-
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 && (
+
+ )}
-
{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 && (
+
+ )}
+
+ {/* 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",