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' import BentoCard from './ui/BentoCard' // Icons const CloseIcon = () => ( ) const WalletIcon = () => ( ) const StarIcon = () => ( ) const CardIcon = () => ( ) const CryptoIcon = () => ( ) const SparklesIcon = () => ( ) const ExternalLinkIcon = () => ( ) const CopyIcon = () => ( ) const CheckIcon = () => ( ) 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 '' const converted = convertAmount(initialAmountRubles) return (targetCurrency === 'IRR' || targetCurrency === 'RUB') ? Math.ceil(converted).toString() : converted.toFixed(2) } const [amount, setAmount] = useState(getInitialAmount) const [error, setError] = useState(null) const [selectedOption, setSelectedOption] = useState( method.options && method.options.length > 0 ? method.options[0].id : null ) const [paymentUrl, setPaymentUrl] = useState(null) const [copied, setCopied] = useState(false) 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 const maxRubles = method.max_amount_kopeks / 100 const methodKey = method.id.toLowerCase().replace(/-/g, '_') const isStarsMethod = methodKey.includes('stars') const methodName = t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) || method.name const starsPaymentMutation = useMutation({ mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks), onSuccess: (data) => { const webApp = window.Telegram?.WebApp if (!data.invoice_url) { setError('Сервер не вернул ссылку на оплату'); return } if (!webApp?.openInvoice) { setError('Оплата Stars доступна только в Telegram Mini App'); return } try { webApp.openInvoice(data.invoice_url, (status) => { if (status === 'paid') { setError(null); onClose() } else if (status === 'failed') { setError(t('wheel.starsPaymentFailed')) } }) } catch (e) { setError('Ошибка: ' + String(e)) } }, onError: (err: unknown) => { const axiosError = err as { response?: { data?: { detail?: string }, status?: number } } setError(`Ошибка: ${axiosError?.response?.data?.detail || 'Не удалось создать счёт'}`) }, }) const topUpMutation = useMutation<{ payment_id: string; payment_url?: string; invoice_url?: string amount_kopeks: number; amount_rubles: number; status: string; expires_at: string | null }, unknown, number>({ mutationFn: (amountKopeks: number) => balanceApi.createTopUp(amountKopeks, method.id, selectedOption || undefined), onSuccess: (data) => { const redirectUrl = data.payment_url || (data as any).invoice_url if (redirectUrl) { // In Telegram Mini App, try to open directly const webApp = window.Telegram?.WebApp if (webApp?.openLink) { try { webApp.openLink(redirectUrl, { try_instant_view: false, try_browser: true }) onClose() return } catch (e) { console.warn('[TopUpModal] webApp.openLink failed:', e) } } // Otherwise show the link for user to click setPaymentUrl(redirectUrl) } }, onError: (err: unknown) => { const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '' setError(detail.includes('not yet implemented') ? t('balance.useBot') : (detail || t('common.error'))) }, }) const handleSubmit = () => { setError(null) setPaymentUrl(null) inputRef.current?.blur() if (!checkRateLimit(RATE_LIMIT_KEYS.PAYMENT, 3, 30000)) { setError('Подождите ' + getRateLimitResetTime(RATE_LIMIT_KEYS.PAYMENT) + ' сек.') return } if (hasOptions && !selectedOption) { setError('Выберите способ'); return } const amountCurrency = parseFloat(amount) if (isNaN(amountCurrency) || amountCurrency <= 0) { setError('Введите сумму'); return } const amountRubles = convertToRub(amountCurrency) if (amountRubles < minRubles || amountRubles > maxRubles) { setError(`Сумма: ${minRubles} – ${maxRubles} ₽`); return } const amountKopeks = Math.round(amountRubles * 100) if (isStarsMethod) { starsPaymentMutation.mutate(amountKopeks) } else { topUpMutation.mutate(amountKopeks) } } const quickAmounts = [100, 300, 500, 1000].filter((a) => a >= minRubles && a <= maxRubles) const currencyDecimals = (targetCurrency === 'IRR' || targetCurrency === 'RUB') ? 0 : 2 const getQuickValue = (rub: number) => (targetCurrency === 'IRR') ? Math.round(convertAmount(rub)).toString() : convertAmount(rub).toFixed(currencyDecimals) const isPending = topUpMutation.isPending || starsPaymentMutation.isPending const handleCopyUrl = async () => { if (!paymentUrl) return try { await navigator.clipboard.writeText(paymentUrl) setCopied(true) setTimeout(() => setCopied(false), 2000) } catch (e) { console.warn('Failed to copy:', e) } } const handleOpenUrl = () => { if (!paymentUrl) return window.open(paymentUrl, '_blank', 'noopener,noreferrer') } // Auto-focus input - works on mobile in Telegram WebApp useEffect(() => { const timer = setTimeout(() => { if (inputRef.current) { inputRef.current.focus() if (isMobileScreen) { inputRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }) } } }, 100) return () => clearTimeout(timer) }, []) // 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 + Submit button - inline */}
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-14 px-4 pr-12 text-xl font-bold bg-transparent text-dark-100 placeholder:text-dark-600 focus:outline-none" autoComplete="off" autoFocus /> {currencySymbol}
{/* Quick amount buttons */} {quickAmounts.length > 0 && (
{quickAmounts.map((a) => { const val = getQuickValue(a) const isSelected = amount === val return ( { setAmount(val); inputRef.current?.blur() }} hover glow={isSelected} className={`flex flex-col items-center justify-center py-3 px-2 ${ isSelected ? 'border-accent-500/50 bg-accent-500/10' : '' }`} > {formatAmount(a, 0)} {currencySymbol} ) })}
)} {/* Error message */} {error && (
{error}
)} {/* Payment link display - shown when URL is received */} {paymentUrl && (
{t('balance.paymentReady', 'Ссылка на оплату готова')}

{t('balance.clickToOpenPayment', 'Нажмите кнопку ниже, чтобы открыть страницу оплаты в новой вкладке')}

{/* Main open button */} { e.preventDefault() handleOpenUrl() }} className="flex items-center justify-center gap-2 w-full h-12 rounded-xl bg-success-500 text-white font-bold hover:bg-success-400 active:bg-success-600 transition-colors" > {t('balance.openPaymentPage', 'Открыть страницу оплаты')} {/* Copy and link display */}

{paymentUrl}

)}
) // Render modal based on screen size - NO nested components! const modalContent = isMobileScreen ? ( <> {/* Backdrop */}
{/* Bottom sheet */}
e.stopPropagation()} > {/* Handle bar */}
{/* Header */}
{t('balance.topUp')}
{/* Divider */}
{/* Content */}
{contentJSX}
) : (
e.stopPropagation()} > {/* Header */}
{t('balance.topUp')}
{/* Content */}
{contentJSX}
) if (typeof document !== 'undefined') { return createPortal(modalContent, document.body) } return modalContent }