From 64acfbee72a57a3635635166d6de6788834bbe96 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 02:17:22 +0300 Subject: [PATCH 01/22] 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 dd85a465953f97793e65b7725b9cbfb514a68b71 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 02:26:25 +0300 Subject: [PATCH 02/22] 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 03/22] 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 04/22] 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 06/22] 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 07/22] 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 11/22] 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 12/22] 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 13/22] 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 14/22] 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 15/22] 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 16/22] 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 17/22] 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 18/22] 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 19/22] 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 20/22] 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 21/22] 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 */}