From b56c9030f476a751a5c50e16bb71ea183b12818e Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 20 Jan 2026 01:45:31 +0300 Subject: [PATCH 01/67] Update index.html --- public/miniapp/index.html | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/public/miniapp/index.html b/public/miniapp/index.html index 1025629..66d4e21 100644 --- a/public/miniapp/index.html +++ b/public/miniapp/index.html @@ -17117,9 +17117,14 @@ const label = document.createElement('div'); label.className = 'payment-method-label'; - const labelKey = `topup.method.${method.id}.title`; - const labelValue = t(labelKey); - label.textContent = labelValue === labelKey ? method.id : labelValue; + // Используем name из API если есть, иначе локализацию + if (method.name) { + label.textContent = method.name; + } else { + const labelKey = `topup.method.${method.id}.title`; + const labelValue = t(labelKey); + label.textContent = labelValue === labelKey ? method.id : labelValue; + } const description = document.createElement('div'); description.className = 'payment-method-description'; From c0ff915f434f71dbfd3252f32eab29c0f95e72a5 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 20 Jan 2026 02:15:44 +0300 Subject: [PATCH 02/67] Update index.html --- public/miniapp/index.html | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/public/miniapp/index.html b/public/miniapp/index.html index 66d4e21..479f839 100644 --- a/public/miniapp/index.html +++ b/public/miniapp/index.html @@ -12417,6 +12417,7 @@ cachedHappCryptolinkRedirectTemplate = undefined; userData.subscriptionUrl = userData.subscription_url || null; userData.subscriptionCryptoLink = userData.subscription_crypto_link || null; + userData.hideSubscriptionLink = Boolean(userData.hide_subscription_link); userData.referral = userData.referral || null; const happData = payload?.happ; @@ -25097,8 +25098,11 @@ updateInstructionConnectButtons(showConnectButton && anyConnectLink); const subscriptionUrl = getCurrentSubscriptionUrl(); + const hideLink = Boolean(userData?.hideSubscriptionLink || userData?.hide_subscription_link); if (copyBtn) { const hasUrl = Boolean(subscriptionUrl); + // Скрываем кнопку копирования если hide_subscription_link=true + copyBtn.classList.toggle('hidden', hideLink); copyBtn.disabled = !hasUrl || !navigator.clipboard; } } From 64acfbee72a57a3635635166d6de6788834bbe96 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 02:17:22 +0300 Subject: [PATCH 03/67] Add fullscreen functionality: introduce API methods for getting and updating fullscreen settings, implement caching for fullscreen state, and integrate fullscreen toggle in AdminSettings. Remove unused fullscreen icons from Layout component. --- src/api/branding.ts | 21 +++++++++++++++++ src/components/layout/Layout.tsx | 28 +---------------------- src/hooks/useTelegramWebApp.ts | 30 ++++++++++++++++++++++++ src/pages/AdminSettings.tsx | 39 ++++++++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 27 deletions(-) diff --git a/src/api/branding.ts b/src/api/branding.ts index 8d21793..9bfe29e 100644 --- a/src/api/branding.ts +++ b/src/api/branding.ts @@ -11,6 +11,10 @@ export interface AnimationEnabled { enabled: boolean } +export interface FullscreenEnabled { + enabled: boolean +} + const BRANDING_CACHE_KEY = 'cabinet_branding' const LOGO_PRELOADED_KEY = 'cabinet_logo_preloaded' @@ -121,4 +125,21 @@ export const brandingApi = { const response = await apiClient.patch('/cabinet/branding/animation', { enabled }) return response.data }, + + // Get fullscreen enabled (public, no auth required) + getFullscreenEnabled: async (): Promise => { + try { + const response = await apiClient.get('/cabinet/branding/fullscreen') + return response.data + } catch { + // If endpoint doesn't exist, default to disabled + return { enabled: false } + } + }, + + // Update fullscreen enabled (admin only) + updateFullscreenEnabled: async (enabled: boolean): Promise => { + const response = await apiClient.patch('/cabinet/branding/fullscreen', { enabled }) + return response.data + }, } diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index 0c82aee..876f537 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -123,18 +123,6 @@ const WheelIcon = () => ( ) -const FullscreenIcon = () => ( - - - -) - -const ExitFullscreenIcon = () => ( - - - -) - export default function Layout({ children }: LayoutProps) { const { t } = useTranslation() const location = useLocation() @@ -142,7 +130,7 @@ export default function Layout({ children }: LayoutProps) { const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const { toggleTheme, isDark } = useTheme() const [userPhotoUrl, setUserPhotoUrl] = useState(null) - const { isTelegramWebApp, isFullscreen, isFullscreenSupported, toggleFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp() + const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp() // Fetch enabled themes from API - same source of truth as AdminSettings const { data: enabledThemes } = useQuery({ @@ -372,20 +360,6 @@ export default function Layout({ children }: LayoutProps) { {/* Right side */}
- {/* Fullscreen toggle - only show in Telegram WebApp */} - {isTelegramWebApp && isFullscreenSupported && ( - - )} - {/* Theme toggle button - only show if both themes are enabled */} {canToggle && (
+ + {/* Fullscreen Toggle */} +
+
+
+

Авто-Fullscreen

+

+ Автоматически открывать на полный экран в Telegram +

+
+ +
+
{/* Theme Colors Card */} From 86fa0a1b4e2dc87e59edc708aa1bc0ff4ad2f03b Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 20 Jan 2026 02:17:47 +0300 Subject: [PATCH 04/67] Update index.ts --- src/types/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/types/index.ts b/src/types/index.ts index 480fd88..2154f86 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -65,6 +65,7 @@ export interface Subscription { autopay_enabled: boolean autopay_days_before: number subscription_url: string | null + hide_subscription_link: boolean is_active: boolean is_expired: boolean traffic_purchases?: TrafficPurchase[] From 3c9cc0ea9d3631a830e3ebe9e6c9a8e266599589 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 20 Jan 2026 02:22:17 +0300 Subject: [PATCH 05/67] Delete public/miniapp/index.html --- public/miniapp/index.html | 26898 ------------------------------------ 1 file changed, 26898 deletions(-) delete mode 100644 public/miniapp/index.html diff --git a/public/miniapp/index.html b/public/miniapp/index.html deleted file mode 100644 index 479f839..0000000 --- a/public/miniapp/index.html +++ /dev/null @@ -1,26898 +0,0 @@ - - - - - - - Pedzeo VPN - - - - - - - - -
- -
- -
- - - - - - - -
- -
- - - - -
-
-
-
- - -
-
-
-
-
-
-
-
- -
- - - - - - - - -
-
-
-
-
-
-
-
-
-
-
-
-
-
- - - - - - -
- - - - - - - - -
- - From dd85a465953f97793e65b7725b9cbfb514a68b71 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 02:26:25 +0300 Subject: [PATCH 06/67] Refactor ConnectionModal component: update platform icons to a consistent size, enhance loading and error states with improved UI, and streamline platform detection logic. Add new icons for close, back, copy, check, link, and chevron actions. Optimize layout and styling for better user experience. --- src/components/ConnectionModal.tsx | 520 +++++++++++++---------------- 1 file changed, 233 insertions(+), 287 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index 473022d..11c299a 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -10,40 +10,75 @@ interface ConnectionModalProps { // Platform SVG Icons const IosIcon = () => ( - + ) const AndroidIcon = () => ( - + ) const WindowsIcon = () => ( - + ) const MacosIcon = () => ( - - + + ) const LinuxIcon = () => ( - + ) const TvIcon = () => ( - + - + +) + +const CloseIcon = () => ( + + + +) + +const BackIcon = () => ( + + + +) + +const CopyIcon = () => ( + + + +) + +const CheckIcon = () => ( + + + +) + +const LinkIcon = () => ( + + + +) + +const ChevronIcon = () => ( + + ) @@ -64,72 +99,37 @@ const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV // Dangerous schemes that should never be allowed const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:'] -/** - * Validate URL to prevent XSS via javascript: and other dangerous schemes - * Only allows http, https, and known app store URLs - */ function isValidExternalUrl(url: string | undefined): boolean { if (!url) return false const lowerUrl = url.toLowerCase().trim() - - // Block dangerous schemes - const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:'] if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) { return false } - - // Allow only http/https URLs return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://') } -/** - * Validate deep link URL - blocks dangerous schemes only - * Allows any custom app scheme (clash://, hiddify://, etc.) - */ function isValidDeepLink(url: string | undefined): boolean { if (!url) return false const lowerUrl = url.toLowerCase().trim() - - // Block dangerous schemes if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) { return false } - - // Allow any URL that has a scheme (contains ://) return lowerUrl.includes('://') } -// Detect user's platform from user agent function detectPlatform(): string | null { if (typeof window === 'undefined' || !navigator?.userAgent) { return null } - const ua = navigator.userAgent.toLowerCase() - - // Check for mobile devices first - if (/iphone|ipad|ipod/.test(ua)) { - return 'ios' - } + if (/iphone|ipad|ipod/.test(ua)) return 'ios' if (/android/.test(ua)) { - // Check if it's Android TV - if (/tv|television|smart-tv|smarttv/.test(ua)) { - return 'androidTV' - } + if (/tv|television|smart-tv|smarttv/.test(ua)) return 'androidTV' return 'android' } - - // Desktop platforms - if (/macintosh|mac os x/.test(ua)) { - return 'macos' - } - if (/windows/.test(ua)) { - return 'windows' - } - if (/linux/.test(ua)) { - return 'linux' - } - + if (/macintosh|mac os x/.test(ua)) return 'macos' + if (/windows/.test(ua)) return 'windows' + if (/linux/.test(ua)) return 'linux' return null } @@ -145,20 +145,16 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { queryFn: () => subscriptionApi.getAppConfig(), }) - // Auto-detect platform on mount useEffect(() => { - const detected = detectPlatform() - setDetectedPlatform(detected) + setDetectedPlatform(detectPlatform()) }, []) - // Helper to get localized text const getLocalizedText = (text: LocalizedText | undefined): string => { if (!text) return '' const lang = i18n.language || 'en' return text[lang] || text['en'] || text['ru'] || Object.values(text)[0] || '' } - // Get platform name const getPlatformName = (platformKey: string): string => { if (!appConfig?.platformNames?.[platformKey]) { return platformKey @@ -166,13 +162,11 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { return getLocalizedText(appConfig.platformNames[platformKey]) } - // Get available platforms sorted (detected platform first) const availablePlatforms = useMemo(() => { if (!appConfig?.platforms) return [] const available = platformOrder.filter( (key) => appConfig.platforms[key] && appConfig.platforms[key].length > 0 ) - // Move detected platform to the front if (detectedPlatform && available.includes(detectedPlatform)) { const filtered = available.filter(p => p !== detectedPlatform) return [detectedPlatform, ...filtered] @@ -180,13 +174,11 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { return available }, [appConfig, detectedPlatform]) - // Get apps for selected platform const platformApps = useMemo(() => { if (!selectedPlatform || !appConfig?.platforms?.[selectedPlatform]) return [] return appConfig.platforms[selectedPlatform] }, [selectedPlatform, appConfig]) - // Copy subscription link const copySubscriptionLink = async () => { if (!appConfig?.subscriptionUrl) return try { @@ -194,7 +186,6 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { setCopied(true) setTimeout(() => setCopied(false), 2000) } catch { - // Fallback for older browsers const textarea = document.createElement('textarea') textarea.value = appConfig.subscriptionUrl document.body.appendChild(textarea) @@ -206,24 +197,16 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { } } - // Handle deep link click - use miniapp redirect page like in miniapp index.html const handleConnect = (app: AppInfo) => { - // Validate deep link to prevent XSS if (!app.deepLink || !isValidDeepLink(app.deepLink)) { console.warn('Invalid or missing deep link:', app.deepLink) return } - const lang = i18n.language?.startsWith('ru') ? 'ru' : 'en' const redirectUrl = `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(app.deepLink)}&lang=${lang}` - - // Check if it's a custom URL scheme (not http/https) const isCustomScheme = !/^https?:\/\//i.test(app.deepLink) - const tg = (window as any).Telegram?.WebApp - + const tg = (window as unknown as { Telegram?: { WebApp?: { openLink?: (url: string, options?: object) => void } } }).Telegram?.WebApp if (isCustomScheme && tg?.openLink) { - // For custom URL schemes - open redirect page in external browser via Telegram - // try_browser: true - показывает диалог для перехода во внешний браузер (важно для мобильных) try { tg.openLink(redirectUrl, { try_instant_view: false, try_browser: true }) return @@ -231,67 +214,55 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { console.warn('tg.openLink failed:', e) } } - - // Fallback - direct navigation window.location.href = redirectUrl } - // Modal wrapper classes - bottom sheet on mobile, centered on desktop - const modalOverlayClass = "fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-end sm:items-center sm:justify-center" - const modalCardClass = "w-full sm:max-w-lg sm:mx-4 bg-dark-850 sm:rounded-2xl rounded-t-2xl rounded-b-none border-t border-x sm:border border-dark-700/50 shadow-2xl overflow-hidden" - const modalContentClass = "p-4 sm:p-6 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] sm:pb-6" - // Allow the sheet to almost fill the viewport height on phones - const modalScrollableClass = "h-[calc(100vh-1rem)] sm:h-auto sm:max-h-[85vh]" - + // Loading state if (isLoading) { return ( -
-
e.stopPropagation()}> -
-
-
-
+
+
e.stopPropagation()}> +
+
) } + // Error state if (error || !appConfig) { return ( -
-
e.stopPropagation()}> -
-
-

{t('common.error')}

- +
+
e.stopPropagation()}> +
+
+ 😕
+

{t('common.error')}

+
) } + // No subscription if (!appConfig.hasSubscription) { return ( -
-
e.stopPropagation()}> -
-
-

- {t('subscription.connection.title')} -

- -
-
-

{t('subscription.connection.noSubscription')}

+
+
e.stopPropagation()}> +
+
+ 📱
+

{t('subscription.connection.title')}

+

{t('subscription.connection.noSubscription')}

+
@@ -301,75 +272,86 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { // Step 1: Select platform if (!selectedPlatform) { return ( -
-
e.stopPropagation()}> - {/* Header - fixed */} -
-

- {t('subscription.connection.title')} -

-
- {/* Scrollable content */} -
-

{t('subscription.connection.selectDevice')}

- -
+ {/* Content */} +
+
{availablePlatforms.map((platform) => { const IconComponent = platformIconComponents[platform] + const isDetected = platform === detectedPlatform return ( ) })}
- {/* Footer - fixed with safe area */} -
+ {/* Footer */} +
@@ -379,62 +361,65 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { ) } - // Step 2: Select app (if not selected yet) + // Step 2: Select app if (!selectedApp) { return ( -
-
e.stopPropagation()}> - {/* Header - fixed */} -
+
+
e.stopPropagation()} + > + {/* Header */} +
- -

- {getPlatformName(selectedPlatform)} -

+
+

{getPlatformName(selectedPlatform)}

+

{t('subscription.connection.selectApp')}

+
-
- {/* Scrollable content */} -
-

{t('subscription.connection.selectApp')}

- + {/* Content */} +
{platformApps.length === 0 ? ( -
+
+
+ 📭 +

{t('subscription.connection.noApps')}

) : ( -
+
{platformApps.map((app) => ( ))}
@@ -445,46 +430,44 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { ) } - // Step 3: Show app instructions and connect button + // Step 3: App instructions return ( -
-
e.stopPropagation()}> - {/* Header - fixed */} -
+
+
e.stopPropagation()} + > + {/* Header */} +
- -

- {selectedApp.name} -

+
+

{selectedApp.name}

+

Инструкция по подключению

+
-
- {/* Scrollable content */} -
-
+ {/* Content */} +
+
{/* Step 1: Install */} {selectedApp.installationStep && ( -
-

- {t('subscription.connection.installApp')} -

-

+

+
+
1
+

{t('subscription.connection.installApp')}

+
+

{getLocalizedText(selectedApp.installationStep.description)}

{selectedApp.installationStep.buttons && selectedApp.installationStep.buttons.length > 0 && ( -
+
{selectedApp.installationStep.buttons .filter((btn) => isValidExternalUrl(btn.buttonLink)) .map((btn, idx) => ( @@ -495,9 +478,7 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { rel="noopener noreferrer" className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-dark-700 text-dark-200 text-sm hover:bg-dark-600 transition-all" > - - - + {getLocalizedText(btn.buttonText)} ))} @@ -506,88 +487,53 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
)} - {/* Additional before add subscription */} - {selectedApp.additionalBeforeAddSubscriptionStep && ( -
- {selectedApp.additionalBeforeAddSubscriptionStep.title && ( -

- {getLocalizedText(selectedApp.additionalBeforeAddSubscriptionStep.title)} -

- )} -

- {getLocalizedText(selectedApp.additionalBeforeAddSubscriptionStep.description)} -

-
- )} - {/* Step 2: Add subscription */} {selectedApp.addSubscriptionStep && ( -
-

- {t('subscription.connection.addSubscription')} -

-

+

+
+
2
+

{t('subscription.connection.addSubscription')}

+
+

{getLocalizedText(selectedApp.addSubscriptionStep.description)}

- {/* Connect button */} - {selectedApp.deepLink && ( - - )} - - {/* Copy link fallback */} - )} - -
- )} - {/* Additional after add subscription */} - {selectedApp.additionalAfterAddSubscriptionStep && ( -
- {selectedApp.additionalAfterAddSubscriptionStep.title && ( -

- {getLocalizedText(selectedApp.additionalAfterAddSubscriptionStep.title)} -

- )} -

- {getLocalizedText(selectedApp.additionalAfterAddSubscriptionStep.description)} -

+ {/* Copy link */} + +
)} {/* Step 3: Connect */} {selectedApp.connectAndUseStep && ( -
-

- {t('subscription.connection.connectVpn')} -

-

+

+
+
3
+

{t('subscription.connection.connectVpn')}

+
+

{getLocalizedText(selectedApp.connectAndUseStep.description)}

@@ -595,9 +541,9 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
- {/* Footer - fixed with safe area */} -
-
From 6183f7b3e6b1b6a02e07577a6bd73b6471df56b0 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 02:30:11 +0300 Subject: [PATCH 07/67] Refactor ConnectionModal component: adjust padding for modal background and update maximum height for better responsiveness across platforms. --- src/components/ConnectionModal.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index 11c299a..981c4f0 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -272,9 +272,9 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { // Step 1: Select platform if (!selectedPlatform) { return ( -
+
e.stopPropagation()} > {/* Header */} @@ -364,9 +364,9 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { // Step 2: Select app if (!selectedApp) { return ( -
+
e.stopPropagation()} > {/* Header */} @@ -432,9 +432,9 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { // Step 3: App instructions return ( -
+
e.stopPropagation()} > {/* Header */} From 5a7fda6e3aa02254a6501a8a998b7cb5577732a2 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 02:38:02 +0300 Subject: [PATCH 08/67] Refactor ConnectionModal and Referral components: introduce ModalContent and CopyLinkButton helper components for improved structure and reusability. Update referral link handling in Referral component to utilize a dynamic bot username, enhancing link generation and sharing functionality. --- src/components/ConnectionModal.tsx | 629 ++++++++++++++++++----------- src/pages/Referral.tsx | 26 +- 2 files changed, 407 insertions(+), 248 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index 981c4f0..dd1ed08 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -93,6 +93,82 @@ const platformIconComponents: Record = { appleTV: TvIcon, } +// Helper components +interface ModalContentProps { + title: string + subtitle?: string + onClose: () => void + onBack?: () => void + icon?: string + children: React.ReactNode +} + +const ModalContent = ({ title, subtitle, onClose, onBack, icon, children }: ModalContentProps) => ( + <> +
+
+ {onBack && ( + + )} + {icon && !onBack && ( +
+ {icon} +
+ )} +
+

{title}

+ {subtitle &&

{subtitle}

} +
+
+ +
+
+ {children} +
+ +) + +interface CopyLinkButtonProps { + copied: boolean + onClick: () => void + t: (key: string) => string + compact?: boolean +} + +const CopyLinkButton = ({ copied, onClick, t, compact }: CopyLinkButtonProps) => ( + +) + // Platform order for display const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV'] @@ -272,89 +348,120 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { // Step 1: Select platform if (!selectedPlatform) { return ( -
+
+ {/* Desktop: centered modal */} +
+
e.stopPropagation()} + > + +
+ {availablePlatforms.map((platform) => { + const IconComponent = platformIconComponents[platform] + const isDetected = platform === detectedPlatform + return ( + + ) + })} +
+
+
+ +
+
+
+ + {/* Mobile: bottom sheet */}
e.stopPropagation()} > - {/* Header */} -
-
-
- 📱 -
-
-

{t('subscription.connection.title')}

-

{t('subscription.connection.selectDevice')}

-
+
+ {/* Handle */} +
+
- -
- {/* Content */} -
-
- {availablePlatforms.map((platform) => { - const IconComponent = platformIconComponents[platform] - const isDetected = platform === detectedPlatform - return ( - +
+ + {/* Content */} +
+
+ {availablePlatforms.map((platform) => { + const IconComponent = platformIconComponents[platform] + const isDetected = platform === detectedPlatform + return ( + - ) - })} +

+ {getPlatformName(platform)} +

+ + ) + })} +
-
- {/* Footer */} -
- + {/* Footer */} +
+ +
@@ -363,189 +470,235 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { // Step 2: Select app if (!selectedApp) { - return ( -
-
e.stopPropagation()} - > - {/* Header */} -
-
- -
-

{getPlatformName(selectedPlatform)}

-

{t('subscription.connection.selectApp')}

+ const appListContent = platformApps.length === 0 ? ( +
+
+ 📭 +
+

{t('subscription.connection.noApps')}

+
+ ) : ( +
+ {platformApps.map((app) => ( + -
+
+ +
+ + ))} +
+ ) - {/* Content */} -
- {platformApps.length === 0 ? ( -
-
- 📭 + return ( +
+ {/* Desktop */} +
+
e.stopPropagation()}> + setSelectedPlatform(null)}> + {appListContent} + +
+
+ + {/* Mobile */} +
e.stopPropagation()}> +
+
+
+
+
+
+ +
+

{getPlatformName(selectedPlatform)}

+

{t('subscription.connection.selectApp')}

-

{t('subscription.connection.noApps')}

- ) : ( -
- {platformApps.map((app) => ( - - ))} -
- )} + +
+
+ {appListContent} +
) } - // Step 3: App instructions + // Step 3: App instructions - shared content + const instructionsContent = ( +
+ {/* Step 1: Install */} + {selectedApp.installationStep && ( +
+
+
1
+

{t('subscription.connection.installApp')}

+
+

+ {getLocalizedText(selectedApp.installationStep.description)} +

+ {selectedApp.installationStep.buttons && selectedApp.installationStep.buttons.length > 0 && ( +
+ {selectedApp.installationStep.buttons + .filter((btn) => isValidExternalUrl(btn.buttonLink)) + .map((btn, idx) => ( + + + {getLocalizedText(btn.buttonText)} + + ))} +
+ )} +
+ )} + + {/* Step 2: Add subscription */} + {selectedApp.addSubscriptionStep && ( +
+
+
2
+

{t('subscription.connection.addSubscription')}

+
+

+ {getLocalizedText(selectedApp.addSubscriptionStep.description)} +

+ +
+ {/* Connect button */} + {selectedApp.deepLink && ( + + )} + + {/* Copy link */} + +
+
+ )} + + {/* Step 3: Connect */} + {selectedApp.connectAndUseStep && ( +
+
+
3
+

{t('subscription.connection.connectVpn')}

+
+

+ {getLocalizedText(selectedApp.connectAndUseStep.description)} +

+
+ )} +
+ ) + return ( -
+
+ {/* Desktop: centered modal */} +
+
e.stopPropagation()} + > + setSelectedApp(null)} + > + {instructionsContent} + +
+ +
+
+
+ + {/* Mobile: bottom sheet */}
e.stopPropagation()} > - {/* Header */} -
-
- -
-

{selectedApp.name}

-

Инструкция по подключению

+
+ {/* Handle */} +
+
+
+ + {/* Header */} +
+
+ +
+

{selectedApp.name}

+

{t('subscription.connection.instructions')}

+
+
- -
- {/* Content */} -
-
- {/* Step 1: Install */} - {selectedApp.installationStep && ( -
-
-
1
-

{t('subscription.connection.installApp')}

-
-

- {getLocalizedText(selectedApp.installationStep.description)} -

- {selectedApp.installationStep.buttons && selectedApp.installationStep.buttons.length > 0 && ( -
- {selectedApp.installationStep.buttons - .filter((btn) => isValidExternalUrl(btn.buttonLink)) - .map((btn, idx) => ( - - - {getLocalizedText(btn.buttonText)} - - ))} -
- )} -
- )} - - {/* Step 2: Add subscription */} - {selectedApp.addSubscriptionStep && ( -
-
-
2
-

{t('subscription.connection.addSubscription')}

-
-

- {getLocalizedText(selectedApp.addSubscriptionStep.description)} -

- -
- {/* Connect button */} - {selectedApp.deepLink && ( - - )} - - {/* Copy link */} - -
-
- )} - - {/* Step 3: Connect */} - {selectedApp.connectAndUseStep && ( -
-
-
3
-

{t('subscription.connection.connectVpn')}

-
-

- {getLocalizedText(selectedApp.connectAndUseStep.description)} -

-
- )} + {/* Content */} +
+ {instructionsContent}
-
- {/* Footer */} -
- + {/* Footer */} +
+ +
diff --git a/src/pages/Referral.tsx b/src/pages/Referral.tsx index 4526fd2..af3c681 100644 --- a/src/pages/Referral.tsx +++ b/src/pages/Referral.tsx @@ -68,6 +68,12 @@ export default function Referral() { queryFn: referralApi.getReferralInfo, }) + // Build referral link using frontend env variable for correct bot username + const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME + const referralLink = info?.referral_code && botUsername + ? `https://t.me/${botUsername}?start=${info.referral_code}` + : info?.referral_link || '' + const { data: terms } = useQuery({ queryKey: ['referral-terms'], queryFn: referralApi.getReferralTerms, @@ -90,15 +96,15 @@ export default function Referral() { }) const copyLink = () => { - if (info?.referral_link) { - navigator.clipboard.writeText(info.referral_link) + if (referralLink) { + navigator.clipboard.writeText(referralLink) setCopied(true) setTimeout(() => setCopied(false), 2000) } } const shareLink = () => { - if (!info?.referral_link) return + if (!referralLink) return const shareText = t('referral.shareMessage', { percent: info?.commission_percent || 0, botName: branding?.name || import.meta.env.VITE_APP_NAME || 'Cabinet', @@ -109,7 +115,7 @@ export default function Referral() { .share({ title: t('referral.title'), text: shareText, - url: info.referral_link, + url: referralLink, }) .catch(() => { // ignore cancellation errors @@ -118,7 +124,7 @@ export default function Referral() { } const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent( - info.referral_link + referralLink )}&text=${encodeURIComponent(shareText)}` window.open(telegramUrl, '_blank', 'noopener,noreferrer') } @@ -176,16 +182,16 @@ export default function Referral() {
- )} - {icon && !onBack && ( -
- {icon} -
- )} -
-

{title}

- {subtitle &&

{subtitle}

} -
-
- -
-
- {children} -
- -) - -interface CopyLinkButtonProps { - copied: boolean - onClick: () => void - t: (key: string) => string - compact?: boolean -} - -const CopyLinkButton = ({ copied, onClick, t, compact }: CopyLinkButtonProps) => ( - -) - // Platform order for display const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV'] @@ -293,414 +217,316 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { window.location.href = redirectUrl } + // Modal wrapper - centered on all devices + const ModalWrapper = ({ children }: { children: React.ReactNode }) => ( +
+
e.stopPropagation()} + > + {children} +
+
+ ) + // Loading state if (isLoading) { return ( -
-
e.stopPropagation()}> -
-
-
+ +
+
-
+
) } // Error state if (error || !appConfig) { return ( -
-
e.stopPropagation()}> -
-
- 😕 -
-

{t('common.error')}

- + +
+
+ 😕
+

{t('common.error')}

+
-
+ ) } // No subscription if (!appConfig.hasSubscription) { return ( -
-
e.stopPropagation()}> -
-
- 📱 -
-

{t('subscription.connection.title')}

-

{t('subscription.connection.noSubscription')}

- + +
+
+ 📱
+

{t('subscription.connection.title')}

+

{t('subscription.connection.noSubscription')}

+
-
+ ) } // Step 1: Select platform if (!selectedPlatform) { return ( -
- {/* Desktop: centered modal */} -
-
e.stopPropagation()} + + {/* Header */} +
+
+
+ 📱 +
+
+

{t('subscription.connection.title')}

+

{t('subscription.connection.selectDevice')}

+
+
+ +
+ + {/* Platforms grid */} +
+
+ {availablePlatforms.map((platform) => { + const IconComponent = platformIconComponents[platform] + const isDetected = platform === detectedPlatform + return ( + + ) + })} +
+
+ + {/* Copy link */} +
+ - ) - })} -
- -
- -
-
+ {copied ? : } + {copied ? t('subscription.connection.copied') : t('subscription.connection.copyLink')} +
- - {/* Mobile: bottom sheet */} -
e.stopPropagation()} - > -
- {/* Handle */} -
-
-
- - {/* Header */} -
-
-
- 📱 -
-
-

{t('subscription.connection.title')}

-

{t('subscription.connection.selectDevice')}

-
-
- -
- - {/* Content */} -
-
- {availablePlatforms.map((platform) => { - const IconComponent = platformIconComponents[platform] - const isDetected = platform === detectedPlatform - return ( - - ) - })} -
-
- - {/* Footer */} -
- -
-
-
-
+ ) } // Step 2: Select app if (!selectedApp) { - const appListContent = platformApps.length === 0 ? ( -
-
- 📭 -
-

{t('subscription.connection.noApps')}

-
- ) : ( -
- {platformApps.map((app) => ( - - ))} -
- ) - return ( -
- {/* Desktop */} -
-
e.stopPropagation()}> - setSelectedPlatform(null)}> - {appListContent} - + + {/* Header */} +
+
+ +
+

{getPlatformName(selectedPlatform)}

+

{t('subscription.connection.selectApp')}

+
+
- {/* Mobile */} -
e.stopPropagation()}> -
-
-
-
-
-
- -
-

{getPlatformName(selectedPlatform)}

-

{t('subscription.connection.selectApp')}

-
+ {/* Apps list */} +
+ {platformApps.length === 0 ? ( +
+
+ 📭
- +

{t('subscription.connection.noApps')}

-
- {appListContent} -
-
-
-
- ) - } - - // Step 3: App instructions - shared content - const instructionsContent = ( -
- {/* Step 1: Install */} - {selectedApp.installationStep && ( -
-
-
1
-

{t('subscription.connection.installApp')}

-
-

- {getLocalizedText(selectedApp.installationStep.description)} -

- {selectedApp.installationStep.buttons && selectedApp.installationStep.buttons.length > 0 && ( -
- {selectedApp.installationStep.buttons - .filter((btn) => isValidExternalUrl(btn.buttonLink)) - .map((btn, idx) => ( - + {platformApps.map((app) => ( + ))}
)}
- )} - - {/* Step 2: Add subscription */} - {selectedApp.addSubscriptionStep && ( -
-
-
2
-

{t('subscription.connection.addSubscription')}

-
-

- {getLocalizedText(selectedApp.addSubscriptionStep.description)} -

- -
- {/* Connect button */} - {selectedApp.deepLink && ( - - )} - - {/* Copy link */} - -
-
- )} - - {/* Step 3: Connect */} - {selectedApp.connectAndUseStep && ( -
-
-
3
-

{t('subscription.connection.connectVpn')}

-
-

- {getLocalizedText(selectedApp.connectAndUseStep.description)} -

-
- )} -
- ) + + ) + } + // Step 3: App instructions return ( -
- {/* Desktop: centered modal */} -
-
e.stopPropagation()} - > - setSelectedApp(null)} - > - {instructionsContent} - -
- + + {/* Header */} +
+
+ +
+

{selectedApp.name}

+

{t('subscription.connection.instructions') || 'Инструкция'}

+
- {/* Mobile: bottom sheet */} -
e.stopPropagation()} - > -
- {/* Handle */} -
-
-
- - {/* Header */} -
-
- -
-

{selectedApp.name}

-

{t('subscription.connection.instructions')}

+ {/* Instructions */} +
+ {/* Step 1: Install */} + {selectedApp.installationStep && ( +
+
+
1
+
+

{t('subscription.connection.installApp')}

+

+ {getLocalizedText(selectedApp.installationStep.description)} +

+ {selectedApp.installationStep.buttons && selectedApp.installationStep.buttons.length > 0 && ( +
+ {selectedApp.installationStep.buttons + .filter((btn) => isValidExternalUrl(btn.buttonLink)) + .map((btn, idx) => ( + + + {getLocalizedText(btn.buttonText)} + + ))} +
+ )}
-
+ )} - {/* Content */} -
- {instructionsContent} + {/* Step 2: Add subscription */} + {selectedApp.addSubscriptionStep && ( +
+
+
2
+
+

{t('subscription.connection.addSubscription')}

+

+ {getLocalizedText(selectedApp.addSubscriptionStep.description)} +

+
+ {selectedApp.deepLink && ( + + )} + +
+
+
+ )} - {/* Footer */} -
- + {/* Step 3: Connect */} + {selectedApp.connectAndUseStep && ( +
+
+
3
+
+

{t('subscription.connection.connectVpn')}

+

+ {getLocalizedText(selectedApp.connectAndUseStep.description)} +

+
+
-
+ )}
-
+ + {/* Footer */} +
+ +
+ ) } From 075f03e46a7688cba3eb7d38373ffc96c1af2d40 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 02:45:54 +0300 Subject: [PATCH 10/67] Enhance ConnectionModal component: implement body scroll locking when the modal is open, and adjust modal wrapper styling for better alignment on mobile and desktop devices. --- src/components/ConnectionModal.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index 9a6bf13..a42ef56 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -149,6 +149,15 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { setDetectedPlatform(detectPlatform()) }, []) + // Lock body scroll when modal is open + useEffect(() => { + const originalStyle = window.getComputedStyle(document.body).overflow + document.body.style.overflow = 'hidden' + return () => { + document.body.style.overflow = originalStyle + } + }, []) + const getLocalizedText = (text: LocalizedText | undefined): string => { if (!text) return '' const lang = i18n.language || 'en' @@ -217,11 +226,15 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { window.location.href = redirectUrl } - // Modal wrapper - centered on all devices + // Modal wrapper - centered on desktop, top on mobile const ModalWrapper = ({ children }: { children: React.ReactNode }) => (
Date: Tue, 20 Jan 2026 02:49:28 +0300 Subject: [PATCH 11/67] Update ConnectionModal component: remove fallback text for instructions and add missing translation keys for 'instructions' in English and Russian locales. --- src/components/ConnectionModal.tsx | 2 +- src/locales/en.json | 3 ++- src/locales/ru.json | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index a42ef56..fc5a92d 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -438,7 +438,7 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {

{selectedApp.name}

-

{t('subscription.connection.instructions') || 'Инструкция'}

+

{t('subscription.connection.instructions')}

+ )}
-

{t('subscription.connection.title')}

+

{t('subscription.connection.selectApp')}

{t('subscription.connection.selectDevice')}

@@ -404,41 +430,58 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
- {/* Platforms grid */} -
-
- {availablePlatforms.map((platform) => { - const IconComponent = platformIconComponents[platform] - const isDetected = platform === detectedPlatform - return ( - - ) - })} -
+
+
+ {apps.map((app) => ( + + ))} +
+
+ ) + })}
{/* Copy link */} -
+
-
-

{getPlatformName(selectedPlatform)}

-

{t('subscription.connection.selectApp')}

-
-
- -
- - {/* Apps list */} -
- {platformApps.length === 0 ? ( -
-
- 📭 -
-

{t('subscription.connection.noApps')}

-
- ) : ( -
- {platformApps.map((app) => ( - - ))} -
- )} -
- - ) - } - - // Step 3: App instructions + // App instructions (main view) return ( - {/* Header */} + {/* Header with app info and change button */}
-
- -
-

{selectedApp.name}

-

{t('subscription.connection.instructions')}

+
+
+

{selectedApp.name}

+

{t('subscription.connection.changeApp') || 'Сменить'} →

+
+ diff --git a/src/locales/en.json b/src/locales/en.json index 3edb504..92e4e72 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -185,7 +185,8 @@ "copyLink": "Copy subscription link", "copied": "Link copied!", "openLink": "Open link", - "instructions": "Instructions" + "instructions": "Instructions", + "changeApp": "Change app" }, "myDevices": "My Devices", "noDevices": "No connected devices", diff --git a/src/locales/ru.json b/src/locales/ru.json index 01563dc..4987412 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -185,7 +185,8 @@ "copyLink": "Скопировать ссылку", "copied": "Ссылка скопирована!", "openLink": "Открыть ссылку", - "instructions": "Инструкция" + "instructions": "Инструкция", + "changeApp": "Сменить приложение" }, "myDevices": "Мои устройства", "noDevices": "Нет подключенных устройств", From 61eaf30da9727acd15a8a67ee6e359a21896e3dc Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 03:19:06 +0300 Subject: [PATCH 15/67] Enhance Layout component: improve mobile menu scroll locking functionality for cross-platform support, restore original body and HTML styles on menu close, and prevent touchmove events outside of menu content for better user experience. --- src/components/layout/Layout.tsx | 70 +++++++++++++++++++++++++++----- 1 file changed, 59 insertions(+), 11 deletions(-) diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index 876f537..af553b3 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -155,17 +155,58 @@ export default function Layout({ children }: LayoutProps) { } }, []) - // Lock body scroll and scroll to top when mobile menu is open + // Lock body scroll when mobile menu is open (cross-platform) useEffect(() => { - if (mobileMenuOpen) { - // Scroll to top so header is visible - window.scrollTo({ top: 0, behavior: 'instant' }) - document.body.style.overflow = 'hidden' - } else { - document.body.style.overflow = '' + if (!mobileMenuOpen) return + + const scrollY = window.scrollY + const body = document.body + const html = document.documentElement + + // Save original styles + const originalStyles = { + bodyOverflow: body.style.overflow, + bodyPosition: body.style.position, + bodyTop: body.style.top, + bodyWidth: body.style.width, + bodyHeight: body.style.height, + htmlOverflow: html.style.overflow, + htmlHeight: html.style.height, } + + // Lock scroll - works on iOS, Android, and desktop + body.style.overflow = 'hidden' + body.style.position = 'fixed' + body.style.top = `-${scrollY}px` + body.style.width = '100%' + body.style.height = '100%' + html.style.overflow = 'hidden' + html.style.height = '100%' + + // Prevent touchmove on body (extra protection for mobile) + const preventScroll = (e: TouchEvent) => { + const target = e.target as HTMLElement + // Allow scroll inside menu content + if (target.closest('.mobile-menu-content')) return + e.preventDefault() + } + document.addEventListener('touchmove', preventScroll, { passive: false }) + return () => { - document.body.style.overflow = '' + // Restore original styles + body.style.overflow = originalStyles.bodyOverflow + body.style.position = originalStyles.bodyPosition + body.style.top = originalStyles.bodyTop + body.style.width = originalStyles.bodyWidth + body.style.height = originalStyles.bodyHeight + html.style.overflow = originalStyles.htmlOverflow + html.style.height = originalStyles.htmlHeight + + // Remove touchmove listener + document.removeEventListener('touchmove', preventScroll) + + // Restore scroll position + window.scrollTo(0, scrollY) } }, [mobileMenuOpen]) @@ -426,9 +467,16 @@ export default function Layout({ children }: LayoutProps) { - {/* Mobile menu - fixed overlay */} + {/* Mobile menu - fixed overlay below header */} {mobileMenuOpen && ( -
+
{/* Backdrop */}
{/* Menu content */} -
+
{/* User info */}
From 275fe811bb5b515f78e386545d88e191ff2a2319 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 03:26:07 +0300 Subject: [PATCH 16/67] Refactor ConnectionModal and TopUpModal: simplify body scroll locking mechanism, update modal wrapper styling for consistent padding across devices, and enhance responsiveness for improved user experience. --- src/components/ConnectionModal.tsx | 44 +++++++----------------------- src/components/TopUpModal.tsx | 10 +++++-- 2 files changed, 18 insertions(+), 36 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index ce7ac3f..9b78a57 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -231,36 +231,14 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { } }, [appConfig, detectedPlatform, selectedApp]) - // Lock body scroll when modal is open (works on iOS too) + // Prevent body scroll when modal is open useEffect(() => { - const scrollY = window.scrollY - const body = document.body - const html = document.documentElement - - // Save original styles - const originalBodyOverflow = body.style.overflow - const originalBodyPosition = body.style.position - const originalBodyTop = body.style.top - const originalBodyWidth = body.style.width - const originalHtmlOverflow = html.style.overflow - - // Lock scroll - body.style.overflow = 'hidden' - body.style.position = 'fixed' - body.style.top = `-${scrollY}px` - body.style.width = '100%' - html.style.overflow = 'hidden' + // Simple overflow hidden - let the modal handle its own scrolling + const originalOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' return () => { - // Restore original styles - body.style.overflow = originalBodyOverflow - body.style.position = originalBodyPosition - body.style.top = originalBodyTop - body.style.width = originalBodyWidth - html.style.overflow = originalHtmlOverflow - - // Restore scroll position - window.scrollTo(0, scrollY) + document.body.style.overflow = originalOverflow } }, []) @@ -327,21 +305,19 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { window.location.href = redirectUrl } - // Modal wrapper - centered on desktop, top on mobile + // Modal wrapper - centered on all devices const ModalWrapper = ({ children }: { children: React.ReactNode }) => (
e.stopPropagation()} > {children} diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index 056c19c..7d62d10 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -133,10 +133,16 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top const isPending = topUpMutation.isPending || starsPaymentMutation.isPending return ( -
+
-
+
{/* Header */}
{methodName} From 023a1d2ce71469ca37d795b39e2cbb7c9a2696b6 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 03:30:22 +0300 Subject: [PATCH 17/67] Refactor ConnectionModal and TopUpModal: reintroduce app selector logic in ConnectionModal, implement body scroll locking in TopUpModal, and adjust modal styling for improved responsiveness and user experience. --- src/components/ConnectionModal.tsx | 26 +++++++++++++------------- src/components/TopUpModal.tsx | 25 ++++++++++++++++++------- 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index 9b78a57..dae275c 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -267,6 +267,19 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { return available }, [appConfig, detectedPlatform]) + // Get all apps for selector (must be before any conditional returns) + const allAppsForSelector = useMemo(() => { + if (!appConfig?.platforms) return [] + const result: { platform: string; apps: AppInfo[] }[] = [] + for (const platform of availablePlatforms) { + const apps = appConfig.platforms[platform] + if (apps?.length) { + result.push({ platform, apps }) + } + } + return result + }, [appConfig, availablePlatforms]) + const copySubscriptionLink = async () => { if (!appConfig?.subscriptionUrl) return try { @@ -371,19 +384,6 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { ) } - // Get all apps for selector - const allAppsForSelector = useMemo(() => { - if (!appConfig?.platforms) return [] - const result: { platform: string; apps: AppInfo[] }[] = [] - for (const platform of availablePlatforms) { - const apps = appConfig.platforms[platform] - if (apps?.length) { - result.push({ platform, apps }) - } - } - return result - }, [appConfig, availablePlatforms]) - // App selector view if (showAppSelector || !selectedApp) { return ( diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index 7d62d10..7aed26b 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from 'react' +import { useState, useRef, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { useMutation } from '@tanstack/react-query' import { balanceApi } from '../api/balance' @@ -55,6 +55,15 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top ) const popupRef = useRef(null) + // Scroll lock when modal is open + useEffect(() => { + const originalOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + return () => { + document.body.style.overflow = originalOverflow + } + }, []) + const hasOptions = method.options && method.options.length > 0 const minRubles = method.min_amount_kopeks / 100 const maxRubles = method.max_amount_kopeks / 100 @@ -134,15 +143,17 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top return (
-
- -
+
e.stopPropagation()} + > {/* Header */}
{methodName} From 4c821a0f555760a8358e61da423ff20011312323 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 03:33:45 +0300 Subject: [PATCH 18/67] Refactor scroll locking in ConnectionModal and TopUpModal: enhance cross-platform compatibility by saving and restoring original body and HTML styles, ensuring consistent user experience when modals are open. --- src/components/ConnectionModal.tsx | 31 +++++++++++++++++++++++++----- src/components/TopUpModal.tsx | 31 ++++++++++++++++++++++++++---- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index dae275c..21ac366 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -231,14 +231,35 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { } }, [appConfig, detectedPlatform, selectedApp]) - // Prevent body scroll when modal is open + // Scroll lock when modal is open (cross-platform) useEffect(() => { - // Simple overflow hidden - let the modal handle its own scrolling - const originalOverflow = document.body.style.overflow - document.body.style.overflow = 'hidden' + const scrollY = window.scrollY + const body = document.body + const html = document.documentElement + + // Save original styles + const originalStyles = { + bodyOverflow: body.style.overflow, + bodyPosition: body.style.position, + bodyTop: body.style.top, + bodyWidth: body.style.width, + htmlOverflow: html.style.overflow, + } + + // Lock scroll + body.style.overflow = 'hidden' + body.style.position = 'fixed' + body.style.top = `-${scrollY}px` + body.style.width = '100%' + html.style.overflow = 'hidden' return () => { - document.body.style.overflow = originalOverflow + body.style.overflow = originalStyles.bodyOverflow + body.style.position = originalStyles.bodyPosition + body.style.top = originalStyles.bodyTop + body.style.width = originalStyles.bodyWidth + html.style.overflow = originalStyles.htmlOverflow + window.scrollTo(0, scrollY) } }, []) diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index 7aed26b..1a8d193 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -55,12 +55,35 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top ) const popupRef = useRef(null) - // Scroll lock when modal is open + // Scroll lock when modal is open (cross-platform) useEffect(() => { - const originalOverflow = document.body.style.overflow - document.body.style.overflow = 'hidden' + const scrollY = window.scrollY + const body = document.body + const html = document.documentElement + + // Save original styles + const originalStyles = { + bodyOverflow: body.style.overflow, + bodyPosition: body.style.position, + bodyTop: body.style.top, + bodyWidth: body.style.width, + htmlOverflow: html.style.overflow, + } + + // Lock scroll + body.style.overflow = 'hidden' + body.style.position = 'fixed' + body.style.top = `-${scrollY}px` + body.style.width = '100%' + html.style.overflow = 'hidden' + return () => { - document.body.style.overflow = originalOverflow + body.style.overflow = originalStyles.bodyOverflow + body.style.position = originalStyles.bodyPosition + body.style.top = originalStyles.bodyTop + body.style.width = originalStyles.bodyWidth + html.style.overflow = originalStyles.htmlOverflow + window.scrollTo(0, scrollY) } }, []) From 2d8a0bd63afcfe1f75048c7d80bd306e1451284c Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 03:35:58 +0300 Subject: [PATCH 19/67] Add pull to refresh functionality in Layout component: integrate usePullToRefresh hook, implement visual indicator for refresh state, and ensure compatibility with mobile menu interactions for improved user experience. --- src/components/layout/Layout.tsx | 31 ++++++++++ src/hooks/usePullToRefresh.ts | 101 +++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 src/hooks/usePullToRefresh.ts diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index af553b3..8bde09b 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -15,6 +15,7 @@ import { themeColorsApi } from '../../api/themeColors' import { promoApi } from '../../api/promo' import { useTheme } from '../../hooks/useTheme' import { useTelegramWebApp } from '../../hooks/useTelegramWebApp' +import { usePullToRefresh } from '../../hooks/usePullToRefresh' // Fallback branding from environment variables const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet' @@ -132,6 +133,12 @@ export default function Layout({ children }: LayoutProps) { const [userPhotoUrl, setUserPhotoUrl] = useState(null) const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp() + // Pull to refresh (disabled when mobile menu is open) + const { isPulling, pullDistance, isRefreshing, progress } = usePullToRefresh({ + disabled: mobileMenuOpen, + threshold: 80, + }) + // Fetch enabled themes from API - same source of truth as AdminSettings const { data: enabledThemes } = useQuery({ queryKey: ['enabled-themes'], @@ -328,6 +335,30 @@ export default function Layout({ children }: LayoutProps) { {/* Animated Background */} + {/* Pull to refresh indicator */} + {(isPulling || isRefreshing) && ( +
+
+ + + +
+
+ )} + {/* Header */}
void | Promise + threshold?: number // How far to pull before triggering refresh (px) + disabled?: boolean +} + +export function usePullToRefresh({ + onRefresh, + threshold = 80, + disabled = false, +}: UsePullToRefreshOptions = {}) { + const [isPulling, setIsPulling] = useState(false) + const [pullDistance, setPullDistance] = useState(0) + const [isRefreshing, setIsRefreshing] = useState(false) + + const startY = useRef(0) + const currentY = useRef(0) + + const handleRefresh = useCallback(async () => { + if (onRefresh) { + setIsRefreshing(true) + try { + await onRefresh() + } finally { + setIsRefreshing(false) + } + } else { + // Default: reload the page + window.location.reload() + } + }, [onRefresh]) + + useEffect(() => { + if (disabled) return + + const handleTouchStart = (e: TouchEvent) => { + // Only trigger if at top of page + if (window.scrollY > 5) return + + startY.current = e.touches[0].clientY + currentY.current = e.touches[0].clientY + } + + const handleTouchMove = (e: TouchEvent) => { + if (startY.current === 0) return + if (window.scrollY > 5) { + // User scrolled down, reset + startY.current = 0 + setPullDistance(0) + setIsPulling(false) + return + } + + currentY.current = e.touches[0].clientY + const diff = currentY.current - startY.current + + // Only track downward pulls + if (diff > 0) { + // Apply resistance - pull distance is less than actual finger movement + const resistance = 0.4 + const distance = Math.min(diff * resistance, threshold * 1.5) + setPullDistance(distance) + setIsPulling(true) + + // Prevent default scroll if we're pulling + if (distance > 10) { + e.preventDefault() + } + } + } + + const handleTouchEnd = () => { + if (pullDistance >= threshold && !isRefreshing) { + handleRefresh() + } + + startY.current = 0 + setPullDistance(0) + setIsPulling(false) + } + + document.addEventListener('touchstart', handleTouchStart, { passive: true }) + document.addEventListener('touchmove', handleTouchMove, { passive: false }) + document.addEventListener('touchend', handleTouchEnd, { passive: true }) + + return () => { + document.removeEventListener('touchstart', handleTouchStart) + document.removeEventListener('touchmove', handleTouchMove) + document.removeEventListener('touchend', handleTouchEnd) + } + }, [disabled, threshold, pullDistance, isRefreshing, handleRefresh]) + + return { + isPulling, + pullDistance, + isRefreshing, + progress: Math.min(pullDistance / threshold, 1), + } +} From 1a642bb7d42fa004e10ae1b76cce42b3e65227ce Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 03:36:42 +0300 Subject: [PATCH 20/67] Refactor modal components: update styling in ConnectionModal and TopUpModal for improved responsiveness, including adjustments to padding and margins for better compatibility with safe area insets on mobile devices. --- src/components/ConnectionModal.tsx | 14 +++++++------- src/components/TopUpModal.tsx | 12 ++++++------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index 21ac366..e79d9a4 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -342,16 +342,16 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { // Modal wrapper - centered on all devices const ModalWrapper = ({ children }: { children: React.ReactNode }) => (
e.stopPropagation()} > {children} diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index 1a8d193..4333c3e 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -166,15 +166,15 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top return (
e.stopPropagation()} > {/* Header */} From a82e809a34f83773f25dc286b5bdd3523083c90d Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 03:39:10 +0300 Subject: [PATCH 21/67] Refactor modal components: update ConnectionModal and TopUpModal to improve layout with top alignment and auto-focus functionality, enhancing user experience on mobile devices. --- src/components/ConnectionModal.tsx | 13 +++++++------ src/components/TopUpModal.tsx | 15 ++++++++++----- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index e79d9a4..082663d 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -339,18 +339,19 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { window.location.href = redirectUrl } - // Modal wrapper - centered on all devices + // Modal wrapper - top aligned with auto-focus const ModalWrapper = ({ children }: { children: React.ReactNode }) => (
el?.focus()} + tabIndex={-1} + className="w-[calc(100%-2rem)] max-w-sm bg-dark-900 rounded-2xl border border-dark-700/50 overflow-hidden animate-scale-in shadow-2xl flex flex-col outline-none" style={{ - maxHeight: 'calc(100dvh - 4rem)', - marginTop: 'env(safe-area-inset-top, 0px)', - marginBottom: 'env(safe-area-inset-bottom, 0px)', + maxHeight: 'calc(100dvh - 6rem)', }} onClick={(e) => e.stopPropagation()} > diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index 4333c3e..dda3800 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -164,17 +164,22 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top : convertAmount(rub).toFixed(currencyDecimals) const isPending = topUpMutation.isPending || starsPaymentMutation.isPending + // Auto-focus input on mount + useEffect(() => { + const timer = setTimeout(() => { + inputRef.current?.focus() + }, 100) + return () => clearTimeout(timer) + }, []) + return (
e.stopPropagation()} > {/* Header */} From d54279baf7c64e7a32b359aa4a37253410884d58 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 03:42:32 +0300 Subject: [PATCH 22/67] Enhance ConnectionModal: add useRef for modal content scrolling, ensuring smooth transition when switching views, and improve styling for better overflow handling and responsiveness. --- src/components/ConnectionModal.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index 082663d..5a95091 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useEffect } from 'react' +import { useState, useMemo, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useQuery } from '@tanstack/react-query' import { subscriptionApi } from '../api/subscription' @@ -205,6 +205,7 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { const [copied, setCopied] = useState(false) const [detectedPlatform, setDetectedPlatform] = useState(null) const [showAppSelector, setShowAppSelector] = useState(false) + const modalContentRef = useRef(null) const { data: appConfig, isLoading, error } = useQuery({ queryKey: ['appConfig'], @@ -231,6 +232,11 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { } }, [appConfig, detectedPlatform, selectedApp]) + // Scroll modal content to top when switching views + useEffect(() => { + modalContentRef.current?.scrollTo({ top: 0, behavior: 'instant' }) + }, [showAppSelector]) + // Scroll lock when modal is open (cross-platform) useEffect(() => { const scrollY = window.scrollY @@ -342,16 +348,17 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { // Modal wrapper - top aligned with auto-focus const ModalWrapper = ({ children }: { children: React.ReactNode }) => (
el?.focus()} + ref={modalContentRef} tabIndex={-1} - className="w-[calc(100%-2rem)] max-w-sm bg-dark-900 rounded-2xl border border-dark-700/50 overflow-hidden animate-scale-in shadow-2xl flex flex-col outline-none" + className="w-[calc(100%-2rem)] max-w-sm bg-dark-900 rounded-2xl border border-dark-700/50 overflow-y-auto overscroll-contain animate-scale-in shadow-2xl flex flex-col outline-none" style={{ maxHeight: 'calc(100dvh - 6rem)', + WebkitOverflowScrolling: 'touch', }} onClick={(e) => e.stopPropagation()} > From 626a0b8da6816daf2de2a72ec77d8675046c4ba4 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 03:46:27 +0300 Subject: [PATCH 23/67] Enhance scroll locking in ConnectionModal and TopUpModal: improve cross-platform support by adding touchmove event prevention for Android, and update styling to ensure consistent behavior and responsiveness across devices. --- src/components/ConnectionModal.tsx | 23 ++++++++++++++++++++++- src/components/TopUpModal.tsx | 22 +++++++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index 5a95091..f2c28e4 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -237,7 +237,7 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { modalContentRef.current?.scrollTo({ top: 0, behavior: 'instant' }) }, [showAppSelector]) - // Scroll lock when modal is open (cross-platform) + // Scroll lock when modal is open (cross-platform including Android) useEffect(() => { const scrollY = window.scrollY const body = document.body @@ -249,7 +249,10 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { bodyPosition: body.style.position, bodyTop: body.style.top, bodyWidth: body.style.width, + bodyHeight: body.style.height, htmlOverflow: html.style.overflow, + htmlHeight: html.style.height, + bodyTouchAction: body.style.touchAction, } // Lock scroll @@ -257,14 +260,30 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { body.style.position = 'fixed' body.style.top = `-${scrollY}px` body.style.width = '100%' + body.style.height = '100%' + body.style.touchAction = 'none' html.style.overflow = 'hidden' + html.style.height = '100%' + + // Prevent touchmove on backdrop (Android fix) + const preventScroll = (e: TouchEvent) => { + const target = e.target as HTMLElement + // Allow scroll inside modal content + if (target.closest('[data-modal-content]')) return + e.preventDefault() + } + document.addEventListener('touchmove', preventScroll, { passive: false }) return () => { body.style.overflow = originalStyles.bodyOverflow body.style.position = originalStyles.bodyPosition body.style.top = originalStyles.bodyTop body.style.width = originalStyles.bodyWidth + body.style.height = originalStyles.bodyHeight + body.style.touchAction = originalStyles.bodyTouchAction html.style.overflow = originalStyles.htmlOverflow + html.style.height = originalStyles.htmlHeight + document.removeEventListener('touchmove', preventScroll) window.scrollTo(0, scrollY) } }, []) @@ -354,11 +373,13 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { >
e.stopPropagation()} > diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index dda3800..f9c3c5a 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -55,7 +55,7 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top ) const popupRef = useRef(null) - // Scroll lock when modal is open (cross-platform) + // Scroll lock when modal is open (cross-platform including Android) useEffect(() => { const scrollY = window.scrollY const body = document.body @@ -67,7 +67,10 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top bodyPosition: body.style.position, bodyTop: body.style.top, bodyWidth: body.style.width, + bodyHeight: body.style.height, htmlOverflow: html.style.overflow, + htmlHeight: html.style.height, + bodyTouchAction: body.style.touchAction, } // Lock scroll @@ -75,14 +78,29 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top body.style.position = 'fixed' body.style.top = `-${scrollY}px` body.style.width = '100%' + body.style.height = '100%' + body.style.touchAction = 'none' html.style.overflow = 'hidden' + html.style.height = '100%' + + // Prevent touchmove on backdrop (Android fix) + const preventScroll = (e: TouchEvent) => { + const target = e.target as HTMLElement + if (target.closest('[data-modal-content]')) return + e.preventDefault() + } + document.addEventListener('touchmove', preventScroll, { passive: false }) return () => { body.style.overflow = originalStyles.bodyOverflow body.style.position = originalStyles.bodyPosition body.style.top = originalStyles.bodyTop body.style.width = originalStyles.bodyWidth + body.style.height = originalStyles.bodyHeight + body.style.touchAction = originalStyles.bodyTouchAction html.style.overflow = originalStyles.htmlOverflow + html.style.height = originalStyles.htmlHeight + document.removeEventListener('touchmove', preventScroll) window.scrollTo(0, scrollY) } }, []) @@ -179,7 +197,9 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top onClick={onClose} >
e.stopPropagation()} > {/* Header */} From 92b1d30cbbf2372ba3d4d8589214068a5b5ab02a Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 03:52:27 +0300 Subject: [PATCH 24/67] Refactor scroll locking in ConnectionModal and TopUpModal: simplify the implementation by removing unnecessary style manipulations and enhancing event listeners for touch and wheel scroll prevention, ensuring a smoother user experience across devices. --- src/components/ConnectionModal.tsx | 48 ++++++++---------------------- src/components/TopUpModal.tsx | 47 ++++++++--------------------- src/components/layout/Layout.tsx | 17 +++++++---- 3 files changed, 38 insertions(+), 74 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index f2c28e4..f47e64d 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -237,53 +237,31 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { modalContentRef.current?.scrollTo({ top: 0, behavior: 'instant' }) }, [showAppSelector]) - // Scroll lock when modal is open (cross-platform including Android) + // Scroll lock when modal is open useEffect(() => { const scrollY = window.scrollY - const body = document.body - const html = document.documentElement - // Save original styles - const originalStyles = { - bodyOverflow: body.style.overflow, - bodyPosition: body.style.position, - bodyTop: body.style.top, - bodyWidth: body.style.width, - bodyHeight: body.style.height, - htmlOverflow: html.style.overflow, - htmlHeight: html.style.height, - bodyTouchAction: body.style.touchAction, - } - - // Lock scroll - body.style.overflow = 'hidden' - body.style.position = 'fixed' - body.style.top = `-${scrollY}px` - body.style.width = '100%' - body.style.height = '100%' - body.style.touchAction = 'none' - html.style.overflow = 'hidden' - html.style.height = '100%' - - // Prevent touchmove on backdrop (Android fix) + // Prevent all touch/wheel scroll on backdrop const preventScroll = (e: TouchEvent) => { const target = e.target as HTMLElement - // Allow scroll inside modal content if (target.closest('[data-modal-content]')) return e.preventDefault() } + + const preventWheel = (e: WheelEvent) => { + const target = e.target as HTMLElement + if (target.closest('[data-modal-content]')) return + e.preventDefault() + } + document.addEventListener('touchmove', preventScroll, { passive: false }) + document.addEventListener('wheel', preventWheel, { passive: false }) + document.body.style.overflow = 'hidden' return () => { - body.style.overflow = originalStyles.bodyOverflow - body.style.position = originalStyles.bodyPosition - body.style.top = originalStyles.bodyTop - body.style.width = originalStyles.bodyWidth - body.style.height = originalStyles.bodyHeight - body.style.touchAction = originalStyles.bodyTouchAction - html.style.overflow = originalStyles.htmlOverflow - html.style.height = originalStyles.htmlHeight document.removeEventListener('touchmove', preventScroll) + document.removeEventListener('wheel', preventWheel) + document.body.style.overflow = '' window.scrollTo(0, scrollY) } }, []) diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index f9c3c5a..d419365 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -55,52 +55,31 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top ) const popupRef = useRef(null) - // Scroll lock when modal is open (cross-platform including Android) + // Scroll lock when modal is open useEffect(() => { const scrollY = window.scrollY - const body = document.body - const html = document.documentElement - // Save original styles - const originalStyles = { - bodyOverflow: body.style.overflow, - bodyPosition: body.style.position, - bodyTop: body.style.top, - bodyWidth: body.style.width, - bodyHeight: body.style.height, - htmlOverflow: html.style.overflow, - htmlHeight: html.style.height, - bodyTouchAction: body.style.touchAction, - } - - // Lock scroll - body.style.overflow = 'hidden' - body.style.position = 'fixed' - body.style.top = `-${scrollY}px` - body.style.width = '100%' - body.style.height = '100%' - body.style.touchAction = 'none' - html.style.overflow = 'hidden' - html.style.height = '100%' - - // Prevent touchmove on backdrop (Android fix) + // Prevent all touch/wheel scroll on backdrop const preventScroll = (e: TouchEvent) => { const target = e.target as HTMLElement if (target.closest('[data-modal-content]')) return e.preventDefault() } + + const preventWheel = (e: WheelEvent) => { + const target = e.target as HTMLElement + if (target.closest('[data-modal-content]')) return + e.preventDefault() + } + document.addEventListener('touchmove', preventScroll, { passive: false }) + document.addEventListener('wheel', preventWheel, { passive: false }) + document.body.style.overflow = 'hidden' return () => { - body.style.overflow = originalStyles.bodyOverflow - body.style.position = originalStyles.bodyPosition - body.style.top = originalStyles.bodyTop - body.style.width = originalStyles.bodyWidth - body.style.height = originalStyles.bodyHeight - body.style.touchAction = originalStyles.bodyTouchAction - html.style.overflow = originalStyles.htmlOverflow - html.style.height = originalStyles.htmlHeight document.removeEventListener('touchmove', preventScroll) + document.removeEventListener('wheel', preventWheel) + document.body.style.overflow = '' window.scrollTo(0, scrollY) } }, []) diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index 8bde09b..a682c1b 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -370,7 +370,7 @@ export default function Layout({ children }: LayoutProps) {
{/* Logo */} - + setMobileMenuOpen(false)} className={`flex items-center gap-2.5 flex-shrink-0 ${!appName ? 'lg:mr-4' : ''}`}>
{/* Always show letter as fallback */} @@ -435,7 +435,10 @@ export default function Layout({ children }: LayoutProps) { {/* Theme toggle button - only show if both themes are enabled */} {canToggle && ( )} - - +
setMobileMenuOpen(false)}> + +
+
setMobileMenuOpen(false)}> + +
{/* Hide language switcher on mobile when promo is active */} -
+
setMobileMenuOpen(false)}>
From a76803f4ea0bc24753fcd320ab9f8709d8cfd279 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 03:59:17 +0300 Subject: [PATCH 25/67] Improve mobile menu interaction in Layout component: add click handler to close menu on outside click and prevent event propagation for mobile menu button, enhancing user experience and interaction consistency. --- src/components/layout/Layout.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index a682c1b..98d1e69 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -367,7 +367,7 @@ export default function Layout({ children }: LayoutProps) { paddingTop: isFullscreen ? `${Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px` : undefined, }} > -
+
mobileMenuOpen && setMobileMenuOpen(false)}>
{/* Logo */} setMobileMenuOpen(false)} className={`flex items-center gap-2.5 flex-shrink-0 ${!appName ? 'lg:mr-4' : ''}`}> @@ -492,7 +492,10 @@ export default function Layout({ children }: LayoutProps) { {/* Mobile menu button */} + + {/* Mobile safe area spacer - top */} +
+ + {children} + + {/* Mobile safe area spacer - bottom */} +
+
-
- ) + ) + } // Loading state if (isLoading) { return ( -
+
@@ -383,12 +419,12 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { if (error || !appConfig) { return ( -
-
- 😕 +
+
+ 😕
-

{t('common.error')}

-
@@ -400,13 +436,13 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { if (!appConfig.hasSubscription) { return ( -
-
- 📱 +
+
+ 📱
-

{t('subscription.connection.title')}

-

{t('subscription.connection.noSubscription')}

-
@@ -419,7 +455,7 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { return ( {/* Header */} -
+
{selectedApp && ( )}
-

{t('subscription.connection.selectApp')}

-

{t('subscription.connection.selectDevice')}

+

{t('subscription.connection.selectApp')}

+

{t('subscription.connection.selectDevice')}

-
{/* Apps by platform */} -
+
{allAppsForSelector.map(({ platform, apps }) => { const IconComponent = platformIconComponents[platform] const isCurrentPlatform = platform === detectedPlatform @@ -487,10 +523,10 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
{/* Copy link */} -
+
-

{selectedApp.name}

-

{t('subscription.connection.changeApp') || 'Сменить'} →

+

{selectedApp.name}

+

{t('subscription.connection.changeApp') || 'Сменить'} →

-
{/* Instructions */} -
+
{/* Step 1: Install */} {selectedApp.installationStep && (
@@ -614,8 +650,8 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { )}
- {/* Footer */} -
+ {/* Footer - hidden on mobile since we have close button on top */} +
From f90bfc167d8c2ff6e7ff01485e6488153c20104d Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 20 Jan 2026 04:34:49 +0300 Subject: [PATCH 28/67] Update ConnectionModal.tsx --- src/components/ConnectionModal.tsx | 627 +++++++++-------------------- 1 file changed, 187 insertions(+), 440 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index a7a2687..2ad4d28 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -9,56 +9,13 @@ interface ConnectionModalProps { onClose: () => void } -// Platform SVG Icons -const IosIcon = () => ( - - - -) - -const AndroidIcon = () => ( - - - -) - -const WindowsIcon = () => ( - - - -) - -const MacosIcon = () => ( - - - -) - -const LinuxIcon = () => ( - - - -) - -const TvIcon = () => ( - - - - -) - +// Icons const CloseIcon = () => ( ) -const BackIcon = () => ( - - - -) - const CopyIcon = () => ( @@ -77,26 +34,15 @@ const LinkIcon = () => ( ) -const ChevronIcon = () => ( - - +const ChevronDownIcon = () => ( + + ) -// Platform icon components map -const platformIconComponents: Record = { - ios: IosIcon, - android: AndroidIcon, - macos: MacosIcon, - windows: WindowsIcon, - linux: LinuxIcon, - androidTV: TvIcon, - appleTV: TvIcon, -} - // App icons const HappIcon = () => ( - + @@ -107,117 +53,79 @@ const HappIcon = () => ( ) const ClashMetaIcon = () => ( - + - - - - - - -) - -const ClashVergeIcon = () => ( - - - - - ) const ShadowrocketIcon = () => ( - + ) const StreisandIcon = () => ( - - + + ) -// App icon mapping by name (case-insensitive) -const getAppIcon = (appName: string, isFeatured: boolean): React.ReactNode => { +const getAppIcon = (appName: string): React.ReactNode => { const name = appName.toLowerCase() - if (name.includes('happ')) { - return - } - if (name.includes('shadowrocket') || name.includes('rocket')) { - return - } - if (name.includes('streisand')) { - return - } - if (name.includes('verge')) { - return - } - if (name.includes('clash') || name.includes('meta')) { - return - } - // Default icons - return isFeatured ? '⭐' : '📦' + if (name.includes('happ')) return + if (name.includes('shadowrocket') || name.includes('rocket')) return + if (name.includes('streisand')) return + if (name.includes('clash') || name.includes('meta') || name.includes('verge')) return + return 📦 } -// Platform order for display const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV'] -// Dangerous schemes that should never be allowed const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:'] function isValidExternalUrl(url: string | undefined): boolean { if (!url) return false const lowerUrl = url.toLowerCase().trim() - if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) { - return false - } + if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) return false return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://') } function isValidDeepLink(url: string | undefined): boolean { if (!url) return false const lowerUrl = url.toLowerCase().trim() - if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) { - return false - } + if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) return false return lowerUrl.includes('://') } function detectPlatform(): string | null { - if (typeof window === 'undefined' || !navigator?.userAgent) { - return null - } + if (typeof window === 'undefined' || !navigator?.userAgent) return null const ua = navigator.userAgent.toLowerCase() if (/iphone|ipad|ipod/.test(ua)) return 'ios' - if (/android/.test(ua)) { - if (/tv|television|smart-tv|smarttv/.test(ua)) return 'androidTV' - return 'android' - } + if (/android/.test(ua)) return /tv|television/.test(ua) ? 'androidTV' : 'android' if (/macintosh|mac os x/.test(ua)) return 'macos' if (/windows/.test(ua)) return 'windows' if (/linux/.test(ua)) return 'linux' return null } +function isMobile(): boolean { + if (typeof window === 'undefined') return false + return window.innerWidth < 768 +} + export default function ConnectionModal({ onClose }: ConnectionModalProps) { const { t, i18n } = useTranslation() const [selectedApp, setSelectedApp] = useState(null) const [copied, setCopied] = useState(false) const [detectedPlatform, setDetectedPlatform] = useState(null) const [showAppSelector, setShowAppSelector] = useState(false) - const modalContentRef = useRef(null) + const modalRef = useRef(null) - // Telegram Mini App support const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp() - // Calculate safe area - prefer Telegram values, fallback to CSS env - const safeTop = isTelegramWebApp - ? Math.max(safeAreaInset.top, contentSafeAreaInset.top) - : 0 const safeBottom = isTelegramWebApp - ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) - : 0 + ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom, 16) + : 16 const { data: appConfig, isLoading, error } = useQuery({ queryKey: ['appConfig'], @@ -228,54 +136,18 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { setDetectedPlatform(detectPlatform()) }, []) - // Auto-select platform and app when data is loaded useEffect(() => { if (!appConfig?.platforms || selectedApp) return - const platform = detectedPlatform || platformOrder.find(p => appConfig.platforms[p]?.length > 0) if (!platform || !appConfig.platforms[platform]?.length) return - const apps = appConfig.platforms[platform] - // Prefer featured app, otherwise first app const app = apps.find(a => a.isFeatured) || apps[0] - - if (app) { - setSelectedApp(app) - } + if (app) setSelectedApp(app) }, [appConfig, detectedPlatform, selectedApp]) - // Scroll modal content to top when switching views useEffect(() => { - modalContentRef.current?.scrollTo({ top: 0, behavior: 'instant' }) - }, [showAppSelector]) - - // Scroll lock when modal is open - useEffect(() => { - const scrollY = window.scrollY - - // Prevent all touch/wheel scroll on backdrop - const preventScroll = (e: TouchEvent) => { - const target = e.target as HTMLElement - if (target.closest('[data-modal-content]')) return - e.preventDefault() - } - - const preventWheel = (e: WheelEvent) => { - const target = e.target as HTMLElement - if (target.closest('[data-modal-content]')) return - e.preventDefault() - } - - document.addEventListener('touchmove', preventScroll, { passive: false }) - document.addEventListener('wheel', preventWheel, { passive: false }) document.body.style.overflow = 'hidden' - - return () => { - document.removeEventListener('touchmove', preventScroll) - document.removeEventListener('wheel', preventWheel) - document.body.style.overflow = '' - window.scrollTo(0, scrollY) - } + return () => { document.body.style.overflow = '' } }, []) const getLocalizedText = (text: LocalizedText | undefined): string => { @@ -284,34 +156,21 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { return text[lang] || text['en'] || text['ru'] || Object.values(text)[0] || '' } - const getPlatformName = (platformKey: string): string => { - if (!appConfig?.platformNames?.[platformKey]) { - return platformKey - } - return getLocalizedText(appConfig.platformNames[platformKey]) - } - const availablePlatforms = useMemo(() => { if (!appConfig?.platforms) return [] - const available = platformOrder.filter( - (key) => appConfig.platforms[key] && appConfig.platforms[key].length > 0 - ) + const available = platformOrder.filter(key => appConfig.platforms[key]?.length > 0) if (detectedPlatform && available.includes(detectedPlatform)) { - const filtered = available.filter(p => p !== detectedPlatform) - return [detectedPlatform, ...filtered] + return [detectedPlatform, ...available.filter(p => p !== detectedPlatform)] } return available }, [appConfig, detectedPlatform]) - // Get all apps for selector (must be before any conditional returns) - const allAppsForSelector = useMemo(() => { + const allApps = useMemo(() => { if (!appConfig?.platforms) return [] - const result: { platform: string; apps: AppInfo[] }[] = [] + const result: AppInfo[] = [] for (const platform of availablePlatforms) { const apps = appConfig.platforms[platform] - if (apps?.length) { - result.push({ platform, apps }) - } + if (apps?.length) result.push(...apps) } return result }, [appConfig, availablePlatforms]) @@ -335,256 +194,163 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { } const handleConnect = (app: AppInfo) => { - if (!app.deepLink || !isValidDeepLink(app.deepLink)) { - console.warn('Invalid or missing deep link:', app.deepLink) - return - } + if (!app.deepLink || !isValidDeepLink(app.deepLink)) return const lang = i18n.language?.startsWith('ru') ? 'ru' : 'en' const redirectUrl = `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(app.deepLink)}&lang=${lang}` - const isCustomScheme = !/^https?:\/\//i.test(app.deepLink) const tg = (window as unknown as { Telegram?: { WebApp?: { openLink?: (url: string, options?: object) => void } } }).Telegram?.WebApp - if (isCustomScheme && tg?.openLink) { + if (tg?.openLink) { try { tg.openLink(redirectUrl, { try_instant_view: false, try_browser: true }) return - } catch (e) { - console.warn('tg.openLink failed:', e) - } + } catch { /* fallback */ } } window.location.href = redirectUrl } - // Modal wrapper - fullscreen on mobile, centered on desktop - const ModalWrapper = ({ children }: { children: React.ReactNode }) => { - // For Telegram, use JS values; for browser, use CSS env() - const closeButtonTop = isTelegramWebApp - ? `${12 + safeTop}px` - : `calc(0.75rem + env(safe-area-inset-top, 0px))` - - const safeTopStyle = isTelegramWebApp - ? `${safeTop}px` - : 'env(safe-area-inset-top, 0px)' - - const safeBottomStyle = isTelegramWebApp - ? `${safeBottom}px` - : 'env(safe-area-inset-bottom, 0px)' + const mobile = isMobile() || isTelegramWebApp + // Loading + if (isLoading) { return ( -
- {/* Mobile: fullscreen */} -
e.stopPropagation()} - > - {/* Mobile close button - fixed top right */} - - - {/* Mobile safe area spacer - top */} -
- - {children} - - {/* Mobile safe area spacer - bottom */} -
+
+
e.stopPropagation()}> +
) } - // Loading state - if (isLoading) { - return ( - -
-
-
- - ) - } - - // Error state + // Error or no config if (error || !appConfig) { return ( - -
-
- 😕 -
-

{t('common.error')}

- +
+
e.stopPropagation()}> +
😕
+

{t('common.error')}

+
- +
) } // No subscription if (!appConfig.hasSubscription) { return ( - -
-
- 📱 -
-

{t('subscription.connection.title')}

-

{t('subscription.connection.noSubscription')}

- +
+
e.stopPropagation()}> +
📱
+

{t('subscription.connection.title')}

+

{t('subscription.connection.noSubscription')}

+
- - ) - } - - // App selector view - if (showAppSelector || !selectedApp) { - return ( - - {/* Header */} -
-
- {selectedApp && ( - - )} -
-

{t('subscription.connection.selectApp')}

-

{t('subscription.connection.selectDevice')}

-
-
- -
- - {/* Apps by platform */} -
- {allAppsForSelector.map(({ platform, apps }) => { - const IconComponent = platformIconComponents[platform] - const isCurrentPlatform = platform === detectedPlatform - return ( -
-
-
- {IconComponent && } -
- - {getPlatformName(platform)} - {isCurrentPlatform && ({t('subscription.connection.yourDevice')})} - -
-
- {apps.map((app) => ( - - ))} -
-
- ) - })} -
- - {/* Copy link */} -
- -
-
- ) - } - - // App instructions (main view) - return ( - - {/* Header with app info and change button */} -
- -
+ ) + } - {/* Instructions */} -
- {/* Step 1: Install */} - {selectedApp.installationStep && ( -
-
-
1
+ // App selector dropdown + if (showAppSelector) { + return ( +
+
e.stopPropagation()} + > + {/* Handle bar for mobile */} + {mobile &&
} + + {/* Header */} +
+

{t('subscription.connection.selectApp')}

+ +
+ + {/* Apps list */} +
+ {allApps.map(app => ( + + ))} +
+
+
+ ) + } + + // Main view - Instructions + return ( +
+
e.stopPropagation()} + > + {/* Handle bar for mobile */} + {mobile &&
} + + {/* Header with app selector */} +
+ + {!mobile && ( + + )} +
+ + {/* Content */} +
+ {/* Step 1: Install (compact) */} + {selectedApp?.installationStep && ( +
+
1
-

{t('subscription.connection.installApp')}

-

+

{getLocalizedText(selectedApp.installationStep.description)}

- {selectedApp.installationStep.buttons && selectedApp.installationStep.buttons.length > 0 && ( -
- {selectedApp.installationStep.buttons - .filter((btn) => isValidExternalUrl(btn.buttonLink)) - .map((btn, idx) => ( + {selectedApp.installationStep.buttons?.length > 0 && ( +
+ {selectedApp.installationStep.buttons.filter(btn => isValidExternalUrl(btn.buttonLink)).map((btn, idx) => ( {getLocalizedText(btn.buttonText)} @@ -594,68 +360,49 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { )}
-
- )} + )} - {/* Step 2: Add subscription */} - {selectedApp.addSubscriptionStep && ( -
-
-
2
-
-

{t('subscription.connection.addSubscription')}

-

+ {/* Step 2: Connect (main action) */} + {selectedApp?.addSubscriptionStep && ( +

+
2
+
+

{getLocalizedText(selectedApp.addSubscriptionStep.description)}

-
- {selectedApp.deepLink && ( - - )} + {selectedApp.deepLink && ( -
+ )} +
-
- )} + )} - {/* Step 3: Connect */} - {selectedApp.connectAndUseStep && ( -
-
-
3
-
-

{t('subscription.connection.connectVpn')}

-

- {getLocalizedText(selectedApp.connectAndUseStep.description)} -

-
+ {/* Step 3: Ready (minimal) */} + {selectedApp?.connectAndUseStep && ( +
+
3
+

{getLocalizedText(selectedApp.connectAndUseStep.description)}

-
- )} + )} +
- - {/* Footer - hidden on mobile since we have close button on top */} -
- -
- +
) } From 7dd340217fddf8c62f35844b6e7e01fb0e44bde3 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 20 Jan 2026 04:35:56 +0300 Subject: [PATCH 29/67] Update ConnectionModal.tsx --- src/components/ConnectionModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index 2ad4d28..5d27d18 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -342,7 +342,7 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {

{getLocalizedText(selectedApp.installationStep.description)}

- {selectedApp.installationStep.buttons?.length > 0 && ( + {selectedApp.installationStep.buttons && selectedApp.installationStep.buttons.length > 0 && (
{selectedApp.installationStep.buttons.filter(btn => isValidExternalUrl(btn.buttonLink)).map((btn, idx) => ( Date: Tue, 20 Jan 2026 04:44:51 +0300 Subject: [PATCH 30/67] Update ConnectionModal.tsx --- src/components/ConnectionModal.tsx | 395 ++++++++++++++++------------- 1 file changed, 212 insertions(+), 183 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index 5d27d18..4e44848 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useEffect, useRef } from 'react' +import { useState, useMemo, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { useQuery } from '@tanstack/react-query' import { subscriptionApi } from '../api/subscription' @@ -9,40 +9,44 @@ interface ConnectionModalProps { onClose: () => void } -// Icons const CloseIcon = () => ( - + ) const CopyIcon = () => ( - + ) const CheckIcon = () => ( - + ) const LinkIcon = () => ( - + ) -const ChevronDownIcon = () => ( +const ChevronIcon = () => ( ) -// App icons +const BackIcon = () => ( + + + +) + const HappIcon = () => ( - + @@ -53,20 +57,20 @@ const HappIcon = () => ( ) const ClashMetaIcon = () => ( - + ) const ShadowrocketIcon = () => ( - + ) const StreisandIcon = () => ( - - + + ) @@ -76,11 +80,10 @@ const getAppIcon = (appName: string): React.ReactNode => { if (name.includes('shadowrocket') || name.includes('rocket')) return if (name.includes('streisand')) return if (name.includes('clash') || name.includes('meta') || name.includes('verge')) return - return 📦 + return 📦 } const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV'] - const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:'] function isValidExternalUrl(url: string | undefined): boolean { @@ -108,9 +111,15 @@ function detectPlatform(): string | null { return null } -function isMobile(): boolean { - if (typeof window === 'undefined') return false - return window.innerWidth < 768 +function useIsMobile() { + const [isMobile, setIsMobile] = useState(false) + useEffect(() => { + const check = () => setIsMobile(window.innerWidth < 768) + check() + window.addEventListener('resize', check) + return () => window.removeEventListener('resize', check) + }, []) + return isMobile } export default function ConnectionModal({ onClose }: ConnectionModalProps) { @@ -119,13 +128,13 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { const [copied, setCopied] = useState(false) const [detectedPlatform, setDetectedPlatform] = useState(null) const [showAppSelector, setShowAppSelector] = useState(false) - const modalRef = useRef(null) const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp() + const isMobileScreen = useIsMobile() + const isMobile = isMobileScreen || isTelegramWebApp - const safeBottom = isTelegramWebApp - ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom, 16) - : 16 + const safeTop = isTelegramWebApp ? Math.max(safeAreaInset.top, contentSafeAreaInset.top) : 0 + const safeBottom = isTelegramWebApp ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) : 0 const { data: appConfig, isLoading, error } = useQuery({ queryKey: ['appConfig'], @@ -207,202 +216,222 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { window.location.href = redirectUrl } - const mobile = isMobile() || isTelegramWebApp + // Desktop modal wrapper + const DesktopWrapper = ({ children }: { children: React.ReactNode }) => ( +
+
e.stopPropagation()}> + {children} +
+
+ ) + + // Mobile fullscreen wrapper - like React Native Modal with animationType="slide" + const MobileWrapper = ({ children }: { children: React.ReactNode }) => ( + <> + {/* Backdrop */} +
+ {/* Modal - slides from bottom */} +
+ {/* Close button */} + + {children} +
+ + ) + + const Wrapper = isMobile ? MobileWrapper : DesktopWrapper // Loading if (isLoading) { return ( -
-
e.stopPropagation()}> -
+ +
+
-
+
) } - // Error or no config + // Error if (error || !appConfig) { return ( -
-
e.stopPropagation()}> -
😕
-

{t('common.error')}

- + +
+
😕
+

{t('common.error')}

+
-
+ ) } // No subscription if (!appConfig.hasSubscription) { return ( -
-
e.stopPropagation()}> -
📱
-

{t('subscription.connection.title')}

-

{t('subscription.connection.noSubscription')}

- + +
+
📱
+

{t('subscription.connection.title')}

+

{t('subscription.connection.noSubscription')}

+
-
+ ) } - // App selector dropdown + // App selector if (showAppSelector) { return ( -
-
e.stopPropagation()} - > - {/* Handle bar for mobile */} - {mobile &&
} - - {/* Header */} -
-

{t('subscription.connection.selectApp')}

- -
- - {/* Apps list */} -
- {allApps.map(app => ( - - ))} -
+ + {/* Header */} +
+ +

{t('subscription.connection.selectApp')}

-
+ + {/* Apps list */} +
+ {allApps.map(app => ( + + ))} +
+ ) } - // Main view - Instructions + // Main view return ( -
-
e.stopPropagation()} - > - {/* Handle bar for mobile */} - {mobile &&
} + + {/* Header - app selector */} +
+ +
- {/* Header with app selector */} -
- - {!mobile && ( - - )} -
- - {/* Content */} -
- {/* Step 1: Install (compact) */} - {selectedApp?.installationStep && ( -
-
1
-
-

- {getLocalizedText(selectedApp.installationStep.description)} -

- {selectedApp.installationStep.buttons && selectedApp.installationStep.buttons.length > 0 && ( -
- {selectedApp.installationStep.buttons.filter(btn => isValidExternalUrl(btn.buttonLink)).map((btn, idx) => ( - - - {getLocalizedText(btn.buttonText)} - - ))} -
- )} -
-
- )} - - {/* Step 2: Connect (main action) */} - {selectedApp?.addSubscriptionStep && ( -
- )} + )} +
+ )} - {/* Step 3: Ready (minimal) */} - {selectedApp?.connectAndUseStep && ( -
-
3
-

{getLocalizedText(selectedApp.connectAndUseStep.description)}

+ {/* Step 2 */} + {selectedApp?.addSubscriptionStep && ( +
+
+
2
+

{t('subscription.connection.addSubscription')}

- )} -
+

{getLocalizedText(selectedApp.addSubscriptionStep.description)}

+ +
+ {selectedApp.deepLink && ( + + )} + +
+
+ )} + + {/* Step 3 */} + {selectedApp?.connectAndUseStep && ( +
+
+
3
+

{t('subscription.connection.connectVpn')}

+
+

{getLocalizedText(selectedApp.connectAndUseStep.description)}

+
+ )}
-
+ + {/* Desktop footer */} + {!isMobile && ( +
+ +
+ )} + ) } From 0054e674fd9ae5207e10bb4e94103a7e855959a4 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 20 Jan 2026 04:47:45 +0300 Subject: [PATCH 31/67] Update ConnectionModal.tsx --- src/components/ConnectionModal.tsx | 79 +++++++++++++++++++++--------- 1 file changed, 57 insertions(+), 22 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index 4e44848..63850d6 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -293,6 +293,16 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { // App selector if (showAppSelector) { + const platformNames: Record = { + ios: 'iOS', + android: 'Android', + windows: 'Windows', + macos: 'macOS', + linux: 'Linux', + androidTV: 'Android TV', + appleTV: 'Apple TV' + } + return ( {/* Header */} @@ -303,29 +313,54 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {

{t('subscription.connection.selectApp')}

- {/* Apps list */} -
- {allApps.map(app => ( - + ))} +
- {app.name} - {app.isFeatured && ( - - {t('subscription.connection.featured')} - - )} - - ))} + ) + })}
) From 706b2b34f7ebba25830dab77492546095a9fd30e Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 20 Jan 2026 04:48:35 +0300 Subject: [PATCH 32/67] Update ConnectionModal.tsx --- src/components/ConnectionModal.tsx | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index 63850d6..a5a0b45 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -174,16 +174,6 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { return available }, [appConfig, detectedPlatform]) - const allApps = useMemo(() => { - if (!appConfig?.platforms) return [] - const result: AppInfo[] = [] - for (const platform of availablePlatforms) { - const apps = appConfig.platforms[platform] - if (apps?.length) result.push(...apps) - } - return result - }, [appConfig, availablePlatforms]) - const copySubscriptionLink = async () => { if (!appConfig?.subscriptionUrl) return try { From 1403a63df58c5cf9e5d927035ae9a355bbb5f7f0 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 20 Jan 2026 04:52:54 +0300 Subject: [PATCH 33/67] Update AnimatedBackground.tsx --- src/components/AnimatedBackground.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/components/AnimatedBackground.tsx b/src/components/AnimatedBackground.tsx index fe54b1b..0557e2a 100644 --- a/src/components/AnimatedBackground.tsx +++ b/src/components/AnimatedBackground.tsx @@ -4,16 +4,11 @@ import { brandingApi } from '../api/branding' const ANIMATION_CACHE_KEY = 'cabinet_animation_enabled' -// Detect low-performance device (mobile in Telegram WebApp) +// Detect if user prefers reduced motion const isLowPerformance = (): boolean => { - // Check if running in Telegram WebApp - const isTelegramWebApp = !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp - // Check if mobile - const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) - // Check for reduced motion preference + // Only check for reduced motion preference - let animation run everywhere else const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches - - return prefersReducedMotion || (isTelegramWebApp && isMobile) + return prefersReducedMotion } // Get cached value from localStorage From 28b180262fcf0b4de6630a2ac2dbffaca78a5fee Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 20 Jan 2026 04:56:10 +0300 Subject: [PATCH 34/67] Update globals.css --- src/styles/globals.css | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/styles/globals.css b/src/styles/globals.css index 408b8e1..e9465db 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -1246,6 +1246,42 @@ input[type="range"]::-moz-range-thumb { background: radial-gradient(circle, rgba(var(--color-accent-500), 0.7) 0%, transparent 70%); } +/* Mobile: brighter and faster animations */ +@media (max-width: 768px) { + .wave-blob { + opacity: 0.7; + filter: blur(50px); + } + + .wave-blob-1 { + width: 300px; + height: 300px; + background: radial-gradient(circle, rgba(var(--color-accent-500), 0.8) 0%, transparent 70%); + animation: wave1-mobile 12s ease-in-out infinite; + } + + .wave-blob-2 { + width: 280px; + height: 280px; + background: radial-gradient(circle, rgba(59, 130, 246, 0.8) 0%, transparent 70%); + animation: wave2-mobile 15s ease-in-out infinite; + } + + @keyframes wave1-mobile { + 0%, 100% { transform: translate3d(0, 0, 0) scale(1); } + 25% { transform: translate3d(15%, 20%, 0) scale(1.1); } + 50% { transform: translate3d(5%, 35%, 0) scale(1.15); } + 75% { transform: translate3d(20%, 10%, 0) scale(1.05); } + } + + @keyframes wave2-mobile { + 0%, 100% { transform: translate3d(0, 0, 0) scale(1); } + 25% { transform: translate3d(-20%, -15%, 0) scale(1.15); } + 50% { transform: translate3d(-10%, -30%, 0) scale(1.1); } + 75% { transform: translate3d(-25%, -5%, 0) scale(1.05); } + } +} + /* Disable animations for reduced motion preference */ @media (prefers-reduced-motion: reduce) { .wave-blob { From 9a8f6ed4040b0f1da5c5af31cc84f25c89226682 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 20 Jan 2026 05:30:41 +0300 Subject: [PATCH 35/67] Add files via upload --- src/components/ConnectionModal.tsx | 54 +++++++++++++++++------------- src/components/TopUpModal.tsx | 11 ++++-- 2 files changed, 38 insertions(+), 27 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index a5a0b45..6d13dbe 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -1,4 +1,5 @@ import { useState, useMemo, useEffect } from 'react' +import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' import { useQuery } from '@tanstack/react-query' import { subscriptionApi } from '../api/subscription' @@ -133,7 +134,6 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { const isMobileScreen = useIsMobile() const isMobile = isMobileScreen || isTelegramWebApp - const safeTop = isTelegramWebApp ? Math.max(safeAreaInset.top, contentSafeAreaInset.top) : 0 const safeBottom = isTelegramWebApp ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) : 0 const { data: appConfig, isLoading, error } = useQuery({ @@ -216,30 +216,36 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { ) // Mobile fullscreen wrapper - like React Native Modal with animationType="slide" - const MobileWrapper = ({ children }: { children: React.ReactNode }) => ( - <> - {/* Backdrop */} -
- {/* Modal - slides from bottom */} -
- {/* Close button */} - - {children} -
- - ) + {/* Close button */} + + {children} +
+ + ) + + if (typeof document !== 'undefined') { + return createPortal(content, document.body) + } + return content + } const Wrapper = isMobile ? MobileWrapper : DesktopWrapper diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index bbcacdc..14f42d4 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -1,4 +1,5 @@ import { useState, useRef, useEffect } from 'react' +import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' import { useMutation } from '@tanstack/react-query' import { balanceApi } from '../api/balance' @@ -169,11 +170,10 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top return () => clearTimeout(timer) }, []) - return ( + const modalContent = (
) + + if (typeof document !== 'undefined') { + return createPortal(modalContent, document.body) + } + return modalContent } From 2dad00159184d577dca6de361d2a8d5b779d4d68 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 20 Jan 2026 05:40:50 +0300 Subject: [PATCH 36/67] Update subscription.ts --- src/api/subscription.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/api/subscription.ts b/src/api/subscription.ts index 4371ab0..c039ed2 100644 --- a/src/api/subscription.ts +++ b/src/api/subscription.ts @@ -321,4 +321,24 @@ export const subscriptionApi = { const response = await apiClient.put('/cabinet/subscription/traffic', { gb }) return response.data }, + + // Refresh traffic usage from RemnaWave (rate limited: 1 per 60 seconds) + refreshTraffic: async (): Promise<{ + success: boolean + cached: boolean + rate_limited?: boolean + retry_after_seconds?: number + source?: string + traffic_used_bytes: number + traffic_used_gb: number + traffic_limit_bytes: number + traffic_limit_gb: number + traffic_used_percent: number + is_unlimited: boolean + lifetime_used_bytes?: number + lifetime_used_gb?: number + }> => { + const response = await apiClient.post('/cabinet/subscription/refresh-traffic') + return response.data + }, } From 17220097d57e8cd24d0e49f596264adf7da6b1b7 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 20 Jan 2026 05:41:17 +0300 Subject: [PATCH 37/67] Update Dashboard.tsx --- src/pages/Dashboard.tsx | 69 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 5 deletions(-) diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 46208c8..2c20823 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -32,6 +32,12 @@ const ChevronRightIcon = () => ( ) +const RefreshIcon = ({ className = "w-4 h-4" }: { className?: string }) => ( + + + +) + // Check if device might be low-performance (Telegram WebApp on mobile) const isLowPerfDevice = (() => { const isTelegramWebApp = !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp @@ -123,6 +129,47 @@ export default function Dashboard() { }, }) + // Traffic refresh state and mutation + const [trafficRefreshCooldown, setTrafficRefreshCooldown] = useState(0) + const [trafficData, setTrafficData] = useState<{ + traffic_used_gb: number + traffic_used_percent: number + is_unlimited: boolean + } | null>(null) + + const refreshTrafficMutation = useMutation({ + mutationFn: subscriptionApi.refreshTraffic, + onSuccess: (data) => { + setTrafficData({ + traffic_used_gb: data.traffic_used_gb, + traffic_used_percent: data.traffic_used_percent, + is_unlimited: data.is_unlimited, + }) + if (data.rate_limited && data.retry_after_seconds) { + setTrafficRefreshCooldown(data.retry_after_seconds) + } else { + setTrafficRefreshCooldown(60) + } + // Also update subscription query cache + queryClient.invalidateQueries({ queryKey: ['subscription'] }) + }, + onError: (error: { response?: { status?: number; headers?: { get?: (key: string) => string } } }) => { + if (error.response?.status === 429) { + const retryAfter = error.response.headers?.get?.('Retry-After') + setTrafficRefreshCooldown(retryAfter ? parseInt(retryAfter, 10) : 60) + } + }, + }) + + // Cooldown timer + useEffect(() => { + if (trafficRefreshCooldown <= 0) return + const timer = setInterval(() => { + setTrafficRefreshCooldown((prev) => Math.max(0, prev - 1)) + }, 1000) + return () => clearInterval(timer) + }, [trafficRefreshCooldown]) + const hasNoSubscription = !subscription && !subLoading && subError // Show onboarding for new users after data loads @@ -223,9 +270,19 @@ export default function Dashboard() {
-
{t('subscription.traffic')}
+
+ {t('subscription.traffic')} + +
- {subscription.traffic_used_gb.toFixed(1)} / {subscription.traffic_limit_gb || '∞'} GB + {(trafficData?.traffic_used_gb ?? subscription.traffic_used_gb).toFixed(1)} / {subscription.traffic_limit_gb || '∞'} GB
@@ -248,12 +305,14 @@ export default function Dashboard() {
{t('subscription.trafficUsed')} - {subscription.traffic_used_percent.toFixed(1)}% + + {(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent).toFixed(1)}% +
From 56e002337255286198502af3e98ab2fe81810327 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 20 Jan 2026 06:33:20 +0300 Subject: [PATCH 38/67] Update Subscription.tsx --- src/pages/Subscription.tsx | 203 +++++++++++++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 2af8144..1f1426f 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -97,6 +97,8 @@ export default function Subscription() { const [devicesToAdd, setDevicesToAdd] = useState(1) const [showTrafficTopup, setShowTrafficTopup] = useState(false) const [selectedTrafficPackage, setSelectedTrafficPackage] = useState(null) + const [showServerManagement, setShowServerManagement] = useState(false) + const [selectedServersToUpdate, setSelectedServersToUpdate] = useState([]) const { data: subscription, isLoading } = useQuery({ queryKey: ['subscription'], @@ -299,6 +301,31 @@ export default function Subscription() { }, }) + // Countries/servers query + const { data: countriesData, isLoading: countriesLoading } = useQuery({ + queryKey: ['countries'], + queryFn: subscriptionApi.getCountries, + enabled: showServerManagement && !!subscription && !subscription.is_trial, + }) + + // Initialize selected servers when data loads + useEffect(() => { + if (countriesData && showServerManagement) { + const connected = countriesData.countries.filter(c => c.is_connected).map(c => c.uuid) + setSelectedServersToUpdate(connected) + } + }, [countriesData, showServerManagement]) + + // Countries update mutation + const updateCountriesMutation = useMutation({ + mutationFn: (countries: string[]) => subscriptionApi.updateCountries(countries), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['subscription'] }) + queryClient.invalidateQueries({ queryKey: ['countries'] }) + setShowServerManagement(false) + }, + }) + // Auto-scroll to switch tariff modal when it appears useEffect(() => { if (switchTariffId && switchModalRef.current) { @@ -912,6 +939,182 @@ export default function Subscription() { )}
)} + + {/* Server Management */} +
+ {!showServerManagement ? ( + + ) : ( +
+
+

Управление серверами

+ +
+ + {countriesLoading ? ( +
+
+
+ ) : countriesData && countriesData.countries.length > 0 ? ( +
+
+ ✅ — подключено • ➕ — будет добавлено (платно) • ➖ — будет отключено +
+ +
+ {countriesData.countries.map((country) => { + const isCurrentlyConnected = country.is_connected + const isSelected = selectedServersToUpdate.includes(country.uuid) + const willBeAdded = !isCurrentlyConnected && isSelected + const willBeRemoved = isCurrentlyConnected && !isSelected + + return ( + + ) + })} +
+ + {(() => { + const currentConnected = countriesData.countries.filter(c => c.is_connected).map(c => c.uuid) + const added = selectedServersToUpdate.filter(u => !currentConnected.includes(u)) + const removed = currentConnected.filter(u => !selectedServersToUpdate.includes(u)) + const hasChanges = added.length > 0 || removed.length > 0 + + // Calculate cost for added servers + const addedServers = countriesData.countries.filter(c => added.includes(c.uuid)) + const totalCost = addedServers.reduce((sum, s) => sum + s.price_kopeks, 0) + const hasEnoughBalance = !purchaseOptions || totalCost <= purchaseOptions.balance_kopeks + const missingAmount = purchaseOptions ? totalCost - purchaseOptions.balance_kopeks : 0 + + return hasChanges ? ( +
+ {added.length > 0 && ( +
+ Добавить:{' '} + + {addedServers.map(s => s.name).join(', ')} + +
+ )} + {removed.length > 0 && ( +
+ Отключить:{' '} + + {countriesData.countries.filter(c => removed.includes(c.uuid)).map(s => s.name).join(', ')} + +
+ )} + {totalCost > 0 && ( +
+
К оплате (пропорционально оставшимся дням):
+
{formatPrice(totalCost)}
+
+ )} + + {totalCost > 0 && !hasEnoughBalance && missingAmount > 0 && ( + + )} + + +
+ ) : ( +
+ Выберите серверы для подключения или отключения +
+ ) + })()} + + {updateCountriesMutation.isError && ( +
+ {getErrorMessage(updateCountriesMutation.error)} +
+ )} +
+ ) : ( +
+ Нет доступных серверов для управления +
+ )} +
+ )} +
)} From fe9e28dbb797e91fc4c78775efb9e020af3918de Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 20 Jan 2026 06:38:25 +0300 Subject: [PATCH 39/67] Update subscription.ts --- src/api/subscription.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/api/subscription.ts b/src/api/subscription.ts index c039ed2..cc723c1 100644 --- a/src/api/subscription.ts +++ b/src/api/subscription.ts @@ -156,14 +156,19 @@ export const subscriptionApi = { uuid: string name: string country_code: string | null + base_price_kopeks: number price_kopeks: number + price_per_month_kopeks: number price_rubles: number is_available: boolean is_connected: boolean - is_trial_eligible: boolean + has_discount: boolean + discount_percent: number }> connected_count: number has_subscription: boolean + days_left: number + discount_percent: number }> => { const response = await apiClient.get('/cabinet/subscription/countries') return response.data From ea47b09badb77072bb95560c3e917f681319737f Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 20 Jan 2026 06:38:51 +0300 Subject: [PATCH 40/67] Update Subscription.tsx --- src/pages/Subscription.tsx | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 1f1426f..46364f8 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -984,6 +984,12 @@ export default function Subscription() { ✅ — подключено • ➕ — будет добавлено (платно) • ➖ — будет отключено
+ {countriesData.discount_percent > 0 && ( +
+ 🎁 Ваша скидка на серверы: -{countriesData.discount_percent}% +
+ )} +
{countriesData.countries.map((country) => { const isCurrentlyConnected = country.is_connected @@ -1017,10 +1023,32 @@ export default function Subscription() { {willBeAdded ? '➕' : willBeRemoved ? '➖' : isSelected ? '✅' : '⚪'}
-
{country.name}
+
+ {country.name} + {country.has_discount && !isCurrentlyConnected && ( + + -{country.discount_percent}% + + )} +
{willBeAdded && (
- +{formatPrice(country.price_kopeks)}/мес (пропорционально) + +{formatPrice(country.price_kopeks)} (за {countriesData.days_left} дн.) + {country.has_discount && ( + + {formatPrice(Math.round(country.base_price_kopeks * countriesData.days_left / 30))} + + )} +
+ )} + {!willBeAdded && !isCurrentlyConnected && ( +
+ {formatPrice(country.price_per_month_kopeks)}/мес + {country.has_discount && ( + + {formatPrice(country.base_price_kopeks)} + + )}
)} {!country.is_available && !isCurrentlyConnected && ( From 3055254483d0129b23bc60a29aa66c6d1db11eb2 Mon Sep 17 00:00:00 2001 From: Dev Date: Tue, 20 Jan 2026 08:50:39 +0500 Subject: [PATCH 41/67] Fix ConnectionModal positioning for TMA fullscreen mode --- src/components/ConnectionModal.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index 6d13dbe..2c031fb 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -130,11 +130,13 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { const [detectedPlatform, setDetectedPlatform] = useState(null) const [showAppSelector, setShowAppSelector] = useState(false) - const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp() + const { isTelegramWebApp, isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp() const isMobileScreen = useIsMobile() const isMobile = isMobileScreen || isTelegramWebApp const safeBottom = isTelegramWebApp ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) : 0 + // In fullscreen mode, add +45px for Telegram native controls (close/menu buttons in corners) + const safeTop = isTelegramWebApp ? Math.max(safeAreaInset.top, contentSafeAreaInset.top) + (isFullscreen ? 45 : 0) : 0 const { data: appConfig, isLoading, error } = useQuery({ queryKey: ['appConfig'], @@ -226,13 +228,15 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
{/* Close button */} From 7dcfecbb808823532f51cd4810062c3c9f629ddc Mon Sep 17 00:00:00 2001 From: Dev Date: Tue, 20 Jan 2026 09:01:57 +0500 Subject: [PATCH 42/67] Close mobile menu when clicking bottom nav items --- src/components/layout/Layout.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index 98d1e69..8b4e7bf 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -635,6 +635,7 @@ export default function Layout({ children }: LayoutProps) { setMobileMenuOpen(false)} className={isActive(item.path) ? 'bottom-nav-item-active' : 'bottom-nav-item'} > From 3a4f9b66dadc2501a62f40497e3b0ecc2afc3cee Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 20 Jan 2026 07:20:00 +0300 Subject: [PATCH 43/67] Update Layout.tsx --- src/components/layout/Layout.tsx | 36 +++++++++++++------------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index 8b4e7bf..9469e0d 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -163,34 +163,25 @@ export default function Layout({ children }: LayoutProps) { }, []) // Lock body scroll when mobile menu is open (cross-platform) + // Note: We avoid using body position:fixed with top:-scrollY as it causes issues + // in Telegram Mini App where the menu disappears when opened from scrolled position useEffect(() => { if (!mobileMenuOpen) return - const scrollY = window.scrollY const body = document.body const html = document.documentElement // Save original styles const originalStyles = { bodyOverflow: body.style.overflow, - bodyPosition: body.style.position, - bodyTop: body.style.top, - bodyWidth: body.style.width, - bodyHeight: body.style.height, htmlOverflow: html.style.overflow, - htmlHeight: html.style.height, } - // Lock scroll - works on iOS, Android, and desktop + // Lock scroll - simple approach without body position manipulation body.style.overflow = 'hidden' - body.style.position = 'fixed' - body.style.top = `-${scrollY}px` - body.style.width = '100%' - body.style.height = '100%' html.style.overflow = 'hidden' - html.style.height = '100%' - // Prevent touchmove on body (extra protection for mobile) + // Prevent touchmove on body (critical for mobile, especially Telegram Mini App) const preventScroll = (e: TouchEvent) => { const target = e.target as HTMLElement // Allow scroll inside menu content @@ -199,21 +190,22 @@ export default function Layout({ children }: LayoutProps) { } document.addEventListener('touchmove', preventScroll, { passive: false }) + // Also prevent wheel scroll on desktop + const preventWheel = (e: WheelEvent) => { + const target = e.target as HTMLElement + if (target.closest('.mobile-menu-content')) return + e.preventDefault() + } + document.addEventListener('wheel', preventWheel, { passive: false }) + return () => { // Restore original styles body.style.overflow = originalStyles.bodyOverflow - body.style.position = originalStyles.bodyPosition - body.style.top = originalStyles.bodyTop - body.style.width = originalStyles.bodyWidth - body.style.height = originalStyles.bodyHeight html.style.overflow = originalStyles.htmlOverflow - html.style.height = originalStyles.htmlHeight - // Remove touchmove listener + // Remove listeners document.removeEventListener('touchmove', preventScroll) - - // Restore scroll position - window.scrollTo(0, scrollY) + document.removeEventListener('wheel', preventWheel) } }, [mobileMenuOpen]) From 3c5869684f099f283292e1bc6c5acaf87c8080d5 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 20 Jan 2026 07:21:18 +0300 Subject: [PATCH 44/67] Update Subscription.tsx --- src/pages/Subscription.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 46364f8..11e9ef8 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -940,9 +940,10 @@ export default function Subscription() {
)} - {/* Server Management */} -
- {!showServerManagement ? ( + {/* Server Management - only in classic mode */} + {!isTariffsMode && ( +
+ {!showServerManagement ? (
)}
+ )}
)} From 89880195ad1c0db838230c91d8e22ff36d19ac1e Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 20 Jan 2026 07:40:46 +0300 Subject: [PATCH 45/67] Update Layout.tsx --- src/components/layout/Layout.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index 9469e0d..4333c07 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -353,7 +353,7 @@ export default function Layout({ children }: LayoutProps) { {/* Header */}
+ {/* Spacer for fixed header - matches header height */} + {isFullscreen ? ( +
+ ) : ( +
+ )} + {/* Mobile menu - fixed overlay below header */} {mobileMenuOpen && (
Date: Tue, 20 Jan 2026 07:41:20 +0300 Subject: [PATCH 46/67] Update globals.css --- src/styles/globals.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/styles/globals.css b/src/styles/globals.css index e9465db..e756480 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -203,6 +203,10 @@ background-color: rgba(254, 249, 240, 0.5) !important; /* champagne-100/50 */ } + .light .bg-dark-900\/95 { + background-color: rgba(254, 249, 240, 0.95) !important; /* champagne-100/95 - mobile menu sticky header */ + } + .light .bg-dark-950 { background-color: #F7E7CE !important; /* champagne-200 */ } From e797b1e282d4fef7437f3905e7728a0b8a841673 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 20 Jan 2026 13:50:37 +0300 Subject: [PATCH 47/67] Update ConnectionModal.tsx --- src/components/ConnectionModal.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index 2c031fb..a94403e 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -113,10 +113,13 @@ function detectPlatform(): string | null { } function useIsMobile() { - const [isMobile, setIsMobile] = useState(false) + // Initialize synchronously to avoid flash between desktop/mobile layouts + const [isMobile, setIsMobile] = useState(() => { + if (typeof window === 'undefined') return false + return window.innerWidth < 768 + }) useEffect(() => { const check = () => setIsMobile(window.innerWidth < 768) - check() window.addEventListener('resize', check) return () => window.removeEventListener('resize', check) }, []) From d57419e338eea71e5958d2ca057f6b52589d8bc7 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 20 Jan 2026 13:51:08 +0300 Subject: [PATCH 48/67] Update useTelegramWebApp.ts --- src/hooks/useTelegramWebApp.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/hooks/useTelegramWebApp.ts b/src/hooks/useTelegramWebApp.ts index 31f5d01..4fc5b03 100644 --- a/src/hooks/useTelegramWebApp.ts +++ b/src/hooks/useTelegramWebApp.ts @@ -25,13 +25,14 @@ export const setCachedFullscreenEnabled = (enabled: boolean) => { * Provides fullscreen mode, safe area insets, and other WebApp features */ export function useTelegramWebApp() { - const [isFullscreen, setIsFullscreen] = useState(false) - const [isTelegramWebApp, setIsTelegramWebApp] = useState(false) - const [safeAreaInset, setSafeAreaInset] = useState({ top: 0, bottom: 0, left: 0, right: 0 }) - const [contentSafeAreaInset, setContentSafeAreaInset] = useState({ top: 0, bottom: 0, left: 0, right: 0 }) - + // Initialize synchronously to avoid flash/flicker on first render const webApp = typeof window !== 'undefined' ? window.Telegram?.WebApp : undefined + const [isFullscreen, setIsFullscreen] = useState(() => webApp?.isFullscreen || false) + const [isTelegramWebApp, setIsTelegramWebApp] = useState(() => !!webApp) + const [safeAreaInset, setSafeAreaInset] = useState(() => webApp?.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 }) + const [contentSafeAreaInset, setContentSafeAreaInset] = useState(() => webApp?.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 }) + useEffect(() => { if (!webApp) { setIsTelegramWebApp(false) From 89c5726e2c682dcacac211f4d5deaf3a0bb3532c Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 20 Jan 2026 13:51:52 +0300 Subject: [PATCH 49/67] Update index.html --- index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.html b/index.html index 8f9b149..e654320 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,7 @@ - + From 4fc2dbc2d5ccf4d008257e367da0bb437f516e33 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 20 Jan 2026 14:03:10 +0300 Subject: [PATCH 50/67] Update ConnectionModal.tsx --- src/components/ConnectionModal.tsx | 38 ++++++++++++++++++------------ 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index a94403e..6490c0b 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -135,7 +135,8 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { const { isTelegramWebApp, isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp() const isMobileScreen = useIsMobile() - const isMobile = isMobileScreen || isTelegramWebApp + // Use mobile layout only on small screens, even in Telegram Desktop + const isMobile = isMobileScreen const safeBottom = isTelegramWebApp ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) : 0 // In fullscreen mode, add +45px for Telegram native controls (close/menu buttons in corners) @@ -211,10 +212,23 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { window.location.href = redirectUrl } - // Desktop modal wrapper + // Desktop modal wrapper - compact centered modal with max height const DesktopWrapper = ({ children }: { children: React.ReactNode }) => ( -
-
e.stopPropagation()}> +
+
e.stopPropagation()} + > + {/* Desktop close button */} + {children}
@@ -260,7 +274,7 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { if (isLoading) { return ( -
+
@@ -271,7 +285,7 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { if (error || !appConfig) { return ( -
+
😕

{t('common.error')}

@@ -284,7 +298,7 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { if (!appConfig.hasSubscription) { return ( -
+
📱

{t('subscription.connection.title')}

{t('subscription.connection.noSubscription')}

@@ -317,7 +331,7 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
{/* Apps grouped by platform */} -
+
{availablePlatforms.map(platform => { const apps = appConfig.platforms[platform] if (!apps?.length) return null @@ -390,7 +404,7 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
{/* Content */} -
+
{/* Step 1 */} {selectedApp?.installationStep && (
@@ -464,12 +478,6 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { )}
- {/* Desktop footer */} - {!isMobile && ( -
- -
- )} ) } From 70f9830e79a62324b758fa55dadf39b8b3a8e5ea Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 20 Jan 2026 14:08:52 +0300 Subject: [PATCH 51/67] Update ConnectionModal.tsx --- src/components/ConnectionModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index 6490c0b..c1563c8 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -215,7 +215,7 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { // Desktop modal wrapper - compact centered modal with max height const DesktopWrapper = ({ children }: { children: React.ReactNode }) => (
Date: Tue, 20 Jan 2026 14:29:39 +0300 Subject: [PATCH 52/67] Update client.ts --- src/api/client.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/api/client.ts b/src/api/client.ts index 808184c..b1d4714 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -87,6 +87,31 @@ apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) => return config }) +// Custom error types for special handling +export interface MaintenanceError { + code: 'maintenance' + message: string + reason?: string +} + +export interface ChannelSubscriptionError { + code: 'channel_subscription_required' + message: string + channel_link?: string +} + +export function isMaintenanceError(error: unknown): error is { response: { status: 503, data: { detail: MaintenanceError } } } { + if (!error || typeof error !== 'object') return false + const err = error as AxiosError<{ detail: MaintenanceError }> + return err.response?.status === 503 && err.response?.data?.detail?.code === 'maintenance' +} + +export function isChannelSubscriptionError(error: unknown): error is { response: { status: 403, data: { detail: ChannelSubscriptionError } } } { + if (!error || typeof error !== 'object') return false + const err = error as AxiosError<{ detail: ChannelSubscriptionError }> + return err.response?.status === 403 && err.response?.data?.detail?.code === 'channel_subscription_required' +} + // Response interceptor - handle 401 as fallback apiClient.interceptors.response.use( (response) => response, From 2836d1faf58367ffd4ee43ebc6103f1b7c9dc3f1 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 20 Jan 2026 14:38:42 +0300 Subject: [PATCH 53/67] Add files via upload --- src/store/blocking.ts | 47 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/store/blocking.ts diff --git a/src/store/blocking.ts b/src/store/blocking.ts new file mode 100644 index 0000000..10378bd --- /dev/null +++ b/src/store/blocking.ts @@ -0,0 +1,47 @@ +import { create } from 'zustand' + +export type BlockingType = 'maintenance' | 'channel_subscription' | null + +interface MaintenanceInfo { + message: string + reason?: string +} + +interface ChannelSubscriptionInfo { + message: string + channel_link?: string +} + +interface BlockingState { + blockingType: BlockingType + maintenanceInfo: MaintenanceInfo | null + channelInfo: ChannelSubscriptionInfo | null + + setMaintenance: (info: MaintenanceInfo) => void + setChannelSubscription: (info: ChannelSubscriptionInfo) => void + clearBlocking: () => void +} + +export const useBlockingStore = create((set) => ({ + blockingType: null, + maintenanceInfo: null, + channelInfo: null, + + setMaintenance: (info) => set({ + blockingType: 'maintenance', + maintenanceInfo: info, + channelInfo: null, + }), + + setChannelSubscription: (info) => set({ + blockingType: 'channel_subscription', + channelInfo: info, + maintenanceInfo: null, + }), + + clearBlocking: () => set({ + blockingType: null, + maintenanceInfo: null, + channelInfo: null, + }), +})) From 1617f676ee691bc74081ca03bbad8dbfcbc34dbe Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 20 Jan 2026 14:39:22 +0300 Subject: [PATCH 54/67] Add files via upload --- .../blocking/ChannelSubscriptionScreen.tsx | 154 ++++++++++++++++++ src/components/blocking/MaintenanceScreen.tsx | 65 ++++++++ src/components/blocking/index.ts | 2 + 3 files changed, 221 insertions(+) create mode 100644 src/components/blocking/ChannelSubscriptionScreen.tsx create mode 100644 src/components/blocking/MaintenanceScreen.tsx create mode 100644 src/components/blocking/index.ts diff --git a/src/components/blocking/ChannelSubscriptionScreen.tsx b/src/components/blocking/ChannelSubscriptionScreen.tsx new file mode 100644 index 0000000..bdc1ea3 --- /dev/null +++ b/src/components/blocking/ChannelSubscriptionScreen.tsx @@ -0,0 +1,154 @@ +import { useState, useEffect, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useBlockingStore } from '../../store/blocking' +import { apiClient } from '../../api/client' + +const CHECK_COOLDOWN_SECONDS = 5 + +export default function ChannelSubscriptionScreen() { + const { t } = useTranslation() + const { channelInfo, clearBlocking } = useBlockingStore() + const [isChecking, setIsChecking] = useState(false) + const [cooldown, setCooldown] = useState(0) + const [error, setError] = useState(null) + + // Cooldown timer + useEffect(() => { + if (cooldown <= 0) return + + const timer = setInterval(() => { + setCooldown((prev) => { + if (prev <= 1) { + clearInterval(timer) + return 0 + } + return prev - 1 + }) + }, 1000) + + return () => clearInterval(timer) + }, [cooldown]) + + const openChannel = useCallback(() => { + if (channelInfo?.channel_link) { + window.open(channelInfo.channel_link, '_blank') + } + }, [channelInfo?.channel_link]) + + const checkSubscription = useCallback(async () => { + if (isChecking || cooldown > 0) return + + setIsChecking(true) + setError(null) + + try { + // Make any authenticated request - if channel check passes, it will succeed + await apiClient.get('/cabinet/auth/me') + // If we get here, subscription is valid + clearBlocking() + } catch (err: unknown) { + // Check if it's still a channel subscription error + const error = err as { response?: { status?: number; data?: { detail?: { code?: string } } } } + if (error.response?.status === 403 && error.response?.data?.detail?.code === 'channel_subscription_required') { + setError(t('blocking.channel.notSubscribed', 'Вы ещё не подписались на канал')) + } else { + // Other error - might be network issue + setError(t('blocking.channel.checkError', 'Ошибка проверки. Попробуйте позже.')) + } + } finally { + setIsChecking(false) + setCooldown(CHECK_COOLDOWN_SECONDS) + } + }, [isChecking, cooldown, clearBlocking, t]) + + return ( +
+
+ {/* Icon */} +
+
+ + + +
+
+ + {/* Title */} +

+ {t('blocking.channel.title', 'Подписка на канал')} +

+ + {/* Message */} +

+ {channelInfo?.message || t('blocking.channel.defaultMessage', 'Для продолжения работы подпишитесь на наш канал')} +

+ + {/* Error message */} + {error && ( +
+

{error}

+
+ )} + + {/* Buttons */} +
+ {/* Open channel button */} + + + {/* Check subscription button */} + +
+ + {/* Hint */} +

+ {t('blocking.channel.hint', 'После подписки нажмите кнопку проверки')} +

+
+
+ ) +} diff --git a/src/components/blocking/MaintenanceScreen.tsx b/src/components/blocking/MaintenanceScreen.tsx new file mode 100644 index 0000000..a7a052b --- /dev/null +++ b/src/components/blocking/MaintenanceScreen.tsx @@ -0,0 +1,65 @@ +import { useTranslation } from 'react-i18next' +import { useBlockingStore } from '../../store/blocking' + +export default function MaintenanceScreen() { + const { t } = useTranslation() + const { maintenanceInfo } = useBlockingStore() + + return ( +
+
+ {/* Icon */} +
+
+ + + +
+
+ + {/* Title */} +

+ {t('blocking.maintenance.title', 'Технические работы')} +

+ + {/* Message */} +

+ {maintenanceInfo?.message || t('blocking.maintenance.defaultMessage', 'Сервис временно недоступен. Проводятся технические работы.')} +

+ + {/* Reason */} + {maintenanceInfo?.reason && ( +
+

+ {t('blocking.maintenance.reason', 'Причина')}: +

+

+ {maintenanceInfo.reason} +

+
+ )} + + {/* Decorative dots */} +
+
+
+
+
+ +

+ {t('blocking.maintenance.waitMessage', 'Пожалуйста, подождите...')} +

+
+
+ ) +} diff --git a/src/components/blocking/index.ts b/src/components/blocking/index.ts new file mode 100644 index 0000000..c04dc4e --- /dev/null +++ b/src/components/blocking/index.ts @@ -0,0 +1,2 @@ +export { default as MaintenanceScreen } from './MaintenanceScreen' +export { default as ChannelSubscriptionScreen } from './ChannelSubscriptionScreen' From 5930de6275cfef3cd24e086f5d157c990c5a63f3 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 20 Jan 2026 14:40:13 +0300 Subject: [PATCH 55/67] Update client.ts --- src/api/client.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/api/client.ts b/src/api/client.ts index b1d4714..e65dc3f 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,5 +1,6 @@ import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios' import { tokenStorage, isTokenExpired, tokenRefreshManager, safeRedirectToLogin } from '../utils/token' +import { useBlockingStore } from '../store/blocking' const API_BASE_URL = import.meta.env.VITE_API_URL || '/api' @@ -112,12 +113,32 @@ export function isChannelSubscriptionError(error: unknown): error is { response: return err.response?.status === 403 && err.response?.data?.detail?.code === 'channel_subscription_required' } -// Response interceptor - handle 401 as fallback +// Response interceptor - handle 401, 503 (maintenance), 403 (channel subscription) apiClient.interceptors.response.use( (response) => response, async (error: AxiosError) => { const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean } + // Handle maintenance mode (503) + if (isMaintenanceError(error)) { + const detail = (error.response?.data as { detail: MaintenanceError }).detail + useBlockingStore.getState().setMaintenance({ + message: detail.message, + reason: detail.reason, + }) + return Promise.reject(error) + } + + // Handle channel subscription required (403) + if (isChannelSubscriptionError(error)) { + const detail = (error.response?.data as { detail: ChannelSubscriptionError }).detail + useBlockingStore.getState().setChannelSubscription({ + message: detail.message, + channel_link: detail.channel_link, + }) + return Promise.reject(error) + } + // Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала) if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true From cada738879e3cd0b034fd2df8aae39d3546957fc Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 20 Jan 2026 14:41:08 +0300 Subject: [PATCH 56/67] Update App.tsx --- src/App.tsx | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index d827e18..4d87f50 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,10 @@ import { lazy, Suspense } from 'react' import { Routes, Route, Navigate, useLocation } from 'react-router-dom' import { useAuthStore } from './store/auth' +import { useBlockingStore } from './store/blocking' import Layout from './components/layout/Layout' import PageLoader from './components/common/PageLoader' +import { MaintenanceScreen, ChannelSubscriptionScreen } from './components/blocking' import { saveReturnUrl } from './utils/token' // Auth pages - load immediately (small) @@ -89,9 +91,25 @@ function LazyPage({ children }: { children: React.ReactNode }) { ) } +function BlockingOverlay() { + const { blockingType } = useBlockingStore() + + if (blockingType === 'maintenance') { + return + } + + if (blockingType === 'channel_subscription') { + return + } + + return null +} + function App() { return ( - + <> + + {/* Public routes */} } /> } /> @@ -316,6 +334,7 @@ function App() { {/* Catch all */} } /> + ) } From b777e9b1a180a5b30cea82043319623b3c8bf141 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 20 Jan 2026 14:41:49 +0300 Subject: [PATCH 57/67] Add files via upload --- src/locales/en.json | 22 +++++++++++++++++++++- src/locales/fa.json | 19 +++++++++++++++++++ src/locales/ru.json | 22 +++++++++++++++++++++- src/locales/zh.json | 22 +++++++++++++++++++++- 4 files changed, 82 insertions(+), 3 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index 92e4e72..a2501e5 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -33,7 +33,8 @@ "contests": "Contests", "polls": "Polls", "info": "Info", - "wheel": "Fortune Wheel" + "wheel": "Fortune Wheel", + "menu": "Menu" }, "notifications": { "ticketNotifications": "Ticket Notifications", @@ -1314,5 +1315,24 @@ "hours": "h", "minutes": "m" } + }, + "blocking": { + "maintenance": { + "title": "Maintenance", + "defaultMessage": "Service is temporarily unavailable. Maintenance in progress.", + "reason": "Reason", + "waitMessage": "Please wait..." + }, + "channel": { + "title": "Channel Subscription", + "defaultMessage": "Please subscribe to our channel to continue", + "openChannel": "Open Channel", + "checkSubscription": "Check Subscription", + "checking": "Checking...", + "waitSeconds": "Wait {{seconds}} sec.", + "hint": "Click check button after subscribing", + "notSubscribed": "You haven't subscribed to the channel yet", + "checkError": "Check failed. Please try again later." + } } } diff --git a/src/locales/fa.json b/src/locales/fa.json index c0f1918..2c14f63 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -794,5 +794,24 @@ "hours": "ساعت", "minutes": "دقیقه" } + }, + "blocking": { + "maintenance": { + "title": "تعمیرات", + "defaultMessage": "سرویس موقتاً در دسترس نیست. تعمیرات در حال انجام است.", + "reason": "دلیل", + "waitMessage": "لطفاً صبر کنید..." + }, + "channel": { + "title": "اشتراک کانال", + "defaultMessage": "لطفاً برای ادامه در کانال ما عضو شوید", + "openChannel": "باز کردن کانال", + "checkSubscription": "بررسی اشتراک", + "checking": "در حال بررسی...", + "waitSeconds": "{{seconds}} ثانیه صبر کنید", + "hint": "پس از عضویت دکمه بررسی را بزنید", + "notSubscribed": "شما هنوز در کانال عضو نشده‌اید", + "checkError": "بررسی ناموفق بود. لطفاً بعداً دوباره امتحان کنید." + } } } \ No newline at end of file diff --git a/src/locales/ru.json b/src/locales/ru.json index 4987412..847c0b2 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -33,7 +33,8 @@ "contests": "Конкурсы", "polls": "Опросы", "info": "Информация", - "wheel": "Колесо удачи" + "wheel": "Колесо удачи", + "menu": "Меню" }, "notifications": { "ticketNotifications": "Уведомления о тикетах", @@ -1472,5 +1473,24 @@ "hours": "ч", "minutes": "м" } + }, + "blocking": { + "maintenance": { + "title": "Технические работы", + "defaultMessage": "Сервис временно недоступен. Проводятся технические работы.", + "reason": "Причина", + "waitMessage": "Пожалуйста, подождите..." + }, + "channel": { + "title": "Подписка на канал", + "defaultMessage": "Для продолжения работы подпишитесь на наш канал", + "openChannel": "Открыть канал", + "checkSubscription": "Проверить подписку", + "checking": "Проверяем...", + "waitSeconds": "Подождите {{seconds}} сек.", + "hint": "После подписки нажмите кнопку проверки", + "notSubscribed": "Вы ещё не подписались на канал", + "checkError": "Ошибка проверки. Попробуйте позже." + } } } diff --git a/src/locales/zh.json b/src/locales/zh.json index 8ce491a..afdaf2c 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -31,7 +31,8 @@ "contests": "活动", "polls": "问卷", "info": "信息", - "wheel": "幸运转盘" + "wheel": "幸运转盘", + "menu": "菜单" }, "notifications": { "ticketNotifications": "工单通知", @@ -794,5 +795,24 @@ "hours": "时", "minutes": "分" } + }, + "blocking": { + "maintenance": { + "title": "系统维护", + "defaultMessage": "服务暂时不可用。正在进行系统维护。", + "reason": "原因", + "waitMessage": "请稍候..." + }, + "channel": { + "title": "频道订阅", + "defaultMessage": "请订阅我们的频道以继续", + "openChannel": "打开频道", + "checkSubscription": "检查订阅", + "checking": "检查中...", + "waitSeconds": "请等待 {{seconds}} 秒", + "hint": "订阅后点击检查按钮", + "notSubscribed": "您还没有订阅该频道", + "checkError": "检查失败。请稍后重试。" + } } } \ No newline at end of file From d23097b4e3ea46de914f55ec6ce445e2ef7aeabf Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 20 Jan 2026 14:43:46 +0300 Subject: [PATCH 58/67] Update ChannelSubscriptionScreen.tsx --- src/components/blocking/ChannelSubscriptionScreen.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/blocking/ChannelSubscriptionScreen.tsx b/src/components/blocking/ChannelSubscriptionScreen.tsx index bdc1ea3..98788f4 100644 --- a/src/components/blocking/ChannelSubscriptionScreen.tsx +++ b/src/components/blocking/ChannelSubscriptionScreen.tsx @@ -44,8 +44,9 @@ export default function ChannelSubscriptionScreen() { try { // Make any authenticated request - if channel check passes, it will succeed await apiClient.get('/cabinet/auth/me') - // If we get here, subscription is valid + // If we get here, subscription is valid - reload page clearBlocking() + window.location.reload() } catch (err: unknown) { // Check if it's still a channel subscription error const error = err as { response?: { status?: number; data?: { detail?: { code?: string } } } } From 6d88bac69301d3b355d642f8b78543f67f821084 Mon Sep 17 00:00:00 2001 From: Dev Date: Tue, 20 Jan 2026 21:10:20 +0500 Subject: [PATCH 59/67] feat(ui): implement Bento Grid design system - Add Urbanist font (replace Inter) - Add CSS variables: --bento-radius, --bento-gap, --bento-padding - Add Tailwind extend: rounded-bento, rounded-4xl, spacing.bento - Create BentoCard component with size variants (sm/md/lg/xl) - Add .bento-card, .bento-card-hover, .bento-card-glow classes - Add .bento-grid container with responsive columns - Implement floating TabBar (margin 16px, shadow, pill active state) - Apply bento styles to Dashboard cards - Support both dark and light themes --- .ai/BENTO_REFACTOR.md | 150 ++++++++++++++++++++++++++++++++ src/components/ui/BentoCard.tsx | 115 ++++++++++++++++++++++++ src/pages/Dashboard.tsx | 18 ++-- src/styles/globals.css | 108 ++++++++++++++++++++--- tailwind.config.js | 10 ++- 5 files changed, 380 insertions(+), 21 deletions(-) create mode 100644 .ai/BENTO_REFACTOR.md create mode 100644 src/components/ui/BentoCard.tsx diff --git a/.ai/BENTO_REFACTOR.md b/.ai/BENTO_REFACTOR.md new file mode 100644 index 0000000..93d1e71 --- /dev/null +++ b/.ai/BENTO_REFACTOR.md @@ -0,0 +1,150 @@ +# Bento Grids Refactor + +> **Ветка:** `bento-grids` +> **Статус:** В работе +> **Последнее обновление:** 2026-01-20 + +## Референс + +- **Стиль:** Bento Grids (карточки разных размеров в сетке) +- **Цветовая схема:** Зеленый неон на темном фоне, чистота +- **Шрифт:** Urbanist (современный гротеск) +- **Скругления:** Крупные (24-32px) + +--- + +## План рефакторинга + +### Этап 1: Фундамент (Vibe Check) +> Цель: Пустая страница уже выглядит "секси" + +| # | Задача | Статус | Заметки | +|---|--------|--------|---------| +| 1.1 | Подключить Urbanist шрифт | `[x]` | Google Fonts, заменить Inter | +| 1.2 | Tailwind: bento radius/spacing | `[x]` | `rounded-bento`, `rounded-4xl`, `spacing.bento` | +| 1.3 | globals.css: CSS переменные Bento | `[x]` | `--bento-radius`, `--bento-gap`, `--bento-padding` | + +**Файлы:** +- `src/styles/globals.css` +- `tailwind.config.js` + +--- + +### Этап 2: Атом (BentoCard) +> Цель: Один идеальный компонент-карточка + +| # | Задача | Статус | Заметки | +|---|--------|--------|---------| +| 2.1 | Создать BentoCard компонент | `[x]` | С вариантами размеров | +| 2.2 | Обновить .card в globals.css | `[x]` | Новые классы .bento-card, .bento-card-hover, .bento-card-glow | + +**Файлы:** +- `src/components/ui/BentoCard.tsx` (новый) +- `src/styles/globals.css` + +**API компонента:** +```tsx +type BentoSize = 'sm' | 'md' | 'lg' | 'xl' // 1x1, 2x1, 1x2, 2x2 + +interface BentoCardProps { + size?: BentoSize + children: React.ReactNode + className?: string + hover?: boolean + as?: 'div' | 'Link' | 'button' +} +``` + +--- + +### Этап 3: Скелет (Floating TabBar) +> Цель: Bottom navigation как "парящий остров" + +| # | Задача | Статус | Заметки | +|---|--------|--------|---------| +| 3.1 | Floating island стиль | `[x]` | margin 16px от краев, shadow | +| 3.2 | Active state с pill | `[x]` | rounded-2xl фон под активной иконкой | +| 3.3 | Убрать border-top | `[x]` | Заменено на shadow | + +**Файлы:** +- `src/components/layout/Layout.tsx` (секция bottom-nav) +- `src/styles/globals.css` (`.bottom-nav` классы) + +--- + +### Этап 4: Мясо (Dashboard Grid) +> Цель: Главная страница в стиле Bento + +| # | Задача | Статус | Заметки | +|---|--------|--------|---------| +| 4.1 | Создать .bento-grid контейнер | `[x]` | CSS Grid с gap, 2 колонки mobile, 4 desktop | +| 4.2 | Subscription Status → bento-card | `[x]` | Главная карточка подписки | +| 4.3 | Stats Grid → bento-grid + bento-card-hover | `[x]` | Balance, Subscription, Referrals, Earnings | +| 4.4 | Quick Actions → bento-card | `[x]` | Контейнер для кнопок | +| 4.5 | Trial + Wheel Banner → bento-card-glow/hover | `[x]` | Акцентные карточки | + +**Файлы:** +- `src/pages/Dashboard.tsx` +- `src/styles/globals.css` + +**Правило:** Логику JS/TS НЕ трогаем — только UI обёртки. + +--- + +## Решения и обоснования + +### Почему Urbanist, а не Inter? +- Urbanist более геометричный, лучше подходит под Bento-эстетику +- Хорошая читаемость на мобильных +- Бесплатный, Google Fonts + +### Почему rounded-3xl (24px)? +- Стандарт Bento — крупные скругления +- 16px (текущий rounded-2xl) выглядит слишком "приложенечно" +- 32px (rounded-4xl) для особо крупных элементов + +### Floating TabBar vs прилипший +- Floating выглядит премиально +- Отделяет навигацию от контента визуально +- Работает с safe-area на iOS + +--- + +## Что НЕ делаем + +- ❌ Модалки (ConnectionModal, TopUpModal) — вторично +- ❌ Страницы кроме Dashboard — после MVP +- ❌ Header — работает, не ломаем +- ❌ Рефакторинг логики — только UI + +--- + +## Прогресс + +``` +Этап 1: ██████████ 100% +Этап 2: ██████████ 100% +Этап 3: ██████████ 100% +Этап 4: ██████████ 100% +───────────────────── +Общий: ██████████ 100% +``` + +--- + +## Заметки для агентов + +**Контекст:** Это Mini App для Telegram (VPN кабинет). Мобильный фокус. + +**Текущий стек:** +- React + Vite + TypeScript +- Tailwind CSS +- React Query для данных + +**Ключевые файлы:** +- `tailwind.config.js` — цвета через CSS переменные +- `src/styles/globals.css` — компонентные классы (.card, .btn, etc) +- `src/components/layout/Layout.tsx` — шапка + таббар +- `src/pages/Dashboard.tsx` — главный экран (монстр, но логику не трогаем) + +**Темы:** Есть dark и light (champagne). Bento-стили должны работать в обеих. diff --git a/src/components/ui/BentoCard.tsx b/src/components/ui/BentoCard.tsx new file mode 100644 index 0000000..3f9d544 --- /dev/null +++ b/src/components/ui/BentoCard.tsx @@ -0,0 +1,115 @@ +import { Link } from 'react-router-dom' +import { forwardRef } from 'react' + +export type BentoSize = 'sm' | 'md' | 'lg' | 'xl' + +interface BentoCardBaseProps { + size?: BentoSize + children: React.ReactNode + className?: string + hover?: boolean + glow?: boolean +} + +interface BentoCardDivProps extends BentoCardBaseProps { + as?: 'div' + onClick?: () => void +} + +interface BentoCardLinkProps extends BentoCardBaseProps { + as: 'link' + to: string + state?: unknown +} + +interface BentoCardButtonProps extends BentoCardBaseProps { + as: 'button' + onClick?: () => void + disabled?: boolean + type?: 'button' | 'submit' +} + +export type BentoCardProps = BentoCardDivProps | BentoCardLinkProps | BentoCardButtonProps + +const sizeClasses: Record = { + sm: '', + md: 'col-span-2', + lg: 'row-span-2', + xl: 'col-span-2 row-span-2', +} + +const baseClasses = ` + bento-card + rounded-[var(--bento-radius)] + p-[var(--bento-padding)] + bg-dark-900/70 + border border-dark-700/40 + transition-all duration-300 ease-smooth +` + +const hoverClasses = ` + cursor-pointer + hover:bg-dark-800/60 + hover:border-dark-600/50 + hover:shadow-lg + hover:scale-[1.01] + active:scale-[0.99] +` + +const glowClasses = ` + hover:shadow-glow + hover:border-accent-500/30 +` + +export const BentoCard = forwardRef((props, ref) => { + const { + size = 'sm', + children, + className = '', + hover = false, + glow = false, + } = props + + const classes = [ + baseClasses, + sizeClasses[size], + hover && hoverClasses, + glow && glowClasses, + className, + ].filter(Boolean).join(' ') + + if (props.as === 'link') { + const { to, state } = props as BentoCardLinkProps + return ( + + {children} + + ) + } + + if (props.as === 'button') { + const { onClick, disabled, type = 'button' } = props as BentoCardButtonProps + return ( + + ) + } + + const { onClick } = props as BentoCardDivProps + return ( +
+ {children} +
+ ) +}) + +BentoCard.displayName = 'BentoCard' + +export default BentoCard diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 2c20823..99880ae 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -254,7 +254,7 @@ export default function Dashboard() { {/* Subscription Status - Main Card */} {subscription && ( -
+

{t('subscription.status')}

@@ -336,9 +336,9 @@ export default function Dashboard() { )} {/* Stats Grid */} -
+
{/* Balance */} - +
{t('balance.currentBalance')} @@ -352,7 +352,7 @@ export default function Dashboard() { {/* Subscription */} - +
{t('subscription.title')} @@ -388,7 +388,7 @@ export default function Dashboard() { {/* Referrals */} - +
{t('referral.stats.totalReferrals')} @@ -403,7 +403,7 @@ export default function Dashboard() { {/* Earnings */} - +
{t('referral.stats.totalEarnings')} @@ -422,7 +422,7 @@ export default function Dashboard() { {/* Trial Activation */} {hasNoSubscription && !trialLoading && trialInfo?.is_available && ( -
+
@@ -483,7 +483,7 @@ export default function Dashboard() { {wheelConfig?.is_enabled && (
{/* Emoji */} @@ -504,7 +504,7 @@ export default function Dashboard() { )} {/* Quick Actions */} -
+

{t('dashboard.quickActions')}

diff --git a/src/styles/globals.css b/src/styles/globals.css index e756480..d434854 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -1,4 +1,4 @@ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Urbanist:wght@400;500;600;700;800&display=swap'); @tailwind base; @tailwind components; @@ -8,6 +8,14 @@ :root { --safe-area-inset-bottom: env(safe-area-inset-bottom, 0px); + /* Bento Design System */ + --bento-radius: 24px; + --bento-radius-lg: 32px; + --bento-gap: 16px; + --bento-gap-lg: 24px; + --bento-padding: 24px; + --bento-padding-lg: 32px; + /* Theme colors - RGB format for opacity support */ /* Dark palette (RGB values) */ --color-dark-50: 248, 250, 252; @@ -327,6 +335,63 @@ } @layer components { + /* ========== BENTO DESIGN SYSTEM ========== */ + + .bento-card { + @apply bg-dark-900/70 border border-dark-700/40 + transition-all duration-300 ease-smooth; + border-radius: var(--bento-radius); + padding: var(--bento-padding); + transform: translateZ(0); + } + + @media (min-width: 1024px) { + .bento-card { + @apply bg-dark-900/50 backdrop-blur-sm; + } + } + + .bento-card-hover { + @apply bento-card cursor-pointer + hover:bg-dark-800/60 hover:border-dark-600/50 + hover:shadow-lg hover:scale-[1.01] active:scale-[0.99]; + } + + .bento-card-glow { + @apply bento-card hover:shadow-glow hover:border-accent-500/30 hover:scale-[1.01]; + } + + .bento-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--bento-gap); + } + + @media (min-width: 768px) { + .bento-grid { + grid-template-columns: repeat(4, 1fr); + gap: var(--bento-gap-lg); + } + } + + .light .bento-card { + @apply bg-white/90 border-champagne-300/50 shadow-sm; + } + + @media (min-width: 1024px) { + .light .bento-card { + @apply bg-white/80 backdrop-blur-sm; + } + } + + .light .bento-card-hover { + @apply hover:bg-white hover:border-champagne-400/50 hover:shadow-md; + } + + .light .bento-card-glow { + @apply hover:shadow-lg hover:border-accent-400/40; + } + /* ========== DARK THEME COMPONENTS (default) ========== */ /* Cards - Dark (optimized for mobile) */ @@ -493,12 +558,17 @@ @apply nav-item text-accent-400 bg-accent-500/10; } - /* Bottom nav - Dark (optimized for mobile) */ .bottom-nav { - @apply fixed bottom-0 left-0 right-0 z-50 - bg-dark-900/95 border-t border-dark-800/50 - safe-area-pb; + @apply fixed z-50 bg-dark-900/95; + bottom: 16px; + left: 16px; + right: 16px; + border-radius: var(--bento-radius); + padding: 8px 4px; + padding-bottom: calc(8px + var(--safe-area-inset-bottom)); transform: translateZ(0); + box-shadow: 0 4px 30px rgba(0, 0, 0, 0.4), + 0 0 0 1px rgba(255, 255, 255, 0.05) inset; } @media (min-width: 1024px) { @@ -508,12 +578,17 @@ } .bottom-nav-item { - @apply flex flex-col items-center justify-center py-2 px-2 flex-1 min-w-[60px] - text-dark-500 transition-colors duration-200 shrink-0; + @apply flex flex-col items-center justify-center py-2.5 px-3 flex-1 min-w-[56px] + text-dark-500 transition-all duration-200 shrink-0 rounded-2xl; + } + + .bottom-nav-item:hover { + @apply text-dark-300; } .bottom-nav-item-active { - @apply bottom-nav-item text-accent-400; + @apply flex flex-col items-center justify-center py-2.5 px-3 flex-1 min-w-[56px] + text-accent-400 bg-accent-500/15 rounded-2xl transition-all duration-200 shrink-0; } /* Divider - Dark */ @@ -668,17 +743,28 @@ @apply text-champagne-800 bg-champagne-200/70; } - /* Bottom nav - Light */ .light .bottom-nav { - @apply bg-white/90 backdrop-blur-xl border-t border-champagne-200; + @apply bg-white/95; + box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1), + 0 0 0 1px rgba(0, 0, 0, 0.05); + } + + @media (min-width: 1024px) { + .light .bottom-nav { + @apply bg-white/80 backdrop-blur-xl; + } } .light .bottom-nav-item { @apply text-champagne-500; } + .light .bottom-nav-item:hover { + @apply text-champagne-700; + } + .light .bottom-nav-item-active { - @apply text-champagne-800; + @apply text-champagne-800 bg-champagne-300/40; } /* Divider - Light */ diff --git a/tailwind.config.js b/tailwind.config.js index b6d30c7..4460776 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -106,7 +106,15 @@ export default { }, }, fontFamily: { - sans: ['Inter', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'], + sans: ['Urbanist', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'], + }, + borderRadius: { + 'bento': '24px', + '4xl': '32px', + }, + spacing: { + 'bento': '16px', + 'bento-lg': '24px', }, fontSize: { '2xs': ['0.625rem', { lineHeight: '0.875rem' }], From b9b889b0bf23df1c9cd36cace0c0970879aa4a7b Mon Sep 17 00:00:00 2001 From: Dev Date: Tue, 20 Jan 2026 21:11:26 +0500 Subject: [PATCH 60/67] docs: add Phase 2 roadmap to refactor plan --- .ai/BENTO_REFACTOR.md | 55 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/.ai/BENTO_REFACTOR.md b/.ai/BENTO_REFACTOR.md index 93d1e71..8cbdfad 100644 --- a/.ai/BENTO_REFACTOR.md +++ b/.ai/BENTO_REFACTOR.md @@ -148,3 +148,58 @@ interface BentoCardProps { - `src/pages/Dashboard.tsx` — главный экран (монстр, но логику не трогаем) **Темы:** Есть dark и light (champagne). Bento-стили должны работать в обеих. + +--- + +## Следующие этапы (Phase 2) + +### Этап 5: Остальные страницы пользователя +> Применить bento-стили к основным страницам + +| # | Страница | Приоритет | Объём | +|---|----------|-----------|-------| +| 5.1 | Subscription.tsx | High | Большая — формы, тарифы, карточки | +| 5.2 | Balance.tsx | High | Средняя — баланс, транзакции, методы оплаты | +| 5.3 | Referral.tsx | Medium | Средняя — статистика, ссылка, условия | +| 5.4 | Support.tsx | Medium | Малая — тикеты | +| 5.5 | Profile.tsx | Low | Малая — настройки | +| 5.6 | Info.tsx | Low | Малая — статичный контент | + +### Этап 6: Модалки +> Обновить модальные окна в bento-стиле + +| # | Компонент | Заметки | +|---|-----------|---------| +| 6.1 | ConnectionModal | QR-код, кнопки подключения | +| 6.2 | TopUpModal | Форма пополнения | +| 6.3 | InsufficientBalancePrompt | Промпт о недостатке средств | + +### Этап 7: Header +> Обновить шапку (опционально) + +| # | Задача | Заметки | +|---|--------|---------| +| 7.1 | Лого в bento-стиле | Скругления, тень | +| 7.2 | Mobile menu | Floating стиль? | + +### Этап 8: Полировка +> Финальные штрихи + +| # | Задача | +|---|--------| +| 8.1 | Анимации появления карточек (stagger) | +| 8.2 | Микро-взаимодействия (hover glow) | +| 8.3 | Тестирование на разных устройствах | +| 8.4 | Performance check (особенно blur на mobile) | + +--- + +## Changelog + +### 2026-01-20 — MVP Complete +- ✅ Urbanist шрифт +- ✅ CSS переменные Bento +- ✅ BentoCard компонент +- ✅ Floating TabBar +- ✅ Dashboard в bento-стиле +- ✅ Commit: `bf0bcfb` From c92a4e7704b6591d833a3ffc7778bbe0478aa7ef Mon Sep 17 00:00:00 2001 From: Dev Date: Tue, 20 Jan 2026 22:26:13 +0500 Subject: [PATCH 61/67] feat: complete Phase 2 - refactor all user pages to Bento UI --- .ai/BENTO_REFACTOR.md | 37 +++++++++++++++++++++++++++++-------- src/pages/Balance.tsx | 14 +++++++------- src/pages/Info.tsx | 8 ++++---- src/pages/Profile.tsx | 6 +++--- src/pages/Referral.tsx | 16 ++++++++-------- src/pages/Subscription.tsx | 34 ++++++++++++++++------------------ src/pages/Support.tsx | 8 ++++---- src/styles/globals.css | 8 +++++++- 8 files changed, 78 insertions(+), 53 deletions(-) diff --git a/.ai/BENTO_REFACTOR.md b/.ai/BENTO_REFACTOR.md index 8cbdfad..e9cf2f2 100644 --- a/.ai/BENTO_REFACTOR.md +++ b/.ai/BENTO_REFACTOR.md @@ -126,6 +126,7 @@ interface BentoCardProps { Этап 2: ██████████ 100% Этап 3: ██████████ 100% Этап 4: ██████████ 100% +Этап 5: ██████████ 100% ───────────────────── Общий: ██████████ 100% ``` @@ -156,14 +157,14 @@ interface BentoCardProps { ### Этап 5: Остальные страницы пользователя > Применить bento-стили к основным страницам -| # | Страница | Приоритет | Объём | -|---|----------|-----------|-------| -| 5.1 | Subscription.tsx | High | Большая — формы, тарифы, карточки | -| 5.2 | Balance.tsx | High | Средняя — баланс, транзакции, методы оплаты | -| 5.3 | Referral.tsx | Medium | Средняя — статистика, ссылка, условия | -| 5.4 | Support.tsx | Medium | Малая — тикеты | -| 5.5 | Profile.tsx | Low | Малая — настройки | -| 5.6 | Info.tsx | Low | Малая — статичный контент | +| # | Страница | Приоритет | Объём | Статус | +|---|----------|-----------|-------|--------| +| 5.1 | Subscription.tsx | High | Большая — формы, тарифы, карточки | `[x]` | +| 5.2 | Balance.tsx | High | Средняя — баланс, транзакции, методы оплаты | `[x]` | +| 5.3 | Referral.tsx | Medium | Средняя — статистика, ссылка, условия | `[x]` | +| 5.4 | Support.tsx | Medium | Малая — тикеты | `[x]` | +| 5.5 | Profile.tsx | Low | Малая — настройки | `[x]` | +| 5.6 | Info.tsx | Low | Малая — статичный контент | `[x]` | ### Этап 6: Модалки > Обновить модальные окна в bento-стиле @@ -203,3 +204,23 @@ interface BentoCardProps { - ✅ Floating TabBar - ✅ Dashboard в bento-стиле - ✅ Commit: `bf0bcfb` + +### 2026-01-20 — Subscription.tsx Refactor +- ✅ Все секции `card` → `bento-card` (6 шт): + - Current Subscription (line 429) + - Daily Pause (line 634) + - Additional Options (line 733) + - My Devices (line 1153) + - Tariffs section (line 1223) + - Classic mode purchase (line 1925) +- ✅ Tariff cards: `bento-card-hover` + `bento-card-glow` для выбранного +- ✅ Period selection cards: `bento-card-hover` + `bento-card-glow` +- ✅ Traffic selection cards: `bento-card-hover` + `bento-card-glow` +- ✅ Исправлен `.bento-grid` — добавлен breakpoint для xs (<375px) + +### 2026-01-20 — Phase 2 Complete (Все страницы пользователя) +- ✅ **Balance.tsx**: 4 карточки → `bento-card`, методы оплаты → `bento-card-hover` +- ✅ **Referral.tsx**: stats grid → `bento-grid` + `bento-card-hover`, 5 секций → `bento-card` +- ✅ **Support.tsx**: 3 карточки → `bento-card`, tickets list items → `rounded-bento` +- ✅ **Profile.tsx**: 3 карточки → `bento-card` +- ✅ **Info.tsx**: FAQ items, rules, privacy, offer → `bento-card` diff --git a/src/pages/Balance.tsx b/src/pages/Balance.tsx index 4f3b5ee..eb8b5c2 100644 --- a/src/pages/Balance.tsx +++ b/src/pages/Balance.tsx @@ -122,7 +122,7 @@ export default function Balance() {

{t('balance.title')}

{/* Balance Card */} -
+
{t('balance.currentBalance')}
{formatAmount(balanceData?.balance_rubles || 0)} @@ -131,7 +131,7 @@ export default function Balance() {
{/* Promo Code Section */} -
+

{t('balance.promocode.title')}

0 && ( -
+

{t('balance.topUpBalance')}

{paymentMethods.map((method) => { @@ -181,10 +181,10 @@ export default function Balance() { key={method.id} disabled={!method.is_available} onClick={() => method.is_available && setSelectedMethod(method)} - className={`p-4 rounded-xl border text-left transition-all ${ + className={`bento-card-hover p-4 text-left transition-all ${ method.is_available - ? 'border-dark-700/50 hover:border-accent-500/50 bg-dark-800/30 cursor-pointer' - : 'border-dark-800/30 bg-dark-900/30 opacity-50 cursor-not-allowed' + ? 'cursor-pointer' + : 'opacity-50 cursor-not-allowed' }`} >
{translatedName || method.name}
@@ -202,7 +202,7 @@ export default function Balance() { )} {/* Transaction History */} -
+

{t('balance.transactionHistory')}

{isLoading ? ( diff --git a/src/pages/Info.tsx b/src/pages/Info.tsx index 5e246ea..1ba1e16 100644 --- a/src/pages/Info.tsx +++ b/src/pages/Info.tsx @@ -166,7 +166,7 @@ export default function Info() { return (
{faqPages.map((faq: FaqPage) => ( -
+
+ ) +} + +function HSLSlider({ + label, + value, + onChange, + max, + gradient, + suffix = '', +}: { + label: string + value: number + onChange: (value: number) => void + max: number + gradient: string + suffix?: string +}) { + return ( +
+
+ + + {value} + {suffix} + +
+ onChange(parseInt(e.target.value))} + className="w-full h-2.5 rounded-full appearance-none cursor-pointer" + style={{ background: gradient }} + /> +
+ ) +} + +function CompactColorInput({ + label, + value, + onChange, +}: { + label: string + value: string + onChange: (color: string) => void +}) { + const [localValue, setLocalValue] = useState(value) + const [isEditing, setIsEditing] = useState(false) + + useEffect(() => { + setLocalValue(value) + }, [value]) + + const handleChange = (newValue: string) => { + let formatted = newValue.toUpperCase() + if (!formatted.startsWith('#')) { + formatted = '#' + formatted + } + setLocalValue(formatted) + if (isValidHex(formatted)) { + onChange(formatted) + } + } + + const handleBlur = () => { + setIsEditing(false) + if (!isValidHex(localValue)) { + setLocalValue(value) + } + } + + return ( +
+ + )} +
+
+ ) +} + +function CollapsibleSection({ + title, + icon, + isOpen, + onToggle, + children, + badge, +}: { + title: string + icon: React.ReactNode + isOpen: boolean + onToggle: () => void + children: React.ReactNode + badge?: string +}) { + return ( +
+ + +
+
+
{children}
+
+
+
+ ) +} + +export function ThemeBentoPicker({ + currentColors, + onColorsChange, + onSave, + isSaving, +}: ThemeBentoPickerProps) { + const { t } = useTranslation() + + const [hsl, setHsl] = useState(() => hexToHsl(currentColors.accent)) + const [hexInput, setHexInput] = useState(currentColors.accent) + const [hasChanges, setHasChanges] = useState(false) + + const [isAccentOpen, setIsAccentOpen] = useState(false) + const [isDarkOpen, setIsDarkOpen] = useState(false) + const [isLightOpen, setIsLightOpen] = useState(false) + const [isStatusOpen, setIsStatusOpen] = useState(false) + + const selectedPresetId = useMemo(() => { + const match = COLOR_PRESETS.find( + (p) => + p.colors.accent.toLowerCase() === currentColors.accent.toLowerCase() && + p.colors.darkBackground.toLowerCase() === currentColors.darkBackground.toLowerCase() && + p.colors.lightBackground.toLowerCase() === currentColors.lightBackground.toLowerCase() + ) + return match?.id ?? null + }, [currentColors.accent, currentColors.darkBackground, currentColors.lightBackground]) + + useEffect(() => { + setHsl(hexToHsl(currentColors.accent)) + setHexInput(currentColors.accent) + }, [currentColors.accent]) + + const updateColor = useCallback( + (key: keyof ThemeColors, value: string) => { + const newColors = { ...currentColors, [key]: value } + onColorsChange(newColors) + applyThemeColors(newColors) + setHasChanges(true) + }, + [currentColors, onColorsChange] + ) + + const updateAccentFromHsl = useCallback( + (newHsl: HSLColor) => { + setHsl(newHsl) + const newHex = hslToHex(newHsl.h, newHsl.s, newHsl.l) + setHexInput(newHex) + updateColor('accent', newHex) + }, + [updateColor] + ) + + const handleHexInputChange = (value: string) => { + setHexInput(value) + if (isValidHex(value)) { + const newHsl = hexToHsl(value) + setHsl(newHsl) + updateColor('accent', value) + } + } + + const handlePresetSelect = (preset: ColorPreset) => { + onColorsChange(preset.colors) + applyThemeColors(preset.colors) + setHasChanges(true) + } + + const hueGradient = useMemo(() => { + return `linear-gradient(to right, + hsl(0, ${hsl.s}%, ${hsl.l}%), + hsl(60, ${hsl.s}%, ${hsl.l}%), + hsl(120, ${hsl.s}%, ${hsl.l}%), + hsl(180, ${hsl.s}%, ${hsl.l}%), + hsl(240, ${hsl.s}%, ${hsl.l}%), + hsl(300, ${hsl.s}%, ${hsl.l}%), + hsl(360, ${hsl.s}%, ${hsl.l}%) + )` + }, [hsl.s, hsl.l]) + + const saturationGradient = useMemo(() => { + return `linear-gradient(to right, + hsl(${hsl.h}, 0%, ${hsl.l}%), + hsl(${hsl.h}, 100%, ${hsl.l}%) + )` + }, [hsl.h, hsl.l]) + + const lightnessGradient = useMemo(() => { + return `linear-gradient(to right, + hsl(${hsl.h}, ${hsl.s}%, 0%), + hsl(${hsl.h}, ${hsl.s}%, 50%), + hsl(${hsl.h}, ${hsl.s}%, 100%) + )` + }, [hsl.h, hsl.s]) + + return ( +
+
+

+ {t('admin.theme.quickPresets', 'Quick Presets')} +

+
+ {COLOR_PRESETS.map((preset, index) => ( +
+ handlePresetSelect(preset)} + /> +
+ ))} +
+
+ +
+

+ {t('admin.theme.customizeColors', 'Customize Colors')} +

+ + } + badge={hexInput.toUpperCase()} + isOpen={isAccentOpen} + onToggle={() => setIsAccentOpen(!isAccentOpen)} + > +
+
+
+
+ {hexInput.toUpperCase()} +
+
+ + updateAccentFromHsl({ ...hsl, h })} + max={360} + gradient={hueGradient} + suffix="°" + /> + + updateAccentFromHsl({ ...hsl, s })} + max={100} + gradient={saturationGradient} + suffix="%" + /> + + updateAccentFromHsl({ ...hsl, l })} + max={100} + gradient={lightnessGradient} + suffix="%" + /> + +
+ + handleHexInputChange(e.target.value)} + placeholder="#3b82f6" + maxLength={7} + className="input w-full text-sm font-mono uppercase" + /> +
+
+ + + } + isOpen={isDarkOpen} + onToggle={() => setIsDarkOpen(!isDarkOpen)} + > +
+ updateColor('darkBackground', c)} + /> + updateColor('darkSurface', c)} + /> + updateColor('darkText', c)} + /> + updateColor('darkTextSecondary', c)} + /> +
+
+ + } + isOpen={isLightOpen} + onToggle={() => setIsLightOpen(!isLightOpen)} + > +
+ updateColor('lightBackground', c)} + /> + updateColor('lightSurface', c)} + /> + updateColor('lightText', c)} + /> + updateColor('lightTextSecondary', c)} + /> +
+
+ + } + isOpen={isStatusOpen} + onToggle={() => setIsStatusOpen(!isStatusOpen)} + > +
+ updateColor('success', c)} + /> + updateColor('warning', c)} + /> + updateColor('error', c)} + /> +
+
+
+ +
+

+ {t('theme.preview', 'Preview')} +

+
+ + + {t('theme.success', 'Success')} + {t('theme.warning', 'Warning')} + {t('theme.error', 'Error')} +
+
+ + {hasChanges && ( +
+ +
+ )} +
+ ) +} diff --git a/src/data/colorPresets.ts b/src/data/colorPresets.ts new file mode 100644 index 0000000..f272f23 --- /dev/null +++ b/src/data/colorPresets.ts @@ -0,0 +1,179 @@ +import { ThemeColors } from '../types/theme' + +export interface ColorPreset { + id: string + name: string + nameRu: string + description: string + descriptionRu: string + colors: ThemeColors + // Preview colors for the card + preview: { + background: string + accent: string + text: string + } +} + +export const COLOR_PRESETS: ColorPreset[] = [ + { + id: 'electric-blue', + name: 'Electric Blue', + nameRu: 'Электрик', + description: 'Classic tech blue, reliable and clean', + descriptionRu: 'Классический технологичный синий', + colors: { + accent: '#3b82f6', + darkBackground: '#0a0f1a', + darkSurface: '#0f172a', + darkText: '#f1f5f9', + darkTextSecondary: '#94a3b8', + lightBackground: '#f8fafc', + lightSurface: '#ffffff', + lightText: '#0f172a', + lightTextSecondary: '#64748b', + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + }, + preview: { + background: '#0a0f1a', + accent: '#3b82f6', + text: '#f1f5f9', + }, + }, + { + id: 'toxic-neon', + name: 'Toxic Neon', + nameRu: 'Токсичный неон', + description: 'Cyberpunk vibes, high energy', + descriptionRu: 'Киберпанк атмосфера, высокая энергия', + colors: { + accent: '#22c55e', + darkBackground: '#030712', + darkSurface: '#0a0f14', + darkText: '#e2e8f0', + darkTextSecondary: '#64748b', + lightBackground: '#f0fdf4', + lightSurface: '#ffffff', + lightText: '#052e16', + lightTextSecondary: '#166534', + success: '#22c55e', + warning: '#eab308', + error: '#ef4444', + }, + preview: { + background: '#030712', + accent: '#22c55e', + text: '#e2e8f0', + }, + }, + { + id: 'royal-purple', + name: 'Royal Purple', + nameRu: 'Королевский пурпур', + description: 'Premium, sophisticated, Stripe-like', + descriptionRu: 'Премиальный, утончённый, как Stripe', + colors: { + accent: '#8b5cf6', + darkBackground: '#0c0a14', + darkSurface: '#13111c', + darkText: '#f1f0f5', + darkTextSecondary: '#a1a1aa', + lightBackground: '#faf5ff', + lightSurface: '#ffffff', + lightText: '#1e1b29', + lightTextSecondary: '#6b21a8', + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + }, + preview: { + background: '#0c0a14', + accent: '#8b5cf6', + text: '#f1f0f5', + }, + }, + { + id: 'sunset-orange', + name: 'Sunset Orange', + nameRu: 'Закатный оранж', + description: 'Warm, energetic, action-oriented', + descriptionRu: 'Тёплый, энергичный, призыв к действию', + colors: { + accent: '#f97316', + darkBackground: '#0f0906', + darkSurface: '#1a120d', + darkText: '#fef3e2', + darkTextSecondary: '#a3a3a3', + lightBackground: '#fff7ed', + lightSurface: '#ffffff', + lightText: '#1c1917', + lightTextSecondary: '#c2410c', + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + }, + preview: { + background: '#0f0906', + accent: '#f97316', + text: '#fef3e2', + }, + }, + { + id: 'ocean-teal', + name: 'Ocean Teal', + nameRu: 'Океанский бирюзовый', + description: 'Calm, trustworthy, health-tech', + descriptionRu: 'Спокойный, надёжный, медтех', + colors: { + accent: '#14b8a6', + darkBackground: '#042f2e', + darkSurface: '#0d3d3b', + darkText: '#f0fdfa', + darkTextSecondary: '#5eead4', + lightBackground: '#f0fdfa', + lightSurface: '#ffffff', + lightText: '#134e4a', + lightTextSecondary: '#0f766e', + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + }, + preview: { + background: '#042f2e', + accent: '#14b8a6', + text: '#f0fdfa', + }, + }, + { + id: 'champagne-gold', + name: 'Champagne Gold', + nameRu: 'Шампанское золото', + description: 'Luxury, premium, elegant', + descriptionRu: 'Роскошный, премиальный, элегантный', + colors: { + accent: '#b8860b', + darkBackground: '#0a0f1a', + darkSurface: '#0f172a', + darkText: '#f1f5f9', + darkTextSecondary: '#94a3b8', + lightBackground: '#F7E7CE', + lightSurface: '#FEF9F0', + lightText: '#1F1A12', + lightTextSecondary: '#7D6B48', + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + }, + preview: { + background: '#F7E7CE', + accent: '#b8860b', + text: '#1F1A12', + }, + }, +] + +export function getPresetById(id: string): ColorPreset | undefined { + return COLOR_PRESETS.find((preset) => preset.id === id) +} diff --git a/src/pages/AdminSettings.tsx b/src/pages/AdminSettings.tsx index 69f1711..cbe668b 100644 --- a/src/pages/AdminSettings.tsx +++ b/src/pages/AdminSettings.tsx @@ -7,8 +7,8 @@ import { brandingApi, setCachedBranding } from '../api/branding' import { setCachedAnimationEnabled } from '../components/AnimatedBackground' import { setCachedFullscreenEnabled } from '../hooks/useTelegramWebApp' import { themeColorsApi } from '../api/themeColors' -import { DEFAULT_THEME_COLORS, DEFAULT_ENABLED_THEMES } from '../types/theme' -import { ColorPicker } from '../components/ColorPicker' +import { DEFAULT_THEME_COLORS, DEFAULT_ENABLED_THEMES, ThemeColors } from '../types/theme' +import { ThemeBentoPicker } from '../components/ThemeBentoPicker' import { applyThemeColors } from '../hooks/useThemeColors' import { updateEnabledThemesCache } from '../hooks/useTheme' @@ -858,6 +858,7 @@ export default function AdminSettings() { const [searchQuery, setSearchQuery] = useState('') const [editingName, setEditingName] = useState(false) const [newName, setNewName] = useState('') + const [localColors, setLocalColors] = useState(null) const fileInputRef = useRef(null) // Branding query and mutations @@ -1355,113 +1356,18 @@ export default function AdminSettings() { Включите нужные темы для пользователей. Минимум одна тема должна быть активна.

- {/* Accent Color */} - updateColorsMutation.mutate({ accent: color })} - disabled={updateColorsMutation.isPending} + + setLocalColors(colors)} + onSave={() => { + if (localColors) { + updateColorsMutation.mutate(localColors) + setLocalColors(null) + } + }} + isSaving={updateColorsMutation.isPending} /> - - {/* Dark Theme Section */} -
-

{t('theme.darkTheme')}

-
- updateColorsMutation.mutate({ darkBackground: color })} - disabled={updateColorsMutation.isPending} - /> - updateColorsMutation.mutate({ darkSurface: color })} - disabled={updateColorsMutation.isPending} - /> - updateColorsMutation.mutate({ darkText: color })} - disabled={updateColorsMutation.isPending} - /> - updateColorsMutation.mutate({ darkTextSecondary: color })} - disabled={updateColorsMutation.isPending} - /> -
-
- - {/* Light Theme Section */} -
-

{t('theme.lightTheme')}

-
- updateColorsMutation.mutate({ lightBackground: color })} - disabled={updateColorsMutation.isPending} - /> - updateColorsMutation.mutate({ lightSurface: color })} - disabled={updateColorsMutation.isPending} - /> - updateColorsMutation.mutate({ lightText: color })} - disabled={updateColorsMutation.isPending} - /> - updateColorsMutation.mutate({ lightTextSecondary: color })} - disabled={updateColorsMutation.isPending} - /> -
-
- - {/* Status Colors */} -
-

{t('theme.statusColors')}

-
- updateColorsMutation.mutate({ success: color })} - disabled={updateColorsMutation.isPending} - /> - updateColorsMutation.mutate({ warning: color })} - disabled={updateColorsMutation.isPending} - /> - updateColorsMutation.mutate({ error: color })} - disabled={updateColorsMutation.isPending} - /> -
-
- - {/* Preview */} -
-

{t('theme.preview')}

-
- - - {t('theme.success')} - {t('theme.warning')} - {t('theme.error')} -
-
diff --git a/src/utils/colorConversion.ts b/src/utils/colorConversion.ts new file mode 100644 index 0000000..acb39fb --- /dev/null +++ b/src/utils/colorConversion.ts @@ -0,0 +1,102 @@ +export interface HSLColor { + h: number + s: number + l: number +} + +export interface RGBColor { + r: number + g: number + b: number +} + +export function hexToRgb(hex: string): RGBColor { + if (hex.length === 4) { + hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3] + } + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + return { r, g, b } +} + +export function rgbToHex(r: number, g: number, b: number): string { + return ( + '#' + + [r, g, b] + .map((x) => { + const hex = Math.round(Math.max(0, Math.min(255, x))).toString(16) + return hex.length === 1 ? '0' + hex : hex + }) + .join('') + ) +} + +export function hexToHsl(hex: string): HSLColor { + const { r, g, b } = hexToRgb(hex) + const rNorm = r / 255 + const gNorm = g / 255 + const bNorm = b / 255 + + const max = Math.max(rNorm, gNorm, bNorm) + const min = Math.min(rNorm, gNorm, bNorm) + let h = 0 + let s = 0 + const l = (max + min) / 2 + + if (max !== min) { + const d = max - min + s = l > 0.5 ? d / (2 - max - min) : d / (max + min) + + switch (max) { + case rNorm: + h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6 + break + case gNorm: + h = ((bNorm - rNorm) / d + 2) / 6 + break + case bNorm: + h = ((rNorm - gNorm) / d + 4) / 6 + break + } + } + + return { + h: Math.round(h * 360), + s: Math.round(s * 100), + l: Math.round(l * 100), + } +} + +export function hslToRgb(h: number, s: number, l: number): RGBColor { + const sNorm = s / 100 + const lNorm = l / 100 + + const a = sNorm * Math.min(lNorm, 1 - lNorm) + const f = (n: number) => { + const k = (n + h / 30) % 12 + const color = lNorm - a * Math.max(Math.min(k - 3, 9 - k, 1), -1) + return Math.round(255 * color) + } + + return { r: f(0), g: f(8), b: f(4) } +} + +export function hslToHex(h: number, s: number, l: number): string { + const { r, g, b } = hslToRgb(h, s, l) + return rgbToHex(r, g, b) +} + +export function isValidHex(hex: string): boolean { + return /^#[0-9A-Fa-f]{6}$/.test(hex) +} + +export function normalizeHex(hex: string): string { + if (!hex.startsWith('#')) { + hex = '#' + hex + } + if (hex.length === 4) { + hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3] + } + return hex.toLowerCase() +} From 7272f0a6c10af563c18c6ed8a666e79618bd8646 Mon Sep 17 00:00:00 2001 From: kinvsh Date: Wed, 21 Jan 2026 01:30:14 +0500 Subject: [PATCH 67/67] bento-grids v1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit в целом все хорошо, немного доправить и переходить к light theme --- src/components/ConnectionModal.tsx | 7 +- src/components/ThemeBentoPicker.tsx | 16 ++- src/components/TopUpModal.tsx | 180 ++++++++++++++++++---------- src/data/colorPresets.ts | 133 +++++++++++++++++--- src/pages/Balance.tsx | 175 +++++++++++++++------------ 5 files changed, 347 insertions(+), 164 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index 68cd8de..58662e6 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -386,8 +386,11 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { // Main view return ( - {/* Header - app selector */} -
+
+

{t('subscription.connection.title')}

+
+ +
-
- {/* Payment options */} - {hasOptions && method.options && ( -
- {method.options.map((opt) => ( - - ))} -
- )} - - {/* Amount input */} -
- setAmount(e.target.value)} - placeholder={`${formatAmount(minRubles, 0)} – ${formatAmount(maxRubles, 0)}`} - className="w-full h-12 px-4 pr-12 text-lg font-semibold bg-dark-800 border border-dark-700 rounded-xl text-dark-100 placeholder:text-dark-500 focus:outline-none focus:border-accent-500" - autoComplete="off" - /> - - {currencySymbol} - -
- - {/* Quick amounts */} +
{quickAmounts.length > 0 && ( -
+
{quickAmounts.map((a) => { const val = getQuickValue(a) + const isSelected = amount === val + return ( - + + {formatAmount(a, 0)} + + + {currencySymbol} + + ) })}
)} - {/* Error */} - {error && ( -
{error}
+
+
+ + + {formatAmount(minRubles, 0)} – {formatAmount(maxRubles, 0)} {currencySymbol} + +
+ +
+
+
+ setAmount(e.target.value)} + placeholder="0" + className="w-full h-14 pl-5 pr-12 text-2xl font-bold bg-transparent text-dark-50 placeholder:text-dark-600 focus:outline-none" + autoComplete="off" + /> +
+ + {currencySymbol} + +
+
+
+
+ + {hasOptions && method.options && ( +
+ +
+ {method.options.map((opt) => { + const isSelected = selectedOption === opt.id + return ( + + ) + })} +
+
+ )} + + {error && ( +
+
+ + + +
+

{error}

+
)} - {/* Submit */}
@@ -285,3 +340,4 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top } return modalContent } + diff --git a/src/data/colorPresets.ts b/src/data/colorPresets.ts index f272f23..0cee321 100644 --- a/src/data/colorPresets.ts +++ b/src/data/colorPresets.ts @@ -7,7 +7,6 @@ export interface ColorPreset { description: string descriptionRu: string colors: ThemeColors - // Preview colors for the card preview: { background: string accent: string @@ -28,10 +27,10 @@ export const COLOR_PRESETS: ColorPreset[] = [ darkSurface: '#0f172a', darkText: '#f1f5f9', darkTextSecondary: '#94a3b8', - lightBackground: '#f8fafc', + lightBackground: '#f1f5f9', lightSurface: '#ffffff', lightText: '#0f172a', - lightTextSecondary: '#64748b', + lightTextSecondary: '#475569', success: '#22c55e', warning: '#f59e0b', error: '#ef4444', @@ -56,7 +55,7 @@ export const COLOR_PRESETS: ColorPreset[] = [ darkTextSecondary: '#64748b', lightBackground: '#f0fdf4', lightSurface: '#ffffff', - lightText: '#052e16', + lightText: '#14532d', lightTextSecondary: '#166534', success: '#22c55e', warning: '#eab308', @@ -82,8 +81,8 @@ export const COLOR_PRESETS: ColorPreset[] = [ darkTextSecondary: '#a1a1aa', lightBackground: '#faf5ff', lightSurface: '#ffffff', - lightText: '#1e1b29', - lightTextSecondary: '#6b21a8', + lightText: '#3b0764', + lightTextSecondary: '#581c87', success: '#22c55e', warning: '#f59e0b', error: '#ef4444', @@ -108,8 +107,8 @@ export const COLOR_PRESETS: ColorPreset[] = [ darkTextSecondary: '#a3a3a3', lightBackground: '#fff7ed', lightSurface: '#ffffff', - lightText: '#1c1917', - lightTextSecondary: '#c2410c', + lightText: '#7c2d12', + lightTextSecondary: '#9a3412', success: '#22c55e', warning: '#f59e0b', error: '#ef4444', @@ -135,7 +134,7 @@ export const COLOR_PRESETS: ColorPreset[] = [ lightBackground: '#f0fdfa', lightSurface: '#ffffff', lightText: '#134e4a', - lightTextSecondary: '#0f766e', + lightTextSecondary: '#115e59', success: '#22c55e', warning: '#f59e0b', error: '#ef4444', @@ -158,18 +157,122 @@ export const COLOR_PRESETS: ColorPreset[] = [ darkSurface: '#0f172a', darkText: '#f1f5f9', darkTextSecondary: '#94a3b8', - lightBackground: '#F7E7CE', - lightSurface: '#FEF9F0', - lightText: '#1F1A12', - lightTextSecondary: '#7D6B48', + lightBackground: '#fefce8', + lightSurface: '#ffffff', + lightText: '#713f12', + lightTextSecondary: '#92400e', success: '#22c55e', warning: '#f59e0b', error: '#ef4444', }, preview: { - background: '#F7E7CE', + background: '#0a0f1a', accent: '#b8860b', - text: '#1F1A12', + text: '#f1f5f9', + }, + }, + { + id: 'quantum-teal', + name: 'Quantum Teal', + nameRu: 'Квантовый бирюзовый', + description: 'Bio-synthetic, futuristic fintech', + descriptionRu: 'Био-синтетический, футуристичный финтех', + colors: { + accent: '#0d9488', + darkBackground: '#0f172a', + darkSurface: '#1e293b', + darkText: '#f1f5f9', + darkTextSecondary: '#94a3b8', + lightBackground: '#f0fdfa', + lightSurface: '#ffffff', + lightText: '#134e4a', + lightTextSecondary: '#0f766e', + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + }, + preview: { + background: '#0f172a', + accent: '#0d9488', + text: '#f1f5f9', + }, + }, + { + id: 'cosmic-violet', + name: 'Cosmic Violet', + nameRu: 'Космический фиолет', + description: 'Digital lavender, wellness vibes', + descriptionRu: 'Цифровая лаванда, атмосфера спокойствия', + colors: { + accent: '#7c3aed', + darkBackground: '#0b0d10', + darkSurface: '#18181b', + darkText: '#f4f4f5', + darkTextSecondary: '#a1a1aa', + lightBackground: '#faf5ff', + lightSurface: '#ffffff', + lightText: '#3b0764', + lightTextSecondary: '#5b21b6', + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + }, + preview: { + background: '#0b0d10', + accent: '#7c3aed', + text: '#f4f4f5', + }, + }, + { + id: 'solar-coral', + name: 'Solar Coral', + nameRu: 'Солнечный коралл', + description: 'Hyper-coral, high energy social', + descriptionRu: 'Гипер-коралловый, энергия соцсетей', + colors: { + accent: '#ea580c', + darkBackground: '#18181b', + darkSurface: '#27272a', + darkText: '#fafafa', + darkTextSecondary: '#a1a1aa', + lightBackground: '#fff7ed', + lightSurface: '#ffffff', + lightText: '#7c2d12', + lightTextSecondary: '#9a3412', + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + }, + preview: { + background: '#18181b', + accent: '#ea580c', + text: '#fafafa', + }, + }, + { + id: 'frost-blue', + name: 'Frost Blue', + nameRu: 'Морозный синий', + description: 'Liquid chrome, enterprise trust', + descriptionRu: 'Жидкий хром, корпоративное доверие', + colors: { + accent: '#0284c7', + darkBackground: '#0b0d10', + darkSurface: '#1e293b', + darkText: '#f1f5f9', + darkTextSecondary: '#94a3b8', + lightBackground: '#f0f9ff', + lightSurface: '#ffffff', + lightText: '#0c4a6e', + lightTextSecondary: '#075985', + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + }, + preview: { + background: '#0b0d10', + accent: '#0284c7', + text: '#f1f5f9', }, }, ] diff --git a/src/pages/Balance.tsx b/src/pages/Balance.tsx index eb8b5c2..6e0c10e 100644 --- a/src/pages/Balance.tsx +++ b/src/pages/Balance.tsx @@ -32,6 +32,7 @@ export default function Balance() { const [promocodeSuccess, setPromocodeSuccess] = useState<{ message: string; amount: number } | null>(null) const [transactionsPage, setTransactionsPage] = useState(1) + const [isHistoryOpen, setIsHistoryOpen] = useState(false) const { data: transactions, isLoading } = useQuery>({ queryKey: ['transactions', transactionsPage], @@ -201,88 +202,104 @@ export default function Balance() {
)} - {/* Transaction History */} -
-

{t('balance.transactionHistory')}

+
+ - {isLoading ? ( -
-
-
- ) : transactions?.items && transactions.items.length > 0 ? ( -
- {transactions.items.map((tx) => { - // API returns negative values for debits, positive for credits - const isPositive = tx.amount_rubles >= 0 - const displayAmount = Math.abs(tx.amount_rubles) - const sign = isPositive ? '+' : '-' - const colorClass = isPositive ? 'text-success-400' : 'text-error-400' - - return ( -
-
-
- - {getTypeLabel(tx.type)} - - - {new Date(tx.created_at).toLocaleDateString()} - -
- {tx.description && ( -
{tx.description}
- )} -
-
- {sign}{formatAmount(displayAmount)} {currencySymbol} -
+ {isHistoryOpen && ( +
+ {isLoading ? ( +
+
- ) - })} -
- ) : ( -
-
- - - -
-
{t('balance.noTransactions')}
-
- )} + ) : transactions?.items && transactions.items.length > 0 ? ( +
+ {transactions.items.map((tx) => { + const isPositive = tx.amount_rubles >= 0 + const displayAmount = Math.abs(tx.amount_rubles) + const sign = isPositive ? '+' : '-' + const colorClass = isPositive ? 'text-success-400' : 'text-error-400' - {transactions && transactions.pages > 1 && ( -
- -
- {t('balance.page', { current: transactions.page, total: transactions.pages })} -
- + return ( +
+
+
+ + {getTypeLabel(tx.type)} + + + {new Date(tx.created_at).toLocaleDateString()} + +
+ {tx.description && ( +
{tx.description}
+ )} +
+
+ {sign}{formatAmount(displayAmount)} {currencySymbol} +
+
+ ) + })} +
+ ) : ( +
+
+ + + +
+
{t('balance.noTransactions')}
+
+ )} + + {transactions && transactions.pages > 1 && ( +
+ +
+ {t('balance.page', { current: transactions.page, total: transactions.pages })} +
+ +
+ )}
)}