+
+ 2
+ {t('subscription.connection.addSubscription')}
+
+
{getLocalizedText(selectedApp.addSubscriptionStep.description)}
+
+
+ {/* Connect button */}
+ {selectedApp.deepLink && (
+
+ )}
+
+ {/* Copy link */}
+
)}
{/* Step 3: Connect */}
- {selectedApp.connectAndUseStep && (
-
-
-
3
-
-
{t('subscription.connection.connectVpn')}
-
- {getLocalizedText(selectedApp.connectAndUseStep.description)}
-
-
+ {selectedApp?.connectAndUseStep && (
+
+
+ 3
+ {t('subscription.connection.connectVpn')}
+
{getLocalizedText(selectedApp.connectAndUseStep.description)}
)}
-
- {/* Footer */}
-
-
-
-
+
)
}
diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx
index 056c19c..8346a9b 100644
--- a/src/components/TopUpModal.tsx
+++ b/src/components/TopUpModal.tsx
@@ -1,8 +1,10 @@
-import { useState, useRef } 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'
@@ -17,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) {
@@ -29,16 +30,78 @@ const openPaymentLink = (url: string, reservedWindow?: Window | null) => {
window.location.href = url
}
+// Icons
+const CloseIcon = () => (
+
+)
+
+const WalletIcon = () => (
+
+)
+
+const StarIcon = () => (
+
+)
+
+const CardIcon = () => (
+
+)
+
+const CryptoIcon = () => (
+
+)
+
+const SparklesIcon = () => (
+
+)
+
interface TopUpModalProps {
method: PaymentMethod
onClose: () => void
initialAmountRubles?: number
}
+function useIsMobile() {
+ const [isMobile, setIsMobile] = useState(() => {
+ if (typeof window === 'undefined') return false
+ return window.innerWidth < 640
+ })
+ useEffect(() => {
+ const check = () => setIsMobile(window.innerWidth < 640)
+ window.addEventListener('resize', check)
+ return () => window.removeEventListener('resize', check)
+ }, [])
+ 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()
+ 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 ''
@@ -54,6 +117,58 @@ 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()
+ }, [onClose])
+
+ // Keyboard: 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 (Android)
+ useEffect(() => {
+ if (!webApp?.BackButton) return
+ webApp.BackButton.show()
+ webApp.BackButton.onClick(handleClose)
+ return () => {
+ webApp.BackButton.offClick(handleClose)
+ webApp.BackButton.hide()
+ }
+ }, [webApp, handleClose])
+
+ // Scroll lock
+ useEffect(() => {
+ const scrollY = window.scrollY
+ 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)
+ }
+ }, [])
const hasOptions = method.options && method.options.length > 0
const minRubles = method.min_amount_kopeks / 100
@@ -132,105 +247,250 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
: convertAmount(rub).toFixed(currencyDecimals)
const isPending = topUpMutation.isPending || starsPaymentMutation.isPending
- return (
-
-
+ // Auto-focus input - works on mobile in Telegram WebApp
+ useEffect(() => {
+ // Small delay to ensure DOM is ready
+ const timer = setTimeout(() => {
+ 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)
+ }, [])
+
+ // Calculate display amount for preview
+ const displayAmount = amount && parseFloat(amount) > 0 ? parseFloat(amount) : 0
+
+ // Content JSX - shared between mobile and desktop
+ const contentJSX = (
+
+ {/* 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) => (
+
+ ))}
+
+
+ )}
+
+ {/* 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"
+ autoFocus
+ />
+
+ {currencySymbol}
+
+
+
+
+ {/* Quick amount buttons */}
+ {quickAmounts.length > 0 && (
+
+ {quickAmounts.map((a) => {
+ const val = getQuickValue(a)
+ const isSelected = amount === val
+ return (
+
+ )
+ })}
+
+ )}
+
+ {/* Error message */}
+ {error && (
+
+ )}
+
+ {/* Submit button */}
+
+
+ )
+
+ // Render modal based on screen size - NO nested components!
+ const modalContent = isMobileScreen ? (
+ <>
+ {/* Backdrop */}
+
+ {/* Bottom sheet */}
+
e.stopPropagation()}
+ >
+ {/* Handle bar */}
+
-
{/* Header */}
-
-
{methodName}
-