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(t('balance.errors.noPaymentLink')); return; } if (!webApp?.openInvoice) { setError(t('balance.errors.starsOnlyInTelegram')); 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(t('balance.errors.generic', { details: String(e) })); } }, onError: (err: unknown) => { const axiosError = err as { response?: { data?: { detail?: string }; status?: number } }; setError(axiosError?.response?.data?.detail || t('balance.errors.invoiceFailed')); }, }); 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.invoice_url; if (redirectUrl) { // Always show the payment link for user to click manually // This ensures it works on all platforms including iOS Safari 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( t('balance.errors.rateLimit', { seconds: getRateLimitResetTime(RATE_LIMIT_KEYS.PAYMENT) }), ); return; } if (hasOptions && !selectedOption) { setError(t('balance.errors.selectMethod')); return; } const amountCurrency = parseFloat(amount); if (isNaN(amountCurrency) || amountCurrency <= 0) { setError(t('balance.errors.enterAmount')); return; } const amountRubles = convertToRub(amountCurrency); if (amountRubles < minRubles || amountRubles > maxRubles) { setError(t('balance.errors.amountRange', { min: minRubles, max: 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); } }; // 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); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // 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="h-14 w-full bg-transparent px-4 pr-12 text-xl font-bold 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 px-2 py-3 ${ 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 - NO preventDefault, let work natively for iOS Safari */} {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; }