diff --git a/src/components/InsufficientBalancePrompt.tsx b/src/components/InsufficientBalancePrompt.tsx index 270cb92..f5d1887 100644 --- a/src/components/InsufficientBalancePrompt.tsx +++ b/src/components/InsufficientBalancePrompt.tsx @@ -146,35 +146,27 @@ function PaymentMethodModal({ paymentMethods, onSelect, onClose }: PaymentMethod return (
- {/* Backdrop click to close */}
- {/* Modal content - bottom sheet on mobile, centered on desktop */} -
- {/* Header */} -
-
-

{t('balance.selectPaymentMethod')}

-

{t('balance.topUpBalance')}

-
-
{/* Scrollable content */} -
+
{!paymentMethods ? ( -
-
+
+
) : paymentMethods.length === 0 ? ( -
+
{t('balance.noPaymentMethods')}
) : ( @@ -182,37 +174,30 @@ function PaymentMethodModal({ paymentMethods, onSelect, onClose }: PaymentMethod {paymentMethods.map((method) => { const methodKey = method.id.toLowerCase().replace(/-/g, '_') const translatedName = t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) - const translatedDesc = t(`balance.paymentMethods.${methodKey}.description`, { defaultValue: '' }) return ( @@ -222,13 +207,9 @@ function PaymentMethodModal({ paymentMethods, onSelect, onClose }: PaymentMethod )}
- {/* Footer */} -
-
diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index 25e9d12..7f72688 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -14,17 +14,14 @@ const openPaymentLink = (url: string, reservedWindow?: Window | null) => { if (typeof window === 'undefined' || !url) return const webApp = window.Telegram?.WebApp - // If inside Telegram Mini App, let Telegram handle t.me links if (isTelegramPaymentLink(url) && webApp?.openTelegramLink) { try { webApp.openTelegramLink(url); return } catch (e) { console.warn('[TopUpModal] openTelegramLink failed:', e) } } - // Inside Telegram Mini App → open in external browser if (webApp?.openLink) { try { webApp.openLink(url, { try_instant_view: false }); return } catch (e) { console.warn('[TopUpModal] webApp.openLink failed:', e) } } - // Regular browser: use reserved popup window if available if (reservedWindow && !reservedWindow.closed) { try { reservedWindow.location.href = url; reservedWindow.focus?.() } catch (e) { console.warn('[TopUpModal] Failed to use reserved window:', e) } return @@ -38,17 +35,15 @@ const openPaymentLink = (url: string, reservedWindow?: Window | null) => { interface TopUpModalProps { method: PaymentMethod onClose: () => void - /** Pre-filled amount in rubles (will be converted to user's currency) */ initialAmountRubles?: number } export default function TopUpModal({ method, onClose, initialAmountRubles }: TopUpModalProps) { const { t } = useTranslation() const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } = useCurrency() - const formRef = useRef(null) + const containerRef = useRef(null) const inputRef = useRef(null) - // Calculate initial amount in user's currency const getInitialAmount = (): string => { if (!initialAmountRubles || initialAmountRubles <= 0) return '' const converted = convertAmount(initialAmountRubles) @@ -63,71 +58,59 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top const [selectedOption, setSelectedOption] = useState( method.options && method.options.length > 0 ? method.options[0].id : null ) + const [isKeyboardOpen, setIsKeyboardOpen] = useState(false) const popupRef = useRef(null) 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 isTelegramMiniApp = typeof window !== 'undefined' && Boolean(window.Telegram?.WebApp?.initData) - // Handle visual viewport changes (keyboard appearance on mobile) + // Detect keyboard open/close useEffect(() => { - const handleResize = () => { - // Scroll input into view when keyboard appears - if (document.activeElement === inputRef.current) { - setTimeout(() => { - inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }) - }, 100) - } - } + const handleFocus = () => setIsKeyboardOpen(true) + const handleBlur = () => setTimeout(() => setIsKeyboardOpen(false), 100) - if (typeof window !== 'undefined' && window.visualViewport) { - window.visualViewport.addEventListener('resize', handleResize) - return () => window.visualViewport?.removeEventListener('resize', handleResize) + const input = inputRef.current + if (input) { + input.addEventListener('focus', handleFocus) + input.addEventListener('blur', handleBlur) + return () => { + input.removeEventListener('focus', handleFocus) + input.removeEventListener('blur', handleBlur) + } } }, []) - // Stars payment using the same approach as Wheel.tsx + // Scroll to input when keyboard opens + useEffect(() => { + if (isKeyboardOpen && containerRef.current && inputRef.current) { + setTimeout(() => { + inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }) + }, 150) + } + }, [isKeyboardOpen]) + 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 - } - + 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')) - } else if (status === 'cancelled') { - setError(null) - } + if (status === 'paid') { setError(null); onClose() } + else if (status === 'failed') { setError(t('wheel.starsPaymentFailed')) } + else if (status === 'cancelled') { setError(null) } }) - } catch (e) { - setError('Ошибка открытия окна оплаты: ' + String(e)) - } + } catch (e) { setError('Ошибка открытия окна оплаты: ' + String(e)) } }, onError: (error: unknown) => { const axiosError = error as { response?: { data?: { detail?: string }, status?: number } } - const detail = axiosError?.response?.data?.detail - const status = axiosError?.response?.status - setError(`Ошибка API (${status || 'network'}): ${detail || 'Не удалось создать счёт'}`) + setError(`Ошибка API (${axiosError?.response?.status || 'network'}): ${axiosError?.response?.data?.detail || 'Не удалось создать счёт'}`) }, }) @@ -143,14 +126,12 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top mutationFn: (amountKopeks: number) => balanceApi.createTopUp(amountKopeks, method.id, selectedOption || undefined), onSuccess: (data) => { const redirectUrl = data.payment_url || (data as any).invoice_url - if (redirectUrl) { - openPaymentLink(redirectUrl, popupRef.current) - } + if (redirectUrl) openPaymentLink(redirectUrl, popupRef.current) popupRef.current = null onClose() }, onError: (error: unknown) => { - try { if (popupRef.current && !popupRef.current.closed) popupRef.current.close() } catch (e) { console.warn('[TopUpModal] Failed to close popup:', e) } + try { if (popupRef.current && !popupRef.current.closed) popupRef.current.close() } catch {} popupRef.current = null const detail = (error as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '' if (detail.includes('not yet implemented')) setError(t('balance.useBot')) @@ -158,34 +139,27 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top }, }) - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault() + const handleSubmit = () => { setError(null) - - // Hide keyboard on mobile inputRef.current?.blur() - // Rate limit check: max 3 payment attempts per 30 seconds if (!checkRateLimit(RATE_LIMIT_KEYS.PAYMENT, 3, 30000)) { - const resetTime = getRateLimitResetTime(RATE_LIMIT_KEYS.PAYMENT) - setError(t('balance.tooManyRequests', { seconds: resetTime }) || `Слишком много запросов. Подождите ${resetTime} сек.`) + setError(t('balance.tooManyRequests', { seconds: getRateLimitResetTime(RATE_LIMIT_KEYS.PAYMENT) }) || 'Подождите...') return } - if (hasOptions && !selectedOption) { setError(t('balance.selectPaymentOption', 'Выберите способ оплаты')); return } + if (hasOptions && !selectedOption) { setError(t('balance.selectPaymentOption', 'Выберите способ')); return } const amountCurrency = parseFloat(amount) - if (isNaN(amountCurrency) || amountCurrency <= 0) { setError(t('balance.invalidAmount', 'Invalid amount')); return } + if (isNaN(amountCurrency) || amountCurrency <= 0) { setError(t('balance.invalidAmount', 'Введите сумму')); return } const amountRubles = convertToRub(amountCurrency) if (amountRubles < minRubles) { setError(t('balance.minAmountError', { amount: minRubles })); return } if (amountRubles > maxRubles) { setError(t('balance.maxAmountError', { amount: maxRubles })); return } + const amountKopeks = Math.round(amountRubles * 100) - // Pre-open popup window to avoid browser blocking (must happen in user click context) if (!isTelegramMiniApp) { try { popupRef.current = window.open('', '_blank') } catch { popupRef.current = null } } - if (isStarsMethod) { - starsPaymentMutation.mutate(amountKopeks); return - } + if (isStarsMethod) { starsPaymentMutation.mutate(amountKopeks); return } topUpMutation.mutate(amountKopeks) } @@ -196,164 +170,138 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top return convertAmount(rubAmount).toFixed(currencyDecimals) } const inputStep = currencyDecimals === 0 ? 1 : 0.01 - const minInputValue = targetCurrency === 'RUB' ? minRubles : targetCurrency === 'IRR' ? Math.round(convertAmount(minRubles)) : Number(convertAmount(minRubles).toFixed(currencyDecimals)) - const maxInputValue = targetCurrency === 'RUB' ? maxRubles : targetCurrency === 'IRR' ? Math.round(convertAmount(maxRubles)) : Number(convertAmount(maxRubles).toFixed(currencyDecimals)) - const isPending = topUpMutation.isPending || starsPaymentMutation.isPending return (
- {/* Backdrop click to close */}
- {/* Modal content - bottom sheet on mobile, centered on desktop */} -
- {/* Header - sticky */} -
-
-

{t('balance.topUp')}

-

{methodName}

+
+ {/* Compact Header */} +
+
+
+ + + +
+
+

{methodName}

+
-
- {/* Scrollable content */} + {/* Scrollable Content */}
-
- {/* Payment options */} +
+ {/* Payment options - compact grid */} {hasOptions && method.options && ( -
- -
- {method.options.map((option) => ( - - ))} -
+
+ {method.options.map((option) => ( + + ))}
)} - {/* Amount input */} -
- -
- setAmount(e.target.value)} - placeholder={`${formatAmount(minRubles, currencyDecimals)} - ${formatAmount(maxRubles, currencyDecimals)}`} - min={minInputValue} - max={maxInputValue} - step={inputStep} - className="input text-lg pr-14 h-14" - autoComplete="off" - /> - - {currencySymbol} - -
-

- {t('balance.minAmount')}: {formatAmount(minRubles, currencyDecimals)} — {t('balance.maxAmount')}: {formatAmount(maxRubles, currencyDecimals)} {currencySymbol} -

+ {/* Amount input with currency */} +
+ setAmount(e.target.value)} + placeholder={`${formatAmount(minRubles, 0)} – ${formatAmount(maxRubles, 0)}`} + step={inputStep} + className="w-full h-14 px-4 pr-16 text-xl 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 focus:ring-1 focus:ring-accent-500" + autoComplete="off" + /> + + {currencySymbol} +
- {/* Quick amounts */} + {/* Quick amounts - compact */} {quickAmounts.length > 0 && ( -
- -
- {quickAmounts.map((a) => { - const quickValue = getQuickAmountValue(a) - const isSelected = amount === quickValue - return ( - - ) - })} -
+
+ {quickAmounts.map((a) => { + const quickValue = getQuickAmountValue(a) + const isSelected = amount === quickValue + return ( + + ) + })}
)} - {/* Error message */} + {/* Error */} {error && ( -
+
{error}
)} - +
- {/* Footer with buttons - sticky at bottom */} -
-
- - -
+ {/* Fixed Footer with Submit */} +
+ +