From 026ba65b8c78a0810f6feeadcd3e5fdc6204d13e Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 03:11:19 +0300 Subject: [PATCH 01/54] Enhance ConnectionModal: improve body scroll locking functionality for better user experience on iOS, restore original styles and scroll position on modal close, and update modal wrapper styling for improved responsiveness and visual appeal. --- src/components/ConnectionModal.tsx | 42 ++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index ae7d8bf..c6d62a2 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -215,12 +215,36 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { setDetectedPlatform(detectPlatform()) }, []) - // Lock body scroll when modal is open + // Lock body scroll when modal is open (works on iOS too) useEffect(() => { - const originalStyle = window.getComputedStyle(document.body).overflow - document.body.style.overflow = 'hidden' + 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' + return () => { - document.body.style.overflow = originalStyle + // 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) } }, []) @@ -295,16 +319,18 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { // Modal wrapper - centered on desktop, top on mobile const ModalWrapper = ({ children }: { children: React.ReactNode }) => (
e.stopPropagation()} > {children} From 4c489547eac7e3ed31c71b5e2135e7aab73c9dda Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 03:16:33 +0300 Subject: [PATCH 02/54] Refactor ConnectionModal: implement auto-selection of platform and app upon data load, enhance app selection UI with improved layout and functionality, and add translation keys for 'changeApp' in English and Russian locales. --- src/components/ConnectionModal.tsx | 212 +++++++++++++---------------- src/locales/en.json | 3 +- src/locales/ru.json | 3 +- 3 files changed, 101 insertions(+), 117 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index c6d62a2..ce7ac3f 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -201,10 +201,10 @@ function detectPlatform(): string | null { export default function ConnectionModal({ onClose }: ConnectionModalProps) { const { t, i18n } = useTranslation() - const [selectedPlatform, setSelectedPlatform] = useState(null) const [selectedApp, setSelectedApp] = useState(null) const [copied, setCopied] = useState(false) const [detectedPlatform, setDetectedPlatform] = useState(null) + const [showAppSelector, setShowAppSelector] = useState(false) const { data: appConfig, isLoading, error } = useQuery({ queryKey: ['appConfig'], @@ -215,6 +215,22 @@ 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) + } + }, [appConfig, detectedPlatform, selectedApp]) + // Lock body scroll when modal is open (works on iOS too) useEffect(() => { const scrollY = window.scrollY @@ -273,11 +289,6 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { return available }, [appConfig, detectedPlatform]) - const platformApps = useMemo(() => { - if (!selectedPlatform || !appConfig?.platforms?.[selectedPlatform]) return [] - return appConfig.platforms[selectedPlatform] - }, [selectedPlatform, appConfig]) - const copySubscriptionLink = async () => { if (!appConfig?.subscriptionUrl) return try { @@ -384,18 +395,33 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { ) } - // Step 1: Select platform - if (!selectedPlatform) { + // 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 ( {/* Header */}
-
-
- 📱 -
+
+ {selectedApp && ( + + )}
-

{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 03/54] 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 04/54] 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 05/54] 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 06/54] 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 07/54] 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 08/54] 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 09/54] 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 10/54] 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 11/54] 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 12/54] 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 13/54] 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 16/54] 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 17/54] 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 18/54] 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 19/54] 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 20/54] 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 21/54] 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 22/54] 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 23/54] 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 24/54] 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 25/54] 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 26/54] 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 27/54] 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 28/54] 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 29/54] 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 30/54] 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 31/54] 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 32/54] 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 33/54] 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 34/54] 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 35/54] 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 36/54] 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 37/54] 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 38/54] 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 39/54] 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 40/54] 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 41/54] 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 42/54] 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 43/54] 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 44/54] 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 45/54] 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 46/54] 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 236c36f1abd92f036f2febdc54c6992a88a12338 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 16:52:01 +0300 Subject: [PATCH 47/54] Enhance ConnectionModal and TopUpModal with improved mobile support, new icons, and keyboard accessibility. Update localization files to include 'enterAmount' key for multiple languages. --- src/components/ConnectionModal.tsx | 519 ++++++++++++++++++----------- src/components/TopUpModal.tsx | 385 ++++++++++++++------- src/locales/en.json | 1 + src/locales/fa.json | 1 + src/locales/ru.json | 1 + src/locales/zh.json | 1 + 6 files changed, 603 insertions(+), 305 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index c1563c8..2b625ac 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, useCallback } from 'react' import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' import { useQuery } from '@tanstack/react-query' @@ -10,6 +10,7 @@ interface ConnectionModalProps { onClose: () => void } +// Icons const CloseIcon = () => ( @@ -34,9 +35,9 @@ const LinkIcon = () => ( ) -const ChevronIcon = () => ( - - +const ChevronIcon = ({ className = '' }: { className?: string }) => ( + + ) @@ -46,6 +47,13 @@ const BackIcon = () => ( ) +const DownloadIcon = () => ( + + + +) + +// App icons const HappIcon = () => ( @@ -87,6 +95,26 @@ const getAppIcon = (appName: string): React.ReactNode => { const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV'] const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:'] +const platformNames: Record = { + ios: 'iOS', + android: 'Android', + windows: 'Windows', + macos: 'macOS', + linux: 'Linux', + androidTV: 'Android TV', + appleTV: 'Apple TV' +} + +const platformIcons: Record = { + ios: '🍎', + android: '🤖', + windows: '💻', + macos: '🖥', + linux: '🐧', + androidTV: '📺', + appleTV: '📺' +} + function isValidExternalUrl(url: string | undefined): boolean { if (!url) return false const lowerUrl = url.toLowerCase().trim() @@ -113,7 +141,6 @@ function detectPlatform(): string | null { } function useIsMobile() { - // Initialize synchronously to avoid flash between desktop/mobile layouts const [isMobile, setIsMobile] = useState(() => { if (typeof window === 'undefined') return false return window.innerWidth < 768 @@ -126,6 +153,10 @@ function useIsMobile() { return isMobile } +// Touch-friendly constants +const touchButtonClass = "select-none" +const minTouchTarget = "min-h-[44px] min-w-[44px]" // Apple HIG minimum + export default function ConnectionModal({ onClose }: ConnectionModalProps) { const { t, i18n } = useTranslation() const [selectedApp, setSelectedApp] = useState(null) @@ -133,13 +164,10 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { const [detectedPlatform, setDetectedPlatform] = useState(null) const [showAppSelector, setShowAppSelector] = useState(false) - const { isTelegramWebApp, isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp() + const { isTelegramWebApp, isFullscreen, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp() const isMobileScreen = useIsMobile() - // 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) const safeTop = isTelegramWebApp ? Math.max(safeAreaInset.top, contentSafeAreaInset.top) + (isFullscreen ? 45 : 0) : 0 const { data: appConfig, isLoading, error } = useQuery({ @@ -147,6 +175,16 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { queryFn: () => subscriptionApi.getAppConfig(), }) + // Handle close with memoization + const handleClose = useCallback(() => { + onClose() + }, [onClose]) + + // Handle back (for app selector) + const handleBack = useCallback(() => { + setShowAppSelector(false) + }, []) + useEffect(() => { setDetectedPlatform(detectPlatform()) }, []) @@ -160,9 +198,57 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { if (app) setSelectedApp(app) }, [appConfig, detectedPlatform, selectedApp]) + // Keyboard support (Escape to close) - PC useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + if (showAppSelector) { + handleBack() + } else { + handleClose() + } + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [handleClose, handleBack, showAppSelector]) + + // Telegram back button support - Android + useEffect(() => { + if (!webApp) return + + const backHandler = showAppSelector ? handleBack : handleClose + + if (webApp.BackButton) { + webApp.BackButton.show() + webApp.BackButton.onClick(backHandler) + } + + return () => { + if (webApp.BackButton) { + webApp.BackButton.offClick(backHandler) + webApp.BackButton.hide() + } + } + }, [webApp, handleClose, handleBack, showAppSelector]) + + // Scroll lock - iOS/Android + useEffect(() => { + const scrollY = window.scrollY document.body.style.overflow = 'hidden' - return () => { document.body.style.overflow = '' } + // iOS specific + document.body.style.position = 'fixed' + document.body.style.width = '100%' + document.body.style.top = `-${scrollY}px` + + return () => { + document.body.style.overflow = '' + document.body.style.position = '' + document.body.style.width = '' + document.body.style.top = '' + window.scrollTo(0, scrollY) + } }, []) const getLocalizedText = (text: LocalizedText | undefined): string => { @@ -212,272 +298,329 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { window.location.href = redirectUrl } - // Desktop modal wrapper - compact centered modal with max height - const DesktopWrapper = ({ children }: { children: React.ReactNode }) => ( -
-
e.stopPropagation()} - > - {/* Desktop close button */} - - {children} -
-
- ) - - // Mobile fullscreen wrapper - like React Native Modal with animationType="slide" - // Use portal to render directly in body, avoiding transform/filter issues with fixed positioning + // Mobile fullscreen modal const MobileWrapper = ({ children }: { children: React.ReactNode }) => { const content = ( <> {/* Backdrop */} -
- {/* Modal - fullscreen overlay */} +
+ {/* Modal */}
- {/* Close button */} - {children}
) - + if (typeof document !== 'undefined') { return createPortal(content, document.body) } return content } - const Wrapper = isMobile ? MobileWrapper : DesktopWrapper + // Desktop centered modal + const DesktopWrapper = ({ children }: { children: React.ReactNode }) => ( +
+
e.stopPropagation()} + > + {children} +
+
+ ) - // Loading + const Wrapper = isMobileScreen ? MobileWrapper : DesktopWrapper + + // Loading state if (isLoading) { return ( -
-
+
+
) } - // Error + // Error state if (error || !appConfig) { return ( -
-
😕
+
+
+ 😕 +

{t('common.error')}

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

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

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

- +
) } - // App selector + // App selector view if (showAppSelector) { - const platformNames: Record = { - ios: 'iOS', - android: 'Android', - windows: 'Windows', - macos: 'macOS', - linux: 'Linux', - androidTV: 'Android TV', - appleTV: 'Apple TV' - } - return ( {/* Header */} -
- -

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

+

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

+
- {/* Apps grouped by platform */} -
- {availablePlatforms.map(platform => { - const apps = appConfig.platforms[platform] - if (!apps?.length) return null - const isCurrentPlatform = platform === detectedPlatform + {/* Apps list */} +
+
+ {availablePlatforms.map(platform => { + const apps = appConfig.platforms[platform] + if (!apps?.length) return null + const isCurrentPlatform = platform === detectedPlatform - return ( -
- {/* Platform header */} -
- - {platformNames[platform] || platform} - - {isCurrentPlatform && ( - - {t('subscription.connection.yourDevice')} + return ( +
+ {/* Platform header */} +
+ {platformIcons[platform]} + + {platformNames[platform] || platform} - )} -
+ {isCurrentPlatform && ( + + {t('subscription.connection.yourDevice')} + + )} +
- {/* Apps for this platform */} -
- {apps.map(app => ( - - ))} + {/* Apps grid */} +
+ {apps.map(app => { + const isSelected = selectedApp?.id === app.id + return ( + + ) + })} +
-
- ) - })} + ) + })} +
) } - // Main view + // Main connection view return ( - {/* Header - app selector */} -
+ {/* Header with app selector */} +
+
+

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

+ +
+ + {/* App selector button */}
- {/* Content */} -
- {/* Step 1 */} - {selectedApp?.installationStep && ( -
-
-
1
-

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

+ {/* Steps 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)} + + ))} +
+ )}
-

{getLocalizedText(selectedApp.installationStep.description)}

- {selectedApp.installationStep.buttons && selectedApp.installationStep.buttons.length > 0 && ( - - )} + {t('subscription.connection.addToApp', { appName: selectedApp.name })} + + )} - {/* Step 2 */} - {selectedApp?.addSubscriptionStep && ( -
-
-
2
-

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

-
-

{getLocalizedText(selectedApp.addSubscriptionStep.description)}

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

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

+ {/* Step 3: Connect */} + {selectedApp?.connectAndUseStep && ( +
+
+
+ 3 +
+
+

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

+

{getLocalizedText(selectedApp.connectAndUseStep.description)}

+
+
-

{getLocalizedText(selectedApp.connectAndUseStep.description)}

-
- )} + )} +
- ) } diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index 14f42d4..55a3d5d 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -1,9 +1,10 @@ -import { useState, useRef, useEffect } from 'react' +import { useState, useRef, useEffect, useCallback } from 'react' import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' import { useMutation } from '@tanstack/react-query' import { balanceApi } from '../api/balance' import { useCurrency } from '../hooks/useCurrency' +import { useTelegramWebApp } from '../hooks/useTelegramWebApp' import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit' import type { PaymentMethod } from '../types' @@ -18,7 +19,6 @@ const openPaymentLink = (url: string, reservedWindow?: Window | null) => { try { webApp.openTelegramLink(url); return } catch (e) { console.warn('[TopUpModal] openTelegramLink failed:', e) } } if (webApp?.openLink) { - // try_browser: true - открывает диалог для перехода во внешний браузер (важно для мобильных) try { webApp.openLink(url, { try_instant_view: false, try_browser: true }); return } catch (e) { console.warn('[TopUpModal] webApp.openLink failed:', e) } } if (reservedWindow && !reservedWindow.closed) { @@ -30,16 +30,46 @@ const openPaymentLink = (url: string, reservedWindow?: Window | null) => { window.location.href = url } +// Icons +const CloseIcon = () => ( + + + +) + +const WalletIcon = () => ( + + + +) + interface TopUpModalProps { method: PaymentMethod onClose: () => void initialAmountRubles?: number } +function useIsMobile() { + const [isMobile, setIsMobile] = useState(() => { + if (typeof window === 'undefined') return false + return window.innerWidth < 768 + }) + useEffect(() => { + const check = () => setIsMobile(window.innerWidth < 768) + window.addEventListener('resize', check) + return () => window.removeEventListener('resize', check) + }, []) + return isMobile +} + export default function TopUpModal({ method, onClose, initialAmountRubles }: TopUpModalProps) { const { t } = useTranslation() const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } = useCurrency() + const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp() const inputRef = useRef(null) + const isMobileScreen = useIsMobile() + + const safeBottom = isTelegramWebApp ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) : 0 const getInitialAmount = (): string => { if (!initialAmountRubles || initialAmountRubles <= 0) return '' @@ -56,31 +86,69 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top ) const popupRef = useRef(null) - // Scroll lock when modal is open + // Handle close with memoization + const handleClose = useCallback(() => { + onClose() + }, [onClose]) + + // Keyboard support (Escape to close) - PC + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault() + handleClose() + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [handleClose]) + + // Telegram back button support - Android + useEffect(() => { + if (!webApp) return + + // Show back button in Telegram + if (webApp.BackButton) { + webApp.BackButton.show() + webApp.BackButton.onClick(handleClose) + } + + return () => { + if (webApp.BackButton) { + webApp.BackButton.offClick(handleClose) + webApp.BackButton.hide() + } + } + }, [webApp, handleClose]) + + // Scroll lock - iOS/Android rubber band prevention 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' + // iOS specific - prevent body scroll + document.body.style.position = 'fixed' + document.body.style.width = '100%' + document.body.style.top = `-${scrollY}px` return () => { document.removeEventListener('touchmove', preventScroll) document.removeEventListener('wheel', preventWheel) document.body.style.overflow = '' + document.body.style.position = '' + document.body.style.width = '' + document.body.style.top = '' window.scrollTo(0, scrollY) } }, []) @@ -101,7 +169,7 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top if (!webApp?.openInvoice) { setError('Оплата Stars доступна только в Telegram Mini App'); return } try { webApp.openInvoice(data.invoice_url, (status) => { - if (status === 'paid') { setError(null); onClose() } + if (status === 'paid') { setError(null); handleClose() } else if (status === 'failed') { setError(t('wheel.starsPaymentFailed')) } }) } catch (e) { setError('Ошибка: ' + String(e)) } @@ -121,7 +189,7 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top const redirectUrl = data.payment_url || (data as any).invoice_url if (redirectUrl) openPaymentLink(redirectUrl, popupRef.current) popupRef.current = null - onClose() + handleClose() }, onError: (err: unknown) => { try { if (popupRef.current && !popupRef.current.closed) popupRef.current.close() } catch {} @@ -162,126 +230,209 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top : convertAmount(rub).toFixed(currencyDecimals) const isPending = topUpMutation.isPending || starsPaymentMutation.isPending - // Auto-focus input on mount + // Auto-focus input on mount (delayed for animation) useEffect(() => { const timer = setTimeout(() => { - inputRef.current?.focus() - }, 100) + // Don't auto-focus on mobile to prevent keyboard from opening immediately + if (!isMobileScreen) { + inputRef.current?.focus() + } + }, 300) return () => clearTimeout(timer) - }, []) + }, [isMobileScreen]) - const modalContent = ( + // Mobile bottom sheet modal + const MobileWrapper = ({ children }: { children: React.ReactNode }) => { + const content = ( + <> + {/* Backdrop */} +
+ {/* Modal */} +
e.stopPropagation()} + > + {/* Handle bar - iOS style */} +
+
+
+ {children} +
+ + ) + + if (typeof document !== 'undefined') { + return createPortal(content, document.body) + } + return content + } + + // Desktop centered modal + const DesktopWrapper = ({ children }: { children: React.ReactNode }) => (
e.stopPropagation()} > - {/* Header */} -
- {methodName} - -
- -
- {/* 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) - return ( - - ) - })} -
- )} - - {/* Error */} - {error && ( -
{error}
- )} - - {/* Submit */} - -
+ {children}
) - if (typeof document !== 'undefined') { - return createPortal(modalContent, document.body) - } - return modalContent + const Wrapper = isMobileScreen ? MobileWrapper : DesktopWrapper + + // Touch-friendly button classes + const touchButtonClass = "select-none" + const minTouchTarget = "min-h-[44px] min-w-[44px]" // Apple HIG minimum + + const modalContent = ( + <> + {/* Header */} +
+
+
+ +
+
+

{methodName}

+

{formatAmount(minRubles, 0)} – {formatAmount(maxRubles, 0)}

+
+
+ +
+ + {/* Content */} +
+ {/* Payment options */} + {hasOptions && method.options && ( +
+ {method.options.map((opt) => ( + + ))} +
+ )} + + {/* Amount input */} +
+ setAmount(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + handleSubmit() + } + }} + placeholder={t('balance.enterAmount') || 'Введите сумму'} + className="w-full h-14 px-5 pr-14 text-xl font-bold bg-dark-800/50 border-2 border-dark-700/50 rounded-2xl text-dark-100 placeholder:text-dark-500 placeholder:font-normal focus:outline-none focus:border-accent-500/50 focus:bg-dark-800/70 transition-all" + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + spellCheck="false" + style={{ + fontSize: '20px', // Prevent iOS zoom on focus + WebkitTapHighlightColor: 'transparent' + }} + /> + + {currencySymbol} + +
+ + {/* Quick amounts */} + {quickAmounts.length > 0 && ( +
+ {quickAmounts.map((a) => { + const val = getQuickValue(a) + const isSelected = amount === val + return ( + + ) + })} +
+ )} + + {/* Error */} + {error && ( +
+ + + + {error} +
+ )} + + {/* Submit */} + +
+ + ) + + return {modalContent} } diff --git a/src/locales/en.json b/src/locales/en.json index a2501e5..4e97bd5 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -253,6 +253,7 @@ "currentBalance": "Current Balance", "topUp": "Top Up", "topUpBalance": "Top Up Balance", + "enterAmount": "Enter amount", "paymentMethods": "Payment Methods", "transactionHistory": "Transaction History", "noTransactions": "No transactions", diff --git a/src/locales/fa.json b/src/locales/fa.json index 2c14f63..0f00446 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -183,6 +183,7 @@ "currentBalance": "موجودی فعلی", "topUp": "شارژ", "topUpBalance": "شارژ موجودی", + "enterAmount": "مقدار را وارد کنید", "paymentMethods": { "yookassa": { "name": "YooKassa", diff --git a/src/locales/ru.json b/src/locales/ru.json index 847c0b2..b6453d3 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -253,6 +253,7 @@ "currentBalance": "Текущий баланс", "topUp": "Пополнить", "topUpBalance": "Пополнение баланса", + "enterAmount": "Введите сумму", "paymentMethods": "Способы оплаты", "transactionHistory": "История операций", "noTransactions": "Нет операций", diff --git a/src/locales/zh.json b/src/locales/zh.json index afdaf2c..c8c2a9f 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -184,6 +184,7 @@ "currentBalance": "当前余额", "topUp": "充值", "topUpBalance": "充值余额", + "enterAmount": "输入金额", "paymentMethods": { "yookassa": { "name": "YooKassa", From 217c038c3825b5c955f4c65f999f77818f70e9b7 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 17:08:07 +0300 Subject: [PATCH 48/54] Fix modal issues: remove jerky animations, restore text platform names, fix PC payments and app selection - Remove position:fixed scroll lock to prevent page jumping on modal open - Remove emoji platform icons, keep text-only platform names - Preserve popupRef pre-open logic for PC payment links - Change detectedPlatform from useState to useMemo to fix race condition in app selection --- src/components/ConnectionModal.tsx | 526 +++++++++++------------------ src/components/TopUpModal.tsx | 385 +++++++++------------ 2 files changed, 361 insertions(+), 550 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index 2b625ac..1833984 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -35,9 +35,9 @@ const LinkIcon = () => ( ) -const ChevronIcon = ({ className = '' }: { className?: string }) => ( - - +const ChevronIcon = () => ( + + ) @@ -47,12 +47,6 @@ const BackIcon = () => ( ) -const DownloadIcon = () => ( - - - -) - // App icons const HappIcon = () => ( @@ -95,26 +89,6 @@ const getAppIcon = (appName: string): React.ReactNode => { const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV'] const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:'] -const platformNames: Record = { - ios: 'iOS', - android: 'Android', - windows: 'Windows', - macos: 'macOS', - linux: 'Linux', - androidTV: 'Android TV', - appleTV: 'Apple TV' -} - -const platformIcons: Record = { - ios: '🍎', - android: '🤖', - windows: '💻', - macos: '🖥', - linux: '🐧', - androidTV: '📺', - appleTV: '📺' -} - function isValidExternalUrl(url: string | undefined): boolean { if (!url) return false const lowerUrl = url.toLowerCase().trim() @@ -153,19 +127,15 @@ function useIsMobile() { return isMobile } -// Touch-friendly constants -const touchButtonClass = "select-none" -const minTouchTarget = "min-h-[44px] min-w-[44px]" // Apple HIG minimum - 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 { isTelegramWebApp, isFullscreen, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp() const isMobileScreen = useIsMobile() + const isMobile = isMobileScreen const safeBottom = isTelegramWebApp ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) : 0 const safeTop = isTelegramWebApp ? Math.max(safeAreaInset.top, contentSafeAreaInset.top) + (isFullscreen ? 45 : 0) : 0 @@ -175,80 +145,64 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { queryFn: () => subscriptionApi.getAppConfig(), }) - // Handle close with memoization - const handleClose = useCallback(() => { - onClose() - }, [onClose]) - - // Handle back (for app selector) - const handleBack = useCallback(() => { - setShowAppSelector(false) - }, []) - - useEffect(() => { - setDetectedPlatform(detectPlatform()) - }, []) + // Detect platform ONCE on mount (stable reference) + const detectedPlatform = useMemo(() => detectPlatform(), []) + // Set initial app based on detected platform - AFTER appConfig loads useEffect(() => { if (!appConfig?.platforms || selectedApp) return - const platform = detectedPlatform || platformOrder.find(p => appConfig.platforms[p]?.length > 0) + + // Priority: detected platform > first available platform + let platform = detectedPlatform + if (!platform || !appConfig.platforms[platform]?.length) { + platform = platformOrder.find(p => appConfig.platforms[p]?.length > 0) || null + } + if (!platform || !appConfig.platforms[platform]?.length) return + const apps = appConfig.platforms[platform] + // Select featured app or first app for the detected platform const app = apps.find(a => a.isFeatured) || apps[0] if (app) setSelectedApp(app) }, [appConfig, detectedPlatform, selectedApp]) - // Keyboard support (Escape to close) - PC + const handleClose = useCallback(() => { + onClose() + }, [onClose]) + + const handleBack = useCallback(() => { + setShowAppSelector(false) + }, []) + + // Keyboard: Escape to close (PC) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault() - if (showAppSelector) { - handleBack() - } else { - handleClose() - } + if (showAppSelector) handleBack() + else handleClose() } } document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) }, [handleClose, handleBack, showAppSelector]) - // Telegram back button support - Android + // Telegram back button (Android) useEffect(() => { - if (!webApp) return - - const backHandler = showAppSelector ? handleBack : handleClose - - if (webApp.BackButton) { - webApp.BackButton.show() - webApp.BackButton.onClick(backHandler) - } - + if (!webApp?.BackButton) return + const handler = showAppSelector ? handleBack : handleClose + webApp.BackButton.show() + webApp.BackButton.onClick(handler) return () => { - if (webApp.BackButton) { - webApp.BackButton.offClick(backHandler) - webApp.BackButton.hide() - } + webApp.BackButton.offClick(handler) + webApp.BackButton.hide() } }, [webApp, handleClose, handleBack, showAppSelector]) - // Scroll lock - iOS/Android + // Scroll lock useEffect(() => { - const scrollY = window.scrollY document.body.style.overflow = 'hidden' - // iOS specific - document.body.style.position = 'fixed' - document.body.style.width = '100%' - document.body.style.top = `-${scrollY}px` - - return () => { - document.body.style.overflow = '' - document.body.style.position = '' - document.body.style.width = '' - document.body.style.top = '' - window.scrollTo(0, scrollY) - } + return () => { document.body.style.overflow = '' } }, []) const getLocalizedText = (text: LocalizedText | undefined): string => { @@ -260,6 +214,7 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { const availablePlatforms = useMemo(() => { if (!appConfig?.platforms) return [] const available = platformOrder.filter(key => appConfig.platforms[key]?.length > 0) + // Put detected platform first if (detectedPlatform && available.includes(detectedPlatform)) { return [detectedPlatform, ...available.filter(p => p !== detectedPlatform)] } @@ -298,209 +253,154 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { window.location.href = redirectUrl } - // Mobile fullscreen modal - const MobileWrapper = ({ children }: { children: React.ReactNode }) => { - const content = ( - <> - {/* Backdrop */} + // Wrapper component + const Wrapper = ({ children }: { children: React.ReactNode }) => { + if (isMobile) { + // Mobile fullscreen + const content = (
- {/* Modal */} -
{children}
- - ) - - if (typeof document !== 'undefined') { - return createPortal(content, document.body) + ) + if (typeof document !== 'undefined') return createPortal(content, document.body) + return content } - return content + + // Desktop centered + return ( +
+
e.stopPropagation()} + > + {children} +
+
+ ) } - // Desktop centered modal - const DesktopWrapper = ({ children }: { children: React.ReactNode }) => ( -
-
e.stopPropagation()} - > - {children} -
-
- ) - - const Wrapper = isMobileScreen ? MobileWrapper : DesktopWrapper - - // Loading state + // Loading if (isLoading) { return ( -
-
+
+
) } - // Error state + // Error if (error || !appConfig) { return (
-
- 😕 -
-

{t('common.error')}

- +

{t('common.error')}

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

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

-

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

- +

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

+
) } - // App selector view + // 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 */} -
- -

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

- +

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

- {/* Apps list */} -
-
- {availablePlatforms.map(platform => { - const apps = appConfig.platforms[platform] - if (!apps?.length) return null - const isCurrentPlatform = platform === detectedPlatform + {/* Apps grouped by platform */} +
+ {availablePlatforms.map(platform => { + const apps = appConfig.platforms[platform] + if (!apps?.length) return null + const isCurrentPlatform = platform === detectedPlatform - return ( -
- {/* Platform header */} -
- {platformIcons[platform]} - - {platformNames[platform] || platform} + return ( +
+ {/* Platform header */} +
+ + {platformNames[platform] || platform} + + {isCurrentPlatform && ( + + {t('subscription.connection.yourDevice')} - {isCurrentPlatform && ( - - {t('subscription.connection.yourDevice')} - - )} -
- - {/* Apps grid */} -
- {apps.map(app => { - const isSelected = selectedApp?.id === app.id - return ( - - ) - })} -
+ )}
- ) - })} -
+ + {/* Apps for this platform */} +
+ {apps.map(app => ( + + ))} +
+
+ ) + })}
) } - // Main connection view + // Main view return ( - {/* Header with app selector */} -
+ {/* Header */} +

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

-
@@ -508,118 +408,94 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { {/* App selector button */}
- {/* Steps 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)} - - ))} -
- )} + {/* Steps */} +
+ {/* Step 1: Install */} + {selectedApp?.installationStep && ( +
+
+ 1 + {t('subscription.connection.installApp')}
- )} - - {/* Step 2: Add subscription */} - {selectedApp?.addSubscriptionStep && ( -
-
-
- 2 -
-
-

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

-

{getLocalizedText(selectedApp.addSubscriptionStep.description)}

-
-
- -
- {/* Connect button */} - {selectedApp.deepLink && ( - - )} + {getLocalizedText(btn.buttonText)} + + ))} +
+ )} +
+ )} - {/* Copy link button */} + {/* Step 2: Add subscription */} + {selectedApp?.addSubscriptionStep && ( +
+
+ 2 + {t('subscription.connection.addSubscription')} +
+

{getLocalizedText(selectedApp.addSubscriptionStep.description)}

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

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

-

{getLocalizedText(selectedApp.connectAndUseStep.description)}

-
-
+ {/* Copy link */} +
- )} -
+
+ )} + + {/* Step 3: Connect */} + {selectedApp?.connectAndUseStep && ( +
+
+ 3 + {t('subscription.connection.connectVpn')} +
+

{getLocalizedText(selectedApp.connectAndUseStep.description)}

+
+ )}
) diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index 55a3d5d..8618c65 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -32,17 +32,11 @@ const openPaymentLink = (url: string, reservedWindow?: Window | null) => { // Icons const CloseIcon = () => ( - + ) -const WalletIcon = () => ( - - - -) - interface TopUpModalProps { method: PaymentMethod onClose: () => void @@ -52,10 +46,10 @@ interface TopUpModalProps { function useIsMobile() { const [isMobile, setIsMobile] = useState(() => { if (typeof window === 'undefined') return false - return window.innerWidth < 768 + return window.innerWidth < 640 }) useEffect(() => { - const check = () => setIsMobile(window.innerWidth < 768) + const check = () => setIsMobile(window.innerWidth < 640) window.addEventListener('resize', check) return () => window.removeEventListener('resize', check) }, []) @@ -86,12 +80,11 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top ) const popupRef = useRef(null) - // Handle close with memoization const handleClose = useCallback(() => { onClose() }, [onClose]) - // Keyboard support (Escape to close) - PC + // Keyboard: Escape to close (PC) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { @@ -103,25 +96,18 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top return () => document.removeEventListener('keydown', handleKeyDown) }, [handleClose]) - // Telegram back button support - Android + // Telegram back button (Android) useEffect(() => { - if (!webApp) return - - // Show back button in Telegram - if (webApp.BackButton) { - webApp.BackButton.show() - webApp.BackButton.onClick(handleClose) - } - + if (!webApp?.BackButton) return + webApp.BackButton.show() + webApp.BackButton.onClick(handleClose) return () => { - if (webApp.BackButton) { - webApp.BackButton.offClick(handleClose) - webApp.BackButton.hide() - } + webApp.BackButton.offClick(handleClose) + webApp.BackButton.hide() } }, [webApp, handleClose]) - // Scroll lock - iOS/Android rubber band prevention + // Scroll lock - simple version without position:fixed useEffect(() => { const scrollY = window.scrollY const preventScroll = (e: TouchEvent) => { @@ -137,18 +123,10 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top document.addEventListener('touchmove', preventScroll, { passive: false }) document.addEventListener('wheel', preventWheel, { passive: false }) document.body.style.overflow = 'hidden' - // iOS specific - prevent body scroll - document.body.style.position = 'fixed' - document.body.style.width = '100%' - document.body.style.top = `-${scrollY}px` - return () => { document.removeEventListener('touchmove', preventScroll) document.removeEventListener('wheel', preventWheel) document.body.style.overflow = '' - document.body.style.position = '' - document.body.style.width = '' - document.body.style.top = '' window.scrollTo(0, scrollY) } }, []) @@ -169,7 +147,7 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top if (!webApp?.openInvoice) { setError('Оплата Stars доступна только в Telegram Mini App'); return } try { webApp.openInvoice(data.invoice_url, (status) => { - if (status === 'paid') { setError(null); handleClose() } + if (status === 'paid') { setError(null); onClose() } else if (status === 'failed') { setError(t('wheel.starsPaymentFailed')) } }) } catch (e) { setError('Ошибка: ' + String(e)) } @@ -189,7 +167,7 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top const redirectUrl = data.payment_url || (data as any).invoice_url if (redirectUrl) openPaymentLink(redirectUrl, popupRef.current) popupRef.current = null - handleClose() + onClose() }, onError: (err: unknown) => { try { if (popupRef.current && !popupRef.current.closed) popupRef.current.close() } catch {} @@ -216,6 +194,7 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top } const amountKopeks = Math.round(amountRubles * 100) + // IMPORTANT: Pre-open window on PC to avoid popup blockers if (!isTelegramMiniApp) { try { popupRef.current = window.open('', '_blank') } catch { popupRef.current = null } } @@ -230,209 +209,165 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top : convertAmount(rub).toFixed(currencyDecimals) const isPending = topUpMutation.isPending || starsPaymentMutation.isPending - // Auto-focus input on mount (delayed for animation) + // Auto-focus input (only on desktop) useEffect(() => { const timer = setTimeout(() => { - // Don't auto-focus on mobile to prevent keyboard from opening immediately - if (!isMobileScreen) { - inputRef.current?.focus() - } - }, 300) + if (!isMobileScreen) inputRef.current?.focus() + }, 150) return () => clearTimeout(timer) }, [isMobileScreen]) - // Mobile bottom sheet modal - const MobileWrapper = ({ children }: { children: React.ReactNode }) => { - const content = ( - <> - {/* Backdrop */} -
- {/* Modal */} -
e.stopPropagation()} - > - {/* Handle bar - iOS style */} -
-
-
- {children} -
- - ) - - if (typeof document !== 'undefined') { - return createPortal(content, document.body) - } - return content - } - - // Desktop centered modal - const DesktopWrapper = ({ children }: { children: React.ReactNode }) => ( -
+ // Mobile bottom sheet + const MobileModal = () => ( + <> +
e.stopPropagation()} > - {children} -
-
- ) - - const Wrapper = isMobileScreen ? MobileWrapper : DesktopWrapper - - // Touch-friendly button classes - const touchButtonClass = "select-none" - const minTouchTarget = "min-h-[44px] min-w-[44px]" // Apple HIG minimum - - const modalContent = ( - <> - {/* Header */} -
-
-
- -
-
-

{methodName}

-

{formatAmount(minRubles, 0)} – {formatAmount(maxRubles, 0)}

-
+ {/* Handle */} +
+
- -
- - {/* Content */} -
- {/* Payment options */} - {hasOptions && method.options && ( -
- {method.options.map((opt) => ( - - ))} -
- )} - - {/* Amount input */} -
- setAmount(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault() - handleSubmit() - } - }} - placeholder={t('balance.enterAmount') || 'Введите сумму'} - className="w-full h-14 px-5 pr-14 text-xl font-bold bg-dark-800/50 border-2 border-dark-700/50 rounded-2xl text-dark-100 placeholder:text-dark-500 placeholder:font-normal focus:outline-none focus:border-accent-500/50 focus:bg-dark-800/70 transition-all" - autoComplete="off" - autoCorrect="off" - autoCapitalize="off" - spellCheck="false" - style={{ - fontSize: '20px', // Prevent iOS zoom on focus - WebkitTapHighlightColor: 'transparent' - }} - /> - - {currencySymbol} - + {/* Header */} +
+ {methodName} + +
+ {/* Content */} +
+ {renderContent()}
- - {/* Quick amounts */} - {quickAmounts.length > 0 && ( -
- {quickAmounts.map((a) => { - const val = getQuickValue(a) - const isSelected = amount === val - return ( - - ) - })} -
- )} - - {/* Error */} - {error && ( -
- - - - {error} -
- )} - - {/* Submit */} -
) - return {modalContent} + // Desktop centered modal + const DesktopModal = () => ( +
+
e.stopPropagation()} + > + {/* Header */} +
+ {methodName} + +
+ {/* Content */} +
+ {renderContent()} +
+
+
+ ) + + const renderContent = () => ( + <> + {/* Payment options */} + {hasOptions && method.options && ( +
+ {method.options.map((opt) => ( + + ))} +
+ )} + + {/* Amount input */} +
+ setAmount(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleSubmit() } }} + 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 transition-colors" + style={{ fontSize: '18px' }} + autoComplete="off" + /> + + {currencySymbol} + +
+ + {/* Quick amounts */} + {quickAmounts.length > 0 && ( +
+ {quickAmounts.map((a) => { + const val = getQuickValue(a) + const isSelected = amount === val + return ( + + ) + })} +
+ )} + + {/* Error */} + {error && ( +
{error}
+ )} + + {/* Submit */} + + + ) + + const content = isMobileScreen ? : + + if (typeof document !== 'undefined') { + return createPortal(content, document.body) + } + return content } From 0a0d2e015b828e6e01ccda4725e73cef95372a7d Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 17:19:22 +0300 Subject: [PATCH 49/54] Refactor TopUpModal and ConnectionModal for improved layout and icon integration - Adjusted layout in ConnectionModal for better vertical alignment. - Introduced new icons for payment methods in TopUpModal, enhancing visual representation. - Updated input fields and buttons for a more modern design and improved user experience. - Enhanced mobile responsiveness and accessibility features. --- src/components/ConnectionModal.tsx | 2 +- src/components/TopUpModal.tsx | 331 ++++++++++++++++++++--------- 2 files changed, 226 insertions(+), 107 deletions(-) diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index 1833984..3b5ccb4 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -274,7 +274,7 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { // Desktop centered return ( -
+
e.stopPropagation()} diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index 8618c65..ffe11a6 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -37,6 +37,36 @@ const CloseIcon = () => ( ) +const WalletIcon = () => ( + + + +) + +const StarIcon = () => ( + + + +) + +const CardIcon = () => ( + + + +) + +const CryptoIcon = () => ( + + + +) + +const SparklesIcon = () => ( + + + +) + interface TopUpModalProps { method: PaymentMethod onClose: () => void @@ -56,6 +86,14 @@ function useIsMobile() { return isMobile } +// Get method icon based on method type +const getMethodIcon = (methodId: string) => { + const id = methodId.toLowerCase() + if (id.includes('stars')) return + if (id.includes('crypto') || id.includes('ton') || id.includes('usdt')) return + return +} + export default function TopUpModal({ method, onClose, initialAmountRubles }: TopUpModalProps) { const { t } = useTranslation() const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } = useCurrency() @@ -79,6 +117,7 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top method.options && method.options.length > 0 ? method.options[0].id : null ) const popupRef = useRef(null) + const [isInputFocused, setIsInputFocused] = useState(false) const handleClose = useCallback(() => { onClose() @@ -107,7 +146,7 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top } }, [webApp, handleClose]) - // Scroll lock - simple version without position:fixed + // Scroll lock useEffect(() => { const scrollY = window.scrollY const preventScroll = (e: TouchEvent) => { @@ -194,7 +233,6 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top } const amountKopeks = Math.round(amountRubles * 100) - // IMPORTANT: Pre-open window on PC to avoid popup blockers if (!isTelegramMiniApp) { try { popupRef.current = window.open('', '_blank') } catch { popupRef.current = null } } @@ -217,104 +255,88 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top return () => clearTimeout(timer) }, [isMobileScreen]) - // Mobile bottom sheet - const MobileModal = () => ( - <> -
-
e.stopPropagation()} - > - {/* Handle */} -
-
-
- {/* Header */} -
- {methodName} - -
- {/* Content */} -
- {renderContent()} -
-
- - ) - - // Desktop centered modal - const DesktopModal = () => ( -
-
e.stopPropagation()} - > - {/* Header */} -
- {methodName} - -
- {/* Content */} -
- {renderContent()} -
-
-
- ) + // Calculate display amount for preview + const displayAmount = amount && parseFloat(amount) > 0 ? parseFloat(amount) : 0 const renderContent = () => ( - <> - {/* Payment options */} +
+ {/* Header icon and method */} +
+
+
+ {getMethodIcon(method.id)} +
+
+
+

{methodName}

+

+ {formatAmount(minRubles, 0)} – {formatAmount(maxRubles, 0)} {currencySymbol} +

+
+
+ + {/* Payment options (if any) */} {hasOptions && method.options && ( -
- {method.options.map((opt) => ( - - ))} +
+ +
+ {method.options.map((opt) => ( + + ))} +
)} - {/* Amount input */} -
- setAmount(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleSubmit() } }} - 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 transition-colors" - style={{ fontSize: '18px' }} - autoComplete="off" - /> - - {currencySymbol} - + {/* Amount input - modern design */} +
+ +
+ setAmount(e.target.value)} + onFocus={() => setIsInputFocused(true)} + onBlur={() => setIsInputFocused(false)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleSubmit() } }} + placeholder="0" + className="w-full h-16 px-5 pr-16 text-2xl font-bold bg-transparent text-dark-100 placeholder:text-dark-600 focus:outline-none" + style={{ fontSize: '24px' }} + autoComplete="off" + /> + + {currencySymbol} + +
- {/* Quick amounts */} + {/* Quick amount buttons */} {quickAmounts.length > 0 && (
{quickAmounts.map((a) => { @@ -325,10 +347,10 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top key={a} type="button" onClick={() => { setAmount(val); inputRef.current?.blur() }} - className={`flex-1 py-2.5 rounded-xl text-sm font-semibold transition-all ${ + className={`flex-1 py-3 rounded-xl text-sm font-bold transition-all duration-200 ${ isSelected - ? 'bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/30' - : 'bg-dark-800 text-dark-300 hover:bg-dark-700' + ? 'bg-accent-500/20 text-accent-400 ring-1 ring-accent-500/40' + : 'bg-dark-800/50 text-dark-400 hover:bg-dark-800 hover:text-dark-300 border border-dark-700/30' }`} > {formatAmount(a, 0)} @@ -338,32 +360,129 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
)} - {/* Error */} + {/* Error message */} {error && ( -
{error}
+
+ + + + {error} +
)} - {/* Submit */} + {/* Submit button */} +
+ ) + + // Mobile bottom sheet + const MobileModal = () => ( + <> + {/* Backdrop */} +
+ {/* Bottom sheet */} +
e.stopPropagation()} + > + {/* Handle bar */} +
+
+
+ + {/* Header */} +
+
+ + {t('balance.topUp')} +
+ +
+ + {/* Divider */} +
+ + {/* Content */} +
+ {renderContent()} +
+
) + // Desktop centered modal + const DesktopModal = () => ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+
+ +
+ {t('balance.topUp')} +
+ +
+ + {/* Content */} +
+ {renderContent()} +
+
+
+ ) + const content = isMobileScreen ? : if (typeof document !== 'undefined') { From 7f0d6a7d18f2f1c2cf69f6d0e7d9a767ce159a80 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 17:25:43 +0300 Subject: [PATCH 50/54] Enhance TopUpModal styling and update localization files - Updated button styles in TopUpModal for improved visual feedback during interactions. - Adjusted modal positioning for better user experience on desktop. - Added 'paymentMethod' key to localization files for English, Persian, Russian, and Chinese languages, enhancing multi-language support. --- src/components/TopUpModal.tsx | 16 ++++++++-------- src/locales/en.json | 1 + src/locales/fa.json | 1 + src/locales/ru.json | 1 + src/locales/zh.json | 1 + 5 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index ffe11a6..a52ddd8 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -375,12 +375,12 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top type="button" onClick={handleSubmit} disabled={isPending || !amount || parseFloat(amount) <= 0} - className={`relative w-full h-14 rounded-2xl text-base font-bold transition-all duration-300 overflow-hidden ${ + className={`relative w-full h-14 rounded-2xl text-base font-bold transition-colors duration-200 overflow-hidden ${ isPending || !amount || parseFloat(amount) <= 0 ? 'bg-dark-700 text-dark-500 cursor-not-allowed' : isStarsMethod - ? 'bg-gradient-to-r from-yellow-500 to-orange-500 text-white shadow-lg shadow-yellow-500/25 hover:shadow-xl hover:shadow-yellow-500/30 hover:scale-[1.02] active:scale-[0.98]' - : 'bg-gradient-to-r from-accent-500 to-accent-600 text-white shadow-lg shadow-accent-500/25 hover:shadow-xl hover:shadow-accent-500/30 hover:scale-[1.02] active:scale-[0.98]' + ? 'bg-gradient-to-r from-yellow-500 to-orange-500 text-white shadow-lg shadow-yellow-500/25 hover:from-yellow-400 hover:to-orange-400 active:from-yellow-600 active:to-orange-600' + : 'bg-gradient-to-r from-accent-500 to-accent-600 text-white shadow-lg shadow-accent-500/25 hover:from-accent-400 hover:to-accent-500 active:from-accent-600 active:to-accent-700' }`} > {isPending ? ( @@ -408,13 +408,13 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top <> {/* Backdrop */}
{/* Bottom sheet */}
e.stopPropagation()} > @@ -448,15 +448,15 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top ) - // Desktop centered modal + // Desktop modal - positioned higher const DesktopModal = () => (
e.stopPropagation()} > {/* Header */} diff --git a/src/locales/en.json b/src/locales/en.json index 4e97bd5..718150a 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -254,6 +254,7 @@ "topUp": "Top Up", "topUpBalance": "Top Up Balance", "enterAmount": "Enter amount", + "paymentMethod": "Payment Method", "paymentMethods": "Payment Methods", "transactionHistory": "Transaction History", "noTransactions": "No transactions", diff --git a/src/locales/fa.json b/src/locales/fa.json index 0f00446..a13371a 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -184,6 +184,7 @@ "topUp": "شارژ", "topUpBalance": "شارژ موجودی", "enterAmount": "مقدار را وارد کنید", + "paymentMethod": "روش پرداخت", "paymentMethods": { "yookassa": { "name": "YooKassa", diff --git a/src/locales/ru.json b/src/locales/ru.json index b6453d3..310a0ae 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -254,6 +254,7 @@ "topUp": "Пополнить", "topUpBalance": "Пополнение баланса", "enterAmount": "Введите сумму", + "paymentMethod": "Способ оплаты", "paymentMethods": "Способы оплаты", "transactionHistory": "История операций", "noTransactions": "Нет операций", diff --git a/src/locales/zh.json b/src/locales/zh.json index c8c2a9f..f02e127 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -185,6 +185,7 @@ "topUp": "充值", "topUpBalance": "充值余额", "enterAmount": "输入金额", + "paymentMethod": "支付方式", "paymentMethods": { "yookassa": { "name": "YooKassa", From cb4f471417e7f1e3f2bcf082a76299d17363c1a4 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 17:30:44 +0300 Subject: [PATCH 51/54] Refactor TopUpModal for improved code clarity and structure - Consolidated content rendering into a single JSX variable for better readability. - Simplified modal rendering logic by removing nested components and using a single conditional return. - Enhanced mobile and desktop modal handling for clearer separation of concerns. --- src/components/TopUpModal.tsx | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index a52ddd8..4bf5d16 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -258,7 +258,8 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top // Calculate display amount for preview const displayAmount = amount && parseFloat(amount) > 0 ? parseFloat(amount) : 0 - const renderContent = () => ( + // Content JSX - shared between mobile and desktop + const contentJSX = (
{/* Header icon and method */}
@@ -403,8 +404,8 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
) - // Mobile bottom sheet - const MobileModal = () => ( + // Render modal based on screen size - NO nested components! + const modalContent = isMobileScreen ? ( <> {/* Backdrop */}
- {renderContent()} + {contentJSX}
- ) - - // Desktop modal - positioned higher - const DesktopModal = () => ( + ) : (
- {renderContent()} + {contentJSX}
) - const content = isMobileScreen ? : - if (typeof document !== 'undefined') { - return createPortal(content, document.body) + return createPortal(modalContent, document.body) } - return content + return modalContent } From de0a9d9efb149a2291b5f8a7833e81fb73bf6ec4 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 17:32:36 +0300 Subject: [PATCH 52/54] Improve TopUpModal input focus behavior for mobile and desktop - Enhanced auto-focus functionality to work on mobile devices, specifically in Telegram WebApp. - Added a small delay to ensure the DOM is ready before focusing the input. - Implemented scrolling into view for iOS Safari to trigger the keyboard when the input is focused. --- src/components/TopUpModal.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index 4bf5d16..8346a9b 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -247,13 +247,20 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top : convertAmount(rub).toFixed(currencyDecimals) const isPending = topUpMutation.isPending || starsPaymentMutation.isPending - // Auto-focus input (only on desktop) + // Auto-focus input - works on mobile in Telegram WebApp useEffect(() => { + // Small delay to ensure DOM is ready const timer = setTimeout(() => { - if (!isMobileScreen) inputRef.current?.focus() - }, 150) + if (inputRef.current) { + inputRef.current.focus() + // For iOS Safari - scroll input into view to trigger keyboard + if (isMobileScreen) { + inputRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } + } + }, 100) return () => clearTimeout(timer) - }, [isMobileScreen]) + }, []) // Calculate display amount for preview const displayAmount = amount && parseFloat(amount) > 0 ? parseFloat(amount) : 0 @@ -330,6 +337,7 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top className="w-full h-16 px-5 pr-16 text-2xl font-bold bg-transparent text-dark-100 placeholder:text-dark-600 focus:outline-none" style={{ fontSize: '24px' }} autoComplete="off" + autoFocus /> {currencySymbol} From 9e7d6b317e59a9d46f4421af74f907ffa6307f4b Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 18:27:05 +0300 Subject: [PATCH 53/54] Enhance ColorPicker component with RGB support and improved styling - Added RGB value updates on color input changes and preset selections. - Refactored RGB sliders to always be visible, enhancing user experience across devices. - Improved range input styles for better accessibility and visual feedback. - Updated number input fields for RGB values to hide spinners and ensure a cleaner look. --- src/components/ColorPicker.tsx | 138 +++++++++++++++++++-------------- src/styles/globals.css | 72 ++++++++++++++--- 2 files changed, 142 insertions(+), 68 deletions(-) diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index 6b90673..9dbefd1 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -87,6 +87,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C const handleColorInputChange = (e: React.ChangeEvent) => { const newColor = e.target.value setLocalValue(newColor) + setRgb(hexToRgb(newColor)) onChange(newColor) } @@ -102,8 +103,9 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C if (newValue === '' || newValue.match(/^#[0-9A-Fa-f]{0,6}$/)) { setLocalValue(newValue) - // Only trigger onChange for valid complete hex + // Only trigger onChange and update RGB for valid complete hex if (newValue.match(/^#[0-9A-Fa-f]{6}$/)) { + setRgb(hexToRgb(newValue)) onChange(newValue) } } @@ -111,6 +113,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C const handlePresetClick = (color: string) => { setLocalValue(color) + setRgb(hexToRgb(color)) onChange(color) setIsOpen(false) } @@ -178,71 +181,90 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C {/* Dropdown with presets and RGB sliders */} {isOpen && ( -
- {/* RGB Sliders - shown in Telegram instead of native picker */} - {isTelegram && ( -
-
RGB
-
- {/* Red */} -
- R - handleRgbChange('r', parseInt(e.target.value))} - className="flex-1 h-2 rounded-full appearance-none cursor-pointer" - style={{ - background: `linear-gradient(to right, rgb(0,${rgb.g},${rgb.b}), rgb(255,${rgb.g},${rgb.b}))`, - }} - /> - {rgb.r} -
- {/* Green */} -
- G - handleRgbChange('g', parseInt(e.target.value))} - className="flex-1 h-2 rounded-full appearance-none cursor-pointer" - style={{ - background: `linear-gradient(to right, rgb(${rgb.r},0,${rgb.b}), rgb(${rgb.r},255,${rgb.b}))`, - }} - /> - {rgb.g} -
- {/* Blue */} -
- B - handleRgbChange('b', parseInt(e.target.value))} - className="flex-1 h-2 rounded-full appearance-none cursor-pointer" - style={{ - background: `linear-gradient(to right, rgb(${rgb.r},${rgb.g},0), rgb(${rgb.r},${rgb.g},255))`, - }} - /> - {rgb.b} -
+
+ {/* RGB Sliders - always visible on all devices */} +
+
RGB
+
+ {/* Red */} +
+ R + handleRgbChange('r', parseInt(e.target.value))} + className="flex-1 h-3 sm:h-2 rounded-full appearance-none cursor-pointer touch-pan-x" + style={{ + background: `linear-gradient(to right, rgb(0,${rgb.g},${rgb.b}), rgb(255,${rgb.g},${rgb.b}))`, + }} + /> + handleRgbChange('r', Math.min(255, Math.max(0, parseInt(e.target.value) || 0)))} + className="w-14 sm:w-12 text-xs text-center bg-dark-700 border border-dark-600 rounded px-1 py-1 text-dark-200" + /> +
+ {/* Green */} +
+ G + handleRgbChange('g', parseInt(e.target.value))} + className="flex-1 h-3 sm:h-2 rounded-full appearance-none cursor-pointer touch-pan-x" + style={{ + background: `linear-gradient(to right, rgb(${rgb.r},0,${rgb.b}), rgb(${rgb.r},255,${rgb.b}))`, + }} + /> + handleRgbChange('g', Math.min(255, Math.max(0, parseInt(e.target.value) || 0)))} + className="w-14 sm:w-12 text-xs text-center bg-dark-700 border border-dark-600 rounded px-1 py-1 text-dark-200" + /> +
+ {/* Blue */} +
+ B + handleRgbChange('b', parseInt(e.target.value))} + className="flex-1 h-3 sm:h-2 rounded-full appearance-none cursor-pointer touch-pan-x" + style={{ + background: `linear-gradient(to right, rgb(${rgb.r},${rgb.g},0), rgb(${rgb.r},${rgb.g},255))`, + }} + /> + handleRgbChange('b', Math.min(255, Math.max(0, parseInt(e.target.value) || 0)))} + className="w-14 sm:w-12 text-xs text-center bg-dark-700 border border-dark-600 rounded px-1 py-1 text-dark-200" + />
- )} +
Preset colors
-
+
{PRESET_COLORS.map((preset) => (
+
+
+
+ ) : null return ( -
+
{description &&

{description}

}
- {/* Color preview button - min 44px for touch accessibility */} + {/* Color preview button */} )}
- {/* Dropdown with presets and RGB sliders */} - {isOpen && ( -
- {/* RGB Sliders - always visible on all devices */} -
-
RGB
-
- {/* Red */} -
- R - handleRgbChange('r', parseInt(e.target.value))} - className="flex-1 h-3 sm:h-2 rounded-full appearance-none cursor-pointer touch-pan-x" - style={{ - background: `linear-gradient(to right, rgb(0,${rgb.g},${rgb.b}), rgb(255,${rgb.g},${rgb.b}))`, - }} - /> - handleRgbChange('r', Math.min(255, Math.max(0, parseInt(e.target.value) || 0)))} - className="w-14 sm:w-12 text-xs text-center bg-dark-700 border border-dark-600 rounded px-1 py-1 text-dark-200" - /> -
- {/* Green */} -
- G - handleRgbChange('g', parseInt(e.target.value))} - className="flex-1 h-3 sm:h-2 rounded-full appearance-none cursor-pointer touch-pan-x" - style={{ - background: `linear-gradient(to right, rgb(${rgb.r},0,${rgb.b}), rgb(${rgb.r},255,${rgb.b}))`, - }} - /> - handleRgbChange('g', Math.min(255, Math.max(0, parseInt(e.target.value) || 0)))} - className="w-14 sm:w-12 text-xs text-center bg-dark-700 border border-dark-600 rounded px-1 py-1 text-dark-200" - /> -
- {/* Blue */} -
- B - handleRgbChange('b', parseInt(e.target.value))} - className="flex-1 h-3 sm:h-2 rounded-full appearance-none cursor-pointer touch-pan-x" - style={{ - background: `linear-gradient(to right, rgb(${rgb.r},${rgb.g},0), rgb(${rgb.r},${rgb.g},255))`, - }} - /> - handleRgbChange('b', Math.min(255, Math.max(0, parseInt(e.target.value) || 0)))} - className="w-14 sm:w-12 text-xs text-center bg-dark-700 border border-dark-600 rounded px-1 py-1 text-dark-200" - /> -
-
-
- -
Preset colors
-
- {PRESET_COLORS.map((preset) => ( -
-
- )} + {/* Render picker in portal */} + {typeof document !== 'undefined' && createPortal(pickerContent, document.body)}
) }