From 236c36f1abd92f036f2febdc54c6992a88a12338 Mon Sep 17 00:00:00 2001 From: PEDZEO Date: Tue, 20 Jan 2026 16:52:01 +0300 Subject: [PATCH] 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",