From e22bab80908aa3dd77ed881fe0a9e1852e0e9c56 Mon Sep 17 00:00:00 2001 From: Egor Date: Sun, 18 Jan 2026 23:55:25 +0300 Subject: [PATCH 01/14] Update ci.yml --- .github/workflows/ci.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29de04a..33e57b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,17 +7,19 @@ on: jobs: lint-and-build: runs-on: ubuntu-latest - steps: - @@ -24,39 +24,10 @@ jobs: + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + - name: Install dependencies run: npm ci - - name: Run ESLint run: npm run lint - - name: Run TypeScript check run: npx tsc --noEmit - - name: Build run: npm run build env: @@ -25,7 +27,6 @@ jobs: VITE_TELEGRAM_BOT_USERNAME: test_bot VITE_APP_NAME: Cabinet VITE_APP_LOGO: V - - name: Upload build artifacts uses: actions/upload-artifact@v4 with: From fb9e0d4f77257bad3abcfccb650d04916c742013 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 19 Jan 2026 00:02:01 +0300 Subject: [PATCH 02/14] Update TopUpModal.tsx --- src/components/TopUpModal.tsx | 31 ++++++++----------------------- 1 file changed, 8 insertions(+), 23 deletions(-) diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index 1b8ce8f..c6d0107 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -7,25 +7,9 @@ import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils import type { PaymentMethod } from '../types' const TELEGRAM_LINK_REGEX = /^https?:\/\/t\.me\//i -const CRYPTOBOT_INVOICE_REGEX = /^(?:https?:\/\/)?(?:app\.cr\.bot|cr\.bot)\/invoices\/([A-Za-z0-9_-]+)/i const isTelegramPaymentLink = (url: string): boolean => TELEGRAM_LINK_REGEX.test(url) -const buildCryptoBotDeepLink = (url: string): string | null => { - try { - const m = url.match(CRYPTOBOT_INVOICE_REGEX) - if (m && m[1]) return `tg://resolve?domain=CryptoBot&start=${m[1]}` - const parsed = new URL(url) - if (/^(?:www\.)?t\.me$/i.test(parsed.hostname) && /\/CryptoBot/i.test(parsed.pathname)) { - return `tg://resolve?domain=CryptoBot${parsed.search || ''}` - } - return null - } catch (e) { - console.warn('[TopUpModal] Failed to build CryptoBot deep link:', e) - return null - } -} - const openPaymentLink = (url: string, reservedWindow?: Window | null) => { if (typeof window === 'undefined' || !url) return const webApp = window.Telegram?.WebApp @@ -35,19 +19,20 @@ const openPaymentLink = (url: string, reservedWindow?: Window | null) => { try { webApp.openTelegramLink(url); return } catch (e) { console.warn('[TopUpModal] openTelegramLink failed:', e) } } - // Prefer Telegram deep link specifically for CryptoBot invoices, but only when - // the backend didn't already return a direct t.me link (those work fine). - const cb = buildCryptoBotDeepLink(url) - const target = cb && !isTelegramPaymentLink(url) ? cb : url + // 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 = target; reservedWindow.focus?.() } catch (e) { console.warn('[TopUpModal] Failed to use reserved window:', e) } + try { reservedWindow.location.href = url; reservedWindow.focus?.() } catch (e) { console.warn('[TopUpModal] Failed to use reserved window:', e) } return } - const w2 = window.open(target, '_blank', 'noopener,noreferrer') + const w2 = window.open(url, '_blank', 'noopener,noreferrer') if (w2) { w2.opener = null; return } - window.location.href = target + window.location.href = url } interface TopUpModalProps { method: PaymentMethod; onClose: () => void } From 9d1b1ce6c3aaf2c1080d15e78de3b8a42bf615fe Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 19 Jan 2026 00:35:44 +0300 Subject: [PATCH 03/14] Add files via upload --- src/components/InsufficientBalancePrompt.tsx | 200 +++++++++++++++++++ src/components/TopUpModal.tsx | 22 +- 2 files changed, 219 insertions(+), 3 deletions(-) create mode 100644 src/components/InsufficientBalancePrompt.tsx diff --git a/src/components/InsufficientBalancePrompt.tsx b/src/components/InsufficientBalancePrompt.tsx new file mode 100644 index 0000000..243f508 --- /dev/null +++ b/src/components/InsufficientBalancePrompt.tsx @@ -0,0 +1,200 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useQuery } from '@tanstack/react-query' +import { balanceApi } from '../api/balance' +import { useCurrency } from '../hooks/useCurrency' +import TopUpModal from './TopUpModal' +import type { PaymentMethod } from '../types' + +interface InsufficientBalancePromptProps { + /** Amount missing in kopeks */ + missingAmountKopeks: number + /** Optional custom message */ + message?: string + /** Compact mode for inline use */ + compact?: boolean + /** Additional className */ + className?: string +} + +export default function InsufficientBalancePrompt({ + missingAmountKopeks, + message, + compact = false, + className = '', +}: InsufficientBalancePromptProps) { + const { t } = useTranslation() + const { formatAmount, currencySymbol } = useCurrency() + const [showMethodSelect, setShowMethodSelect] = useState(false) + const [selectedMethod, setSelectedMethod] = useState(null) + + const { data: paymentMethods } = useQuery({ + queryKey: ['payment-methods'], + queryFn: balanceApi.getPaymentMethods, + enabled: showMethodSelect, + }) + + const missingRubles = missingAmountKopeks / 100 + const displayAmount = formatAmount(missingRubles) + + const handleMethodSelect = (method: PaymentMethod) => { + setSelectedMethod(method) + setShowMethodSelect(false) + } + + if (compact) { + return ( + <> +
+
+ + + + + {message || t('balance.insufficientFunds')}: {displayAmount} {currencySymbol} + +
+ +
+ + {showMethodSelect && ( + setShowMethodSelect(false)} + /> + )} + + {selectedMethod && ( + setSelectedMethod(null)} + /> + )} + + ) + } + + return ( + <> +
+
+
+ + + +
+
+
+ {t('balance.insufficientFunds')} +
+
+ {message || t('balance.topUpToComplete')} +
+
+
+ {t('balance.missing')}: {displayAmount} {currencySymbol} +
+
+
+
+ +
+ + {showMethodSelect && ( + setShowMethodSelect(false)} + /> + )} + + {selectedMethod && ( + setSelectedMethod(null)} + /> + )} + + ) +} + +interface PaymentMethodModalProps { + paymentMethods: PaymentMethod[] | undefined + onSelect: (method: PaymentMethod) => void + onClose: () => void +} + +function PaymentMethodModal({ paymentMethods, onSelect, onClose }: PaymentMethodModalProps) { + const { t } = useTranslation() + const { formatAmount, currencySymbol } = useCurrency() + + return ( +
+
+
+

{t('balance.selectPaymentMethod')}

+ +
+ + {!paymentMethods ? ( +
+
+
+ ) : paymentMethods.length === 0 ? ( +
+ {t('balance.noPaymentMethods')} +
+ ) : ( +
+ {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 ( + + ) + })} +
+ )} +
+
+ ) +} diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index c6d0107..772c949 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -35,12 +35,28 @@ const openPaymentLink = (url: string, reservedWindow?: Window | null) => { window.location.href = url } -interface TopUpModalProps { method: PaymentMethod; onClose: () => void } +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 }: TopUpModalProps) { +export default function TopUpModal({ method, onClose, initialAmountRubles }: TopUpModalProps) { const { t } = useTranslation() const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } = useCurrency() - const [amount, setAmount] = useState('') + + // Calculate initial amount in user's currency + const getInitialAmount = (): string => { + if (!initialAmountRubles || initialAmountRubles <= 0) return '' + const converted = convertAmount(initialAmountRubles) + if (targetCurrency === 'IRR' || targetCurrency === 'RUB') { + return Math.ceil(converted).toString() + } + return 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 From a7f0d705ff54cf35d2024f57d5057ebff8382ede Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 19 Jan 2026 00:36:19 +0300 Subject: [PATCH 04/14] Add files via upload --- src/pages/Subscription.tsx | 82 ++++++++++++++++++++++++-------------- src/pages/Wheel.tsx | 22 +++++----- 2 files changed, 65 insertions(+), 39 deletions(-) diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 5762afb..a827d44 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -7,6 +7,7 @@ import { subscriptionApi } from '../api/subscription' import { promoApi } from '../api/promo' import type { PurchaseSelection, PeriodOption, Tariff, TariffPeriod, ClassicPurchaseOptions } from '../types' import ConnectionModal from '../components/ConnectionModal' +import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt' import { useCurrency } from '../hooks/useCurrency' // Helper to extract error message from axios/api errors @@ -772,9 +773,16 @@ export default function Subscription() {
)} + {devicePriceData && purchaseOptions && devicePriceData.total_price_kopeks && devicePriceData.total_price_kopeks > purchaseOptions.balance_kopeks && ( + + )} + - )} + {selectedTrafficPackage && (() => { + const selectedPkg = trafficPackages.find(p => p.gb === selectedTrafficPackage) + const hasEnoughBalance = !selectedPkg || !purchaseOptions || selectedPkg.price_kopeks <= purchaseOptions.balance_kopeks + const missingAmount = selectedPkg && purchaseOptions ? selectedPkg.price_kopeks - purchaseOptions.balance_kopeks : 0 + + return ( + <> + {!hasEnoughBalance && missingAmount > 0 && ( + + )} + + + ) + })()} {trafficPurchaseMutation.isError && (
@@ -1050,9 +1073,10 @@ export default function Subscription() {
{!switchPreview.has_enough_balance && switchPreview.upgrade_cost_kopeks > 0 && ( -
- {t('subscription.switchTariff.notEnoughBalance')}: {switchPreview.missing_amount_label} -
+ )} - {!paymentMethods ? ( -
-
-
- ) : paymentMethods.length === 0 ? ( -
- {t('balance.noPaymentMethods')} -
- ) : ( -
- {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: '' }) + {/* Scrollable content */} +
+ {!paymentMethods ? ( +
+
+
+ ) : paymentMethods.length === 0 ? ( +
+ {t('balance.noPaymentMethods')} +
+ ) : ( +
+ {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 ( - - ) - })} -
- )} + return ( + + ) + })} +
+ )} +
+ + {/* Footer */} +
+ +
) diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index 772c949..25e9d12 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from 'react' +import { useState, useRef, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { useMutation } from '@tanstack/react-query' import { balanceApi } from '../api/balance' @@ -45,6 +45,8 @@ interface TopUpModalProps { export default function TopUpModal({ method, onClose, initialAmountRubles }: TopUpModalProps) { const { t } = useTranslation() const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } = useCurrency() + const formRef = useRef(null) + const inputRef = useRef(null) // Calculate initial amount in user's currency const getInitialAmount = (): string => { @@ -73,6 +75,23 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top 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) + useEffect(() => { + const handleResize = () => { + // Scroll input into view when keyboard appears + if (document.activeElement === inputRef.current) { + setTimeout(() => { + inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }) + }, 100) + } + } + + if (typeof window !== 'undefined' && window.visualViewport) { + window.visualViewport.addEventListener('resize', handleResize) + return () => window.visualViewport?.removeEventListener('resize', handleResize) + } + }, []) + // Stars payment using the same approach as Wheel.tsx const starsPaymentMutation = useMutation({ mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks), @@ -140,7 +159,11 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top }) const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); setError(null) + e.preventDefault() + 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)) { @@ -176,103 +199,163 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top 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 ( -
-
-
-

{t('balance.topUp')} - {methodName}

-
-
- {hasOptions && method.options && ( -
- -
- {method.options.map((option) => ( - - ))} + {/* Scrollable content */} +
+ + {/* Payment options */} + {hasOptions && method.options && ( +
+ +
+ {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} +

- )} -
- - setAmount(e.target.value)} - placeholder={`${formatAmount(minRubles, currencyDecimals)} - ${formatAmount(maxRubles, currencyDecimals)}`} - min={minInputValue} - max={maxInputValue} - step={inputStep} - className="input" - /> -
- {t('balance.minAmount')}: {formatAmount(minRubles, currencyDecimals)} {currencySymbol} | {t('balance.maxAmount')}: {formatAmount(maxRubles, currencyDecimals)} {currencySymbol} -
-
+ {/* Quick amounts */} + {quickAmounts.length > 0 && ( +
+ +
+ {quickAmounts.map((a) => { + const quickValue = getQuickAmountValue(a) + const isSelected = amount === quickValue + return ( + + ) + })} +
+
+ )} - {quickAmounts.length > 0 && ( -
- {quickAmounts.map((a) => { - const quickValue = getQuickAmountValue(a) - return ( - - ) - })} -
- )} + {/* Error message */} + {error && ( +
+ {error} +
+ )} + +
- {error && ( -
{error}
- )} - -
- + -
- +
) } - From 6baeacc58728d22fbbb829fe06e25e560a58d8de Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 19 Jan 2026 00:53:43 +0300 Subject: [PATCH 08/14] Update globals.css --- src/styles/globals.css | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/styles/globals.css b/src/styles/globals.css index 84ad3af..64c5048 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -837,6 +837,16 @@ backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); } + + /* Safe area insets for mobile bottom sheets */ + .safe-area-inset-bottom { + padding-bottom: max(1rem, env(safe-area-inset-bottom, 0px)); + } + @media (min-width: 640px) { + .safe-area-inset-bottom { + padding-bottom: 1.25rem; + } + } } /* Animations */ From 3e8f547b92d54c7a8b55f9cf55e4145b069a1c4f Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 19 Jan 2026 01:04:44 +0300 Subject: [PATCH 09/14] Add files via upload --- src/components/InsufficientBalancePrompt.tsx | 61 ++-- src/components/TopUpModal.tsx | 332 ++++++++----------- 2 files changed, 161 insertions(+), 232 deletions(-) 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 */} +
+ +
From daefd5cdb44c9f96df961b26797739cac3f9d12e Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 19 Jan 2026 01:10:03 +0300 Subject: [PATCH 10/14] Update TopUpModal.tsx --- src/components/TopUpModal.tsx | 209 +++++++++++++++------------------- 1 file changed, 93 insertions(+), 116 deletions(-) diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index 7f72688..81c4360 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -41,7 +41,6 @@ interface TopUpModalProps { export default function TopUpModal({ method, onClose, initialAmountRubles }: TopUpModalProps) { const { t } = useTranslation() const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } = useCurrency() - const containerRef = useRef(null) const inputRef = useRef(null) const getInitialAmount = (): string => { @@ -58,7 +57,7 @@ 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 [isInputFocused, setIsInputFocused] = useState(false) const popupRef = useRef(null) const hasOptions = method.options && method.options.length > 0 @@ -69,31 +68,6 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top const methodName = t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) || method.name const isTelegramMiniApp = typeof window !== 'undefined' && Boolean(window.Telegram?.WebApp?.initData) - // Detect keyboard open/close - useEffect(() => { - const handleFocus = () => setIsKeyboardOpen(true) - const handleBlur = () => setTimeout(() => setIsKeyboardOpen(false), 100) - - const input = inputRef.current - if (input) { - input.addEventListener('focus', handleFocus) - input.addEventListener('blur', handleBlur) - return () => { - input.removeEventListener('focus', handleFocus) - input.removeEventListener('blur', handleBlur) - } - } - }, []) - - // 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) => { @@ -172,25 +146,23 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top const inputStep = currencyDecimals === 0 ? 1 : 0.01 const isPending = topUpMutation.isPending || starsPaymentMutation.isPending - return ( -
-
+ // When input is focused in Telegram Mini App, position modal at top + const modalPosition = isInputFocused && isTelegramMiniApp ? 'items-start pt-2' : 'items-end sm:items-center' -
- {/* Compact Header */} -
+ return ( +
+
{ inputRef.current?.blur(); onClose() }} /> + +
+ {/* Header - hide when keyboard open on mobile */} +
-
-

{methodName}

-
+

{methodName}

- {/* Scrollable Content */} -
-
- {/* Payment options - compact grid */} - {hasOptions && method.options && ( -
- {method.options.map((option) => ( + {/* Content */} +
+ {/* Payment options - hide when keyboard open */} + {hasOptions && method.options && !isInputFocused && ( +
+ {method.options.map((option) => ( + + ))} +
+ )} + + {/* Amount input */} +
+ setAmount(e.target.value)} + onFocus={() => setIsInputFocused(true)} + onBlur={() => setTimeout(() => setIsInputFocused(false), 150)} + 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 - always show, they help dismiss keyboard */} + {quickAmounts.length > 0 && ( +
+ {quickAmounts.map((a) => { + const quickValue = getQuickAmountValue(a) + const isSelected = amount === quickValue + return ( - ))} -
- )} - - {/* 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 - compact */} - {quickAmounts.length > 0 && ( -
- {quickAmounts.map((a) => { - const quickValue = getQuickAmountValue(a) - const isSelected = amount === quickValue - return ( - - ) - })} -
- )} + {/* Error - hide when keyboard open */} + {error && !isInputFocused && ( +
+ {error} +
+ )} - {/* Error */} - {error && ( -
- {error} -
- )} -
-
- - {/* Fixed Footer with Submit */} -
+ {/* Submit button - always visible */} - + + {/* Cancel - hide when keyboard open */} + {!isInputFocused && ( + + )}
+ + {/* Safe area for iPhone */} +
) From c004cc54fcda54b24b3ac5e0d0535cbc655e8125 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 19 Jan 2026 01:11:56 +0300 Subject: [PATCH 11/14] Update TopUpModal.tsx --- src/components/TopUpModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index 81c4360..1ce3615 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from 'react' +import { useState, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useMutation } from '@tanstack/react-query' import { balanceApi } from '../api/balance' From 4833f51dc790c5e26d7852ffa4ff1c6719068aea Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 19 Jan 2026 01:18:02 +0300 Subject: [PATCH 12/14] Add files via upload --- src/components/InsufficientBalancePrompt.tsx | 87 +++++----- src/components/TopUpModal.tsx | 167 +++++++------------ 2 files changed, 97 insertions(+), 157 deletions(-) diff --git a/src/components/InsufficientBalancePrompt.tsx b/src/components/InsufficientBalancePrompt.tsx index f5d1887..d1101bd 100644 --- a/src/components/InsufficientBalancePrompt.tsx +++ b/src/components/InsufficientBalancePrompt.tsx @@ -145,22 +145,22 @@ function PaymentMethodModal({ paymentMethods, onSelect, onClose }: PaymentMethod const { formatAmount, currencySymbol } = useCurrency() return ( -
+
-
- {/* Compact Header */} -
-

{t('balance.selectPaymentMethod')}

-
- {/* Scrollable content */} -
+ {/* Content */} +
{!paymentMethods ? (
@@ -170,49 +170,40 @@ function PaymentMethodModal({ paymentMethods, onSelect, onClose }: PaymentMethod {t('balance.noPaymentMethods')}
) : ( -
- {paymentMethods.map((method) => { - const methodKey = method.id.toLowerCase().replace(/-/g, '_') - const translatedName = t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) + paymentMethods.map((method) => { + const methodKey = method.id.toLowerCase().replace(/-/g, '_') + const translatedName = t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) - return ( - - ) - })} -
+
+
+
{translatedName || method.name}
+
+ {formatAmount(method.min_amount_kopeks / 100, 0)} – {formatAmount(method.max_amount_kopeks / 100, 0)} {currencySymbol} +
+
+ + + + + ) + }) )}
- - {/* Compact Footer */} -
- -
) diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index 1ce3615..822860f 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -7,7 +7,6 @@ import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils import type { PaymentMethod } from '../types' const TELEGRAM_LINK_REGEX = /^https?:\/\/t\.me\//i - const isTelegramPaymentLink = (url: string): boolean => TELEGRAM_LINK_REGEX.test(url) const openPaymentLink = (url: string, reservedWindow?: Window | null) => { @@ -17,16 +16,13 @@ const openPaymentLink = (url: string, reservedWindow?: Window | null) => { if (isTelegramPaymentLink(url) && webApp?.openTelegramLink) { try { webApp.openTelegramLink(url); return } catch (e) { console.warn('[TopUpModal] openTelegramLink failed:', e) } } - if (webApp?.openLink) { try { webApp.openLink(url, { try_instant_view: false }); return } catch (e) { console.warn('[TopUpModal] webApp.openLink failed:', e) } } - if (reservedWindow && !reservedWindow.closed) { try { reservedWindow.location.href = url; reservedWindow.focus?.() } catch (e) { console.warn('[TopUpModal] Failed to use reserved window:', e) } return } - const w2 = window.open(url, '_blank', 'noopener,noreferrer') if (w2) { w2.opener = null; return } window.location.href = url @@ -46,10 +42,9 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top const getInitialAmount = (): string => { if (!initialAmountRubles || initialAmountRubles <= 0) return '' const converted = convertAmount(initialAmountRubles) - if (targetCurrency === 'IRR' || targetCurrency === 'RUB') { - return Math.ceil(converted).toString() - } - return converted.toFixed(2) + return (targetCurrency === 'IRR' || targetCurrency === 'RUB') + ? Math.ceil(converted).toString() + : converted.toFixed(2) } const [amount, setAmount] = useState(getInitialAmount) @@ -57,7 +52,6 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top const [selectedOption, setSelectedOption] = useState( method.options && method.options.length > 0 ? method.options[0].id : null ) - const [isInputFocused, setIsInputFocused] = useState(false) const popupRef = useRef(null) const hasOptions = method.options && method.options.length > 0 @@ -78,24 +72,18 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top 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) } }) - } catch (e) { setError('Ошибка открытия окна оплаты: ' + String(e)) } + } catch (e) { setError('Ошибка: ' + String(e)) } }, - onError: (error: unknown) => { - const axiosError = error as { response?: { data?: { detail?: string }, status?: number } } - setError(`Ошибка API (${axiosError?.response?.status || 'network'}): ${axiosError?.response?.data?.detail || 'Не удалось создать счёт'}`) + 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 + 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) => { @@ -104,12 +92,11 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top popupRef.current = null onClose() }, - onError: (error: unknown) => { + onError: (err: unknown) => { 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')) - else setError(detail || t('common.error')) + const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '' + setError(detail.includes('not yet implemented') ? t('balance.useBot') : (detail || t('common.error'))) }, }) @@ -118,76 +105,63 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top inputRef.current?.blur() if (!checkRateLimit(RATE_LIMIT_KEYS.PAYMENT, 3, 30000)) { - setError(t('balance.tooManyRequests', { seconds: getRateLimitResetTime(RATE_LIMIT_KEYS.PAYMENT) }) || 'Подождите...') + setError('Подождите ' + getRateLimitResetTime(RATE_LIMIT_KEYS.PAYMENT) + ' сек.') return } - - if (hasOptions && !selectedOption) { setError(t('balance.selectPaymentOption', 'Выберите способ')); return } + if (hasOptions && !selectedOption) { setError('Выберите способ'); return } const amountCurrency = parseFloat(amount) - if (isNaN(amountCurrency) || amountCurrency <= 0) { setError(t('balance.invalidAmount', 'Введите сумму')); return } + if (isNaN(amountCurrency) || amountCurrency <= 0) { setError('Введите сумму'); 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 } + if (amountRubles < minRubles || amountRubles > maxRubles) { + setError(`Сумма: ${minRubles} – ${maxRubles} ₽`); return + } const amountKopeks = Math.round(amountRubles * 100) if (!isTelegramMiniApp) { try { popupRef.current = window.open('', '_blank') } catch { popupRef.current = null } } - if (isStarsMethod) { starsPaymentMutation.mutate(amountKopeks); return } - topUpMutation.mutate(amountKopeks) + 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 getQuickAmountValue = (rubAmount: number): string => { - if (targetCurrency === 'IRR') return Math.round(convertAmount(rubAmount)).toString() - return convertAmount(rubAmount).toFixed(currencyDecimals) - } - const inputStep = currencyDecimals === 0 ? 1 : 0.01 + 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 - // When input is focused in Telegram Mini App, position modal at top - const modalPosition = isInputFocused && isTelegramMiniApp ? 'items-start pt-2' : 'items-end sm:items-center' - return ( -
-
{ inputRef.current?.blur(); onClose() }} /> +
+
-
- {/* Header - hide when keyboard open on mobile */} -
-
-
- - - -
-

{methodName}

-
-
- {/* Content */}
- {/* Payment options - hide when keyboard open */} - {hasOptions && method.options && !isInputFocused && ( -
- {method.options.map((option) => ( + {/* Payment options */} + {hasOptions && method.options && ( +
+ {method.options.map((opt) => ( ))}
@@ -201,36 +175,27 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top inputMode="decimal" value={amount} onChange={(e) => setAmount(e.target.value)} - onFocus={() => setIsInputFocused(true)} - onBlur={() => setTimeout(() => setIsInputFocused(false), 150)} 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" + className="w-full h-12 px-4 pr-12 text-lg 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" autoComplete="off" /> - + {currencySymbol}
- {/* Quick amounts - always show, they help dismiss keyboard */} + {/* Quick amounts */} {quickAmounts.length > 0 && ( -
+
{quickAmounts.map((a) => { - const quickValue = getQuickAmountValue(a) - const isSelected = amount === quickValue + const val = getQuickValue(a) return (
)} - {/* Error - hide when keyboard open */} - {error && !isInputFocused && ( -
- {error} -
+ {/* Error */} + {error && ( +
{error}
)} - {/* Submit button - always visible */} + {/* Submit */} - - {/* Cancel - hide when keyboard open */} - {!isInputFocused && ( - - )}
- - {/* Safe area for iPhone */} -
) From 2c847e27f6e2124e4e32ea6f6c8d853cfade6bee Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 19 Jan 2026 01:22:02 +0300 Subject: [PATCH 13/14] Add files via upload --- src/components/InsufficientBalancePrompt.tsx | 4 ++-- src/components/TopUpModal.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/InsufficientBalancePrompt.tsx b/src/components/InsufficientBalancePrompt.tsx index d1101bd..dc784ed 100644 --- a/src/components/InsufficientBalancePrompt.tsx +++ b/src/components/InsufficientBalancePrompt.tsx @@ -145,10 +145,10 @@ function PaymentMethodModal({ paymentMethods, onSelect, onClose }: PaymentMethod const { formatAmount, currencySymbol } = useCurrency() return ( -
+
-
+
{/* Header */}
{t('balance.selectPaymentMethod')} diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index 822860f..2486d1b 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -132,7 +132,7 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top const isPending = topUpMutation.isPending || starsPaymentMutation.isPending return ( -
+
From 85f935a6b08948d31ccf88a2f90d0bf5cdb5f9f4 Mon Sep 17 00:00:00 2001 From: Egor Date: Mon, 19 Jan 2026 01:28:52 +0300 Subject: [PATCH 14/14] Add files via upload --- src/components/InsufficientBalancePrompt.tsx | 4 ++-- src/components/TopUpModal.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/InsufficientBalancePrompt.tsx b/src/components/InsufficientBalancePrompt.tsx index dc784ed..702a4fa 100644 --- a/src/components/InsufficientBalancePrompt.tsx +++ b/src/components/InsufficientBalancePrompt.tsx @@ -145,10 +145,10 @@ function PaymentMethodModal({ paymentMethods, onSelect, onClose }: PaymentMethod const { formatAmount, currencySymbol } = useCurrency() return ( -
+
-
+
{/* Header */}
{t('balance.selectPaymentMethod')} diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index 2486d1b..6cf974b 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -132,7 +132,7 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top const isPending = topUpMutation.isPending || starsPaymentMutation.isPending return ( -
+