import { useState, useRef, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useParams, useSearchParams } from 'react-router'; import { useMutation, useQuery } from '@tanstack/react-query'; import { motion } from 'framer-motion'; import { balanceApi } from '../api/balance'; import { useCurrency } from '../hooks/useCurrency'; import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit'; import { useCloseOnSuccessNotification } from '../store/successNotification'; import { useHaptic, usePlatform } from '@/platform'; import { staggerContainer, staggerItem } from '@/components/motion/transitions'; import type { PaymentMethod, PaymentMethodOption } from '../types'; import BentoCard from '../components/ui/BentoCard'; import { saveTopUpPendingInfo } from '../utils/topUpStorage'; import { getSafeRedirectPath } from '../utils/safeRedirect'; import { openPaymentUrl } from '../utils/openPaymentUrl'; import { copyToClipboard } from '@/utils/clipboard'; import { CardIcon, CheckIcon, CopyIcon, CryptoIcon, ExclamationIcon, ExternalLinkIcon, SparklesIcon, StarIcon, } from '@/components/icons'; 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 ; }; const getPreferredOptionId = (options?: PaymentMethod['options']) => { if (!options || options.length === 0) return null; const sbpOption = options.find((option) => { const normalizedId = option.id.toLowerCase(); const normalizedName = option.name.toLowerCase(); return ( normalizedId.includes('sbp') || normalizedName.includes('сбп') || normalizedName.includes('sbp') ); }); return sbpOption?.id ?? options[0].id; }; const sortOptionsWithSbpFirst = (options?: PaymentMethod['options']) => { if (!options || options.length <= 1) return options ?? []; const isPreferredOption = (option: PaymentMethodOption) => { const normalizedId = option.id.toLowerCase(); const normalizedName = option.name.toLowerCase(); return ( normalizedId.includes('sbp') || normalizedName.includes('сбп') || normalizedName.includes('sbp') ); }; return [...options].sort((left, right) => { const leftIsPreferred = isPreferredOption(left); const rightIsPreferred = isPreferredOption(right); if (leftIsPreferred === rightIsPreferred) return 0; return leftIsPreferred ? -1 : 1; }); }; export default function TopUpAmount() { const { t } = useTranslation(); const navigate = useNavigate(); const { methodId } = useParams<{ methodId: string }>(); const [searchParams] = useSearchParams(); const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } = useCurrency(); const { openInvoice, openTelegramLink, openLink, platform } = usePlatform(); const haptic = useHaptic(); const inputRef = useRef(null); const returnTo = searchParams.get('returnTo'); const initialAmountRubles = searchParams.get('amount') ? parseFloat(searchParams.get('amount')!) : undefined; // Fetch payment methods with a real query (dedupes with the method-selection page and // Balance via the shared ['payment-methods'] key). A non-reactive getQueryData read used // to dead-end on an infinite spinner whenever the cache was cold — reload, browser-back // from the provider page, or a deep link straight to this route. const { data: methods, isLoading: isMethodsLoading } = useQuery({ queryKey: ['payment-methods'], queryFn: balanceApi.getPaymentMethods, }); const method = methods?.find((m) => m.id === methodId); const handleNavigateBack = useCallback(() => { navigate(-1); }, [navigate]); const handleSuccess = useCallback(() => { // returnTo arrives via query string — validate as an in-app path before // navigate(), otherwise an absolute or encoded URL produces ugly // path artefacts in the URL bar. The validator returns '/' for invalid // input; treat that case as "no returnTo" and use the /balance default. const safe = getSafeRedirectPath(returnTo); navigate(returnTo && safe !== '/' ? safe : '/balance', { replace: true }); }, [navigate, returnTo]); // Keyboard: Escape to go back useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); handleNavigateBack(); } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [handleNavigateBack]); // Auto-redirect when success notification appears (e.g., balance topped up via WebSocket) useCloseOnSuccessNotification(handleSuccess); 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 initialDisplayAmount = getInitialAmount(); const [amount, setAmount] = useState(initialDisplayAmount); const [error, setError] = useState(null); const [selectedOption, setSelectedOption] = useState( getPreferredOptionId(method?.options), ); const [paymentUrl, setPaymentUrl] = useState(null); const [copied, setCopied] = useState(false); const [isInputFocused, setIsInputFocused] = useState(false); // Canonical RUB amount when the user picked a quick-amount chip. The input shows a // rounded display-currency value; validating/charging the canonical RUB avoids the FX // round-trip that could push a min-amount chip just below the allowed minimum. Cleared // as soon as the user edits the field by hand. const [quickRub, setQuickRub] = useState(null); // Once methods have loaded, redirect to method selection if this method id is unknown. useEffect(() => { if (methods && !method) { const params = new URLSearchParams(); const amount = searchParams.get('amount'); const rt = searchParams.get('returnTo'); if (amount) params.set('amount', amount); if (rt) params.set('returnTo', rt); const qs = params.toString(); navigate(`/balance/top-up${qs ? `?${qs}` : ''}`, { replace: true }); } }, [methods, method, navigate, searchParams]); useEffect(() => { if (!method?.options || method.options.length === 0) { if (selectedOption !== null) { setSelectedOption(null); } return; } const optionExists = method.options.some((option) => option.id === selectedOption); if (!optionExists) { setSelectedOption(getPreferredOptionId(method.options)); } }, [method?.id, method?.options, selectedOption]); const starsPaymentMutation = useMutation({ mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks), onSuccess: async (data) => { if (!data.invoice_url) { setError(t('balance.errors.noPaymentLink')); return; } try { const status = await openInvoice(data.invoice_url); if (status === 'paid') { haptic.notification('success'); setError(null); handleSuccess(); } else if (status === 'failed') { haptic.notification('error'); setError(t('wheel.starsPaymentFailed')); } } catch (e) { setError(t('balance.errors.generic', { details: String(e) })); } }, onError: (err: unknown) => { haptic.notification('error'); 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) => { if (!method) throw new Error('Method not loaded'); return balanceApi.createTopUp(amountKopeks, method.id, selectedOption || undefined); }, onSuccess: (data) => { const redirectUrl = data.payment_url || data.invoice_url; if (redirectUrl) { // Save payment info for the result page (do BEFORE possible redirect, // иначе после window.location.href этот код не выполнится). if (method && data.payment_id) { const methodKey = method.id.toLowerCase().replace(/-/g, '_'); const displayName = t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) || method.name; saveTopUpPendingInfo({ amount_kopeks: data.amount_kopeks, method_id: method.id, method_name: displayName, payment_id: data.payment_id, created_at: Date.now(), }); } // open_url_direct: seamless флоу как при покупке подарка. // window.location.href внутри Telegram MiniApp WebView навигирует // в том же контейнере без открытия внешнего браузера. После // оплаты return_url возвращает на /balance/top-up/result. // // t.me/ URL (Telegram Stars, CryptoBot) — всегда через нативный // handler (openInvoice / openTelegramLink в setPaymentUrl-ветке). // Stars уже отбит раньше через starsPaymentMutation, здесь — защита // на случай CryptoBot и других Telegram-deep-link провайдеров. // toLowerCase для устойчивости к редким провайдерам, которые могут вернуть // URL в нестандартном регистре. Также покрываем tg:// scheme на всякий случай. const lowerUrl = redirectUrl.toLowerCase(); const isTelegramDeepLink = lowerUrl.startsWith('https://t.me/') || lowerUrl.startsWith('http://t.me/') || lowerUrl.startsWith('tg://'); if (method?.open_url_direct && !isTelegramDeepLink) { // In the Telegram WebView, same-container navigation to the provider page breaks // when it hands off to a bank app via a custom scheme (SBP) — Android shows // ERR_UNKNOWN_URL_SCHEME, iOS opens nothing (bug #654272). Open externally there; // on web keep same-tab navigation. openPaymentUrl(redirectUrl, platform, openLink); return; } 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'), ); }, }); // Auto-focus input (only on desktop — mobile keyboard hides bottom nav) useEffect(() => { if (platform === 'telegram') return; const timer = setTimeout(() => { if (inputRef.current) { inputRef.current.focus(); } }, 100); return () => clearTimeout(timer); }, [platform]); // Spinner only while methods are actually loading. Once the query has resolved without // this method, the redirect effect above navigates away (so we render nothing here rather // than spinning forever on a cold cache). if (!method) { if (!isMethodsLoading) { return null; } return ( ); } const hasOptions = method.options && method.options.length > 0; const orderedOptions = sortOptionsWithSbpFirst(method.options); 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 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); // Resolve the canonical RUB amount. Prefer an exact source — an unedited prefill or a // quick-amount chip — over the display value, whose FX round-trip rounding (e.g. 150₽ at // rate 90.66 → "1.65" USD → back to 149.59₽) could push a min selection just below the // allowed minimum and block the top-up. quickRub is cleared on any manual edit, so a // non-null value means the field still holds that chip's exact amount. const userEditedAmount = amount.trim() !== initialDisplayAmount.trim(); const usingPrefill = !userEditedAmount && !!initialAmountRubles && initialAmountRubles > 0; const usingQuick = quickRub !== null; let canonicalRubles = amountRubles; if (usingPrefill) { canonicalRubles = initialAmountRubles as number; } else if (usingQuick && quickRub !== null) { canonicalRubles = quickRub; } else if (targetCurrency !== 'RUB') { // Hand-typed non-RUB amount: snap up to the minimum when it lands within one // display-currency rounding step below it, so typing the advertised (rounded) // minimum isn't rejected by FX rounding. const decimals = targetCurrency === 'IRR' ? 0 : 2; const roundingStep = convertToRub(Math.pow(10, -decimals)); if (canonicalRubles < minRubles && canonicalRubles >= minRubles - roundingStep) { canonicalRubles = minRubles; } } if (canonicalRubles < minRubles || canonicalRubles > maxRubles) { setError(t('balance.errors.amountRange', { min: minRubles, max: maxRubles })); return; } // Round for exact sources; ceil a hand-typed amount so float noise never lands sub-kopeck // under the chosen value. const amountKopeks = targetCurrency === 'RUB' || usingPrefill || usingQuick ? Math.round(canonicalRubles * 100) : Math.ceil(canonicalRubles * 100); if (isStarsMethod) { starsPaymentMutation.mutate(amountKopeks); } else { topUpMutation.mutate(amountKopeks); } }; const quickAmounts = ( method.quick_amounts != null ? method.quick_amounts.map((kopeks) => kopeks / 100) : [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 handleOpenPayment = () => { if (!paymentUrl) return; if (paymentUrl.includes('t.me/')) { openTelegramLink(paymentUrl); } else { openLink(paymentUrl); } }; const handleCopyUrl = async () => { if (!paymentUrl) return; try { await copyToClipboard(paymentUrl); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { // Clipboard write failed silently } }; return ( {/* Header icon and method */} {getMethodIcon(method.id)} {methodName} {formatAmount(minRubles, 0)} – {formatAmount(maxRubles, 0)} {currencySymbol} {/* Payment options (if any) */} {hasOptions && orderedOptions.length > 0 && ( {t('balance.paymentMethod')} {orderedOptions.map((opt) => ( setSelectedOption(opt.id)} className={`relative rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-200 ${ selectedOption === opt.id ? 'bg-accent-500/15 text-accent-400 ring-2 ring-accent-500/40' : 'border border-dark-700/50 bg-dark-800/70 text-dark-300 hover:bg-dark-700/70' }`} > {opt.name} {selectedOption === opt.id && ( )} ))} )} {/* Amount input + Submit button - inline */} {t('balance.enterAmount')} { setAmount(e.target.value); setQuickRub(null); }} 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" /> {currencySymbol} {isPending ? ( ) : ( <> {t('balance.topUp')} > )} {/* Quick amount buttons */} {quickAmounts.length > 0 && ( {quickAmounts.map((a) => { const val = getQuickValue(a); const isSelected = amount === val; return ( { setAmount(val); setQuickRub(a); 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')} {t('balance.openPaymentPage')} {paymentUrl} {copied ? : } )} ); }
{formatAmount(minRubles, 0)} – {formatAmount(maxRubles, 0)} {currencySymbol}
{t('balance.clickToOpenPayment')}
{paymentUrl}