From 217c038c3825b5c955f4c65f999f77818f70e9b7 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 17:08:07 +0300 Subject: [PATCH] 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 }