Merge pull request #29 from BEDOLAGA-DEV/dev

Add files via upload
This commit is contained in:
Egor
2026-01-19 01:04:59 +03:00
committed by GitHub
2 changed files with 161 additions and 232 deletions

View File

@@ -146,35 +146,27 @@ function PaymentMethodModal({ paymentMethods, onSelect, onClose }: PaymentMethod
return ( return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-end sm:items-center justify-center"> <div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-end sm:items-center justify-center">
{/* Backdrop click to close */}
<div className="absolute inset-0" onClick={onClose} /> <div className="absolute inset-0" onClick={onClose} />
{/* Modal content - bottom sheet on mobile, centered on desktop */} <div className="relative w-full sm:max-w-lg sm:mx-4 bg-dark-900 sm:rounded-2xl rounded-t-2xl border border-dark-700/50 shadow-2xl animate-slide-up max-h-[80vh] flex flex-col">
<div className="relative w-full sm:max-w-lg sm:mx-4 bg-dark-900 sm:rounded-2xl rounded-t-2xl border border-dark-700/50 shadow-2xl animate-slide-up max-h-[85vh] flex flex-col"> {/* Compact Header */}
{/* Header */} <div className="flex-shrink-0 flex justify-between items-center px-4 py-3 border-b border-dark-800">
<div className="flex-shrink-0 flex justify-between items-center p-4 sm:p-5 border-b border-dark-800"> <h2 className="text-base font-semibold text-dark-100">{t('balance.selectPaymentMethod')}</h2>
<div> <button onClick={onClose} className="w-8 h-8 rounded-lg bg-dark-800 hover:bg-dark-700 flex items-center justify-center">
<h2 className="text-lg font-semibold text-dark-100">{t('balance.selectPaymentMethod')}</h2> <svg className="w-4 h-4 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<p className="text-sm text-dark-400 mt-0.5">{t('balance.topUpBalance')}</p>
</div>
<button
onClick={onClose}
className="w-10 h-10 rounded-xl bg-dark-800 hover:bg-dark-700 flex items-center justify-center transition-colors"
>
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
</div> </div>
{/* Scrollable content */} {/* Scrollable content */}
<div className="flex-1 overflow-y-auto overscroll-contain p-4 sm:p-5"> <div className="flex-1 overflow-y-auto overscroll-contain p-3">
{!paymentMethods ? ( {!paymentMethods ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-8">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" /> <div className="w-6 h-6 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div> </div>
) : paymentMethods.length === 0 ? ( ) : paymentMethods.length === 0 ? (
<div className="text-center py-8 text-dark-400"> <div className="text-center py-6 text-dark-400 text-sm">
{t('balance.noPaymentMethods')} {t('balance.noPaymentMethods')}
</div> </div>
) : ( ) : (
@@ -182,37 +174,30 @@ function PaymentMethodModal({ paymentMethods, onSelect, onClose }: PaymentMethod
{paymentMethods.map((method) => { {paymentMethods.map((method) => {
const methodKey = method.id.toLowerCase().replace(/-/g, '_') const methodKey = method.id.toLowerCase().replace(/-/g, '_')
const translatedName = t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) const translatedName = t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' })
const translatedDesc = t(`balance.paymentMethods.${methodKey}.description`, { defaultValue: '' })
return ( return (
<button <button
key={method.id} key={method.id}
disabled={!method.is_available} disabled={!method.is_available}
onClick={() => method.is_available && onSelect(method)} onClick={() => method.is_available && onSelect(method)}
className={`w-full p-4 rounded-xl border text-left transition-all flex items-center gap-4 ${ className={`w-full p-3 rounded-xl border text-left transition-all flex items-center gap-3 ${
method.is_available method.is_available
? 'border-dark-700/50 hover:border-accent-500/50 bg-dark-800/30 cursor-pointer hover:bg-dark-800/50 active:bg-dark-700/50' ? 'border-dark-700/50 bg-dark-800/30 active:bg-dark-700/50'
: 'border-dark-800/30 bg-dark-900/30 opacity-50 cursor-not-allowed' : 'border-dark-800/30 bg-dark-900/30 opacity-50'
}`} }`}
> >
{/* Icon */} <div className="w-10 h-10 rounded-lg bg-dark-800 flex items-center justify-center flex-shrink-0">
<div className="w-12 h-12 rounded-xl bg-dark-800 flex items-center justify-center flex-shrink-0"> <svg className="w-5 h-5 text-accent-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<svg className="w-6 h-6 text-accent-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
</svg> </svg>
</div> </div>
{/* Content */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-semibold text-dark-100">{translatedName || method.name}</div> <div className="font-medium text-dark-100 text-sm">{translatedName || method.name}</div>
{(translatedDesc || method.description) && ( <div className="text-xs text-dark-500">
<div className="text-sm text-dark-500 mt-0.5 truncate">{translatedDesc || method.description}</div>
)}
<div className="text-xs text-dark-600 mt-1">
{formatAmount(method.min_amount_kopeks / 100, 0)} {formatAmount(method.max_amount_kopeks / 100, 0)} {currencySymbol} {formatAmount(method.min_amount_kopeks / 100, 0)} {formatAmount(method.max_amount_kopeks / 100, 0)} {currencySymbol}
</div> </div>
</div> </div>
{/* Arrow */} <svg className="w-4 h-4 text-dark-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg className="w-5 h-5 text-dark-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> <path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg> </svg>
</button> </button>
@@ -222,13 +207,9 @@ function PaymentMethodModal({ paymentMethods, onSelect, onClose }: PaymentMethod
)} )}
</div> </div>
{/* Footer */} {/* Compact Footer */}
<div className="flex-shrink-0 px-4 sm:px-5 pt-4 sm:pt-5 safe-area-inset-bottom border-t border-dark-800 bg-dark-900"> <div className="flex-shrink-0 p-3 pt-2 border-t border-dark-800 bg-dark-900 safe-area-inset-bottom">
<button <button type="button" onClick={onClose} className="w-full py-2 text-sm text-dark-400 hover:text-dark-200">
type="button"
onClick={onClose}
className="btn-secondary w-full h-12 text-base"
>
{t('common.cancel')} {t('common.cancel')}
</button> </button>
</div> </div>

View File

@@ -14,17 +14,14 @@ const openPaymentLink = (url: string, reservedWindow?: Window | null) => {
if (typeof window === 'undefined' || !url) return if (typeof window === 'undefined' || !url) return
const webApp = window.Telegram?.WebApp const webApp = window.Telegram?.WebApp
// If inside Telegram Mini App, let Telegram handle t.me links
if (isTelegramPaymentLink(url) && webApp?.openTelegramLink) { if (isTelegramPaymentLink(url) && webApp?.openTelegramLink) {
try { webApp.openTelegramLink(url); return } catch (e) { console.warn('[TopUpModal] openTelegramLink failed:', e) } try { webApp.openTelegramLink(url); return } catch (e) { console.warn('[TopUpModal] openTelegramLink failed:', e) }
} }
// Inside Telegram Mini App → open in external browser
if (webApp?.openLink) { if (webApp?.openLink) {
try { webApp.openLink(url, { try_instant_view: false }); return } catch (e) { console.warn('[TopUpModal] webApp.openLink failed:', e) } 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) { if (reservedWindow && !reservedWindow.closed) {
try { reservedWindow.location.href = url; 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 return
@@ -38,17 +35,15 @@ const openPaymentLink = (url: string, reservedWindow?: Window | null) => {
interface TopUpModalProps { interface TopUpModalProps {
method: PaymentMethod method: PaymentMethod
onClose: () => void onClose: () => void
/** Pre-filled amount in rubles (will be converted to user's currency) */
initialAmountRubles?: number initialAmountRubles?: number
} }
export default function TopUpModal({ method, onClose, initialAmountRubles }: TopUpModalProps) { export default function TopUpModal({ method, onClose, initialAmountRubles }: TopUpModalProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } = useCurrency() const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } = useCurrency()
const formRef = useRef<HTMLFormElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
// Calculate initial amount in user's currency
const getInitialAmount = (): string => { const getInitialAmount = (): string => {
if (!initialAmountRubles || initialAmountRubles <= 0) return '' if (!initialAmountRubles || initialAmountRubles <= 0) return ''
const converted = convertAmount(initialAmountRubles) const converted = convertAmount(initialAmountRubles)
@@ -63,71 +58,59 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
const [selectedOption, setSelectedOption] = useState<string | null>( const [selectedOption, setSelectedOption] = useState<string | null>(
method.options && method.options.length > 0 ? method.options[0].id : null method.options && method.options.length > 0 ? method.options[0].id : null
) )
const [isKeyboardOpen, setIsKeyboardOpen] = useState(false)
const popupRef = useRef<Window | null>(null) const popupRef = useRef<Window | null>(null)
const hasOptions = method.options && method.options.length > 0 const hasOptions = method.options && method.options.length > 0
const minRubles = method.min_amount_kopeks / 100 const minRubles = method.min_amount_kopeks / 100
const maxRubles = method.max_amount_kopeks / 100 const maxRubles = method.max_amount_kopeks / 100
const methodKey = method.id.toLowerCase().replace(/-/g, '_') const methodKey = method.id.toLowerCase().replace(/-/g, '_')
const isStarsMethod = methodKey.includes('stars') const isStarsMethod = methodKey.includes('stars')
const methodName = t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) || method.name const methodName = t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) || method.name
const isTelegramMiniApp = typeof window !== 'undefined' && Boolean(window.Telegram?.WebApp?.initData) const isTelegramMiniApp = typeof window !== 'undefined' && Boolean(window.Telegram?.WebApp?.initData)
// Handle visual viewport changes (keyboard appearance on mobile) // Detect keyboard open/close
useEffect(() => { useEffect(() => {
const handleResize = () => { const handleFocus = () => setIsKeyboardOpen(true)
// Scroll input into view when keyboard appears const handleBlur = () => setTimeout(() => setIsKeyboardOpen(false), 100)
if (document.activeElement === inputRef.current) {
setTimeout(() => {
inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 100)
}
}
if (typeof window !== 'undefined' && window.visualViewport) { const input = inputRef.current
window.visualViewport.addEventListener('resize', handleResize) if (input) {
return () => window.visualViewport?.removeEventListener('resize', handleResize) 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({ const starsPaymentMutation = useMutation({
mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks), mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks),
onSuccess: (data) => { onSuccess: (data) => {
const webApp = window.Telegram?.WebApp const webApp = window.Telegram?.WebApp
if (!data.invoice_url) { setError('Сервер не вернул ссылку на оплату'); return }
if (!data.invoice_url) { if (!webApp?.openInvoice) { setError('Оплата Stars доступна только в Telegram Mini App'); return }
setError('Сервер не вернул ссылку на оплату')
return
}
if (!webApp?.openInvoice) {
setError('Оплата Stars доступна только в Telegram Mini App')
return
}
try { try {
webApp.openInvoice(data.invoice_url, (status) => { webApp.openInvoice(data.invoice_url, (status) => {
if (status === 'paid') { if (status === 'paid') { setError(null); onClose() }
setError(null) else if (status === 'failed') { setError(t('wheel.starsPaymentFailed')) }
onClose() else if (status === 'cancelled') { setError(null) }
} else if (status === 'failed') {
setError(t('wheel.starsPaymentFailed'))
} else if (status === 'cancelled') {
setError(null)
}
}) })
} catch (e) { } catch (e) { setError('Ошибка открытия окна оплаты: ' + String(e)) }
setError('Ошибка открытия окна оплаты: ' + String(e))
}
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
const axiosError = error as { response?: { data?: { detail?: string }, status?: number } } const axiosError = error as { response?: { data?: { detail?: string }, status?: number } }
const detail = axiosError?.response?.data?.detail setError(`Ошибка API (${axiosError?.response?.status || 'network'}): ${axiosError?.response?.data?.detail || 'Не удалось создать счёт'}`)
const status = axiosError?.response?.status
setError(`Ошибка API (${status || 'network'}): ${detail || 'Не удалось создать счёт'}`)
}, },
}) })
@@ -143,14 +126,12 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
mutationFn: (amountKopeks: number) => balanceApi.createTopUp(amountKopeks, method.id, selectedOption || undefined), mutationFn: (amountKopeks: number) => balanceApi.createTopUp(amountKopeks, method.id, selectedOption || undefined),
onSuccess: (data) => { onSuccess: (data) => {
const redirectUrl = data.payment_url || (data as any).invoice_url const redirectUrl = data.payment_url || (data as any).invoice_url
if (redirectUrl) { if (redirectUrl) openPaymentLink(redirectUrl, popupRef.current)
openPaymentLink(redirectUrl, popupRef.current)
}
popupRef.current = null popupRef.current = null
onClose() onClose()
}, },
onError: (error: unknown) => { 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 popupRef.current = null
const detail = (error as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '' const detail = (error as { response?: { data?: { detail?: string } } })?.response?.data?.detail || ''
if (detail.includes('not yet implemented')) setError(t('balance.useBot')) 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) => { const handleSubmit = () => {
e.preventDefault()
setError(null) setError(null)
// Hide keyboard on mobile
inputRef.current?.blur() inputRef.current?.blur()
// Rate limit check: max 3 payment attempts per 30 seconds
if (!checkRateLimit(RATE_LIMIT_KEYS.PAYMENT, 3, 30000)) { if (!checkRateLimit(RATE_LIMIT_KEYS.PAYMENT, 3, 30000)) {
const resetTime = getRateLimitResetTime(RATE_LIMIT_KEYS.PAYMENT) setError(t('balance.tooManyRequests', { seconds: getRateLimitResetTime(RATE_LIMIT_KEYS.PAYMENT) }) || 'Подождите...')
setError(t('balance.tooManyRequests', { seconds: resetTime }) || `Слишком много запросов. Подождите ${resetTime} сек.`)
return return
} }
if (hasOptions && !selectedOption) { setError(t('balance.selectPaymentOption', 'Выберите способ оплаты')); return } if (hasOptions && !selectedOption) { setError(t('balance.selectPaymentOption', 'Выберите способ')); return }
const amountCurrency = parseFloat(amount) 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) const amountRubles = convertToRub(amountCurrency)
if (amountRubles < minRubles) { setError(t('balance.minAmountError', { amount: minRubles })); return } if (amountRubles < minRubles) { setError(t('balance.minAmountError', { amount: minRubles })); return }
if (amountRubles > maxRubles) { setError(t('balance.maxAmountError', { amount: maxRubles })); return } if (amountRubles > maxRubles) { setError(t('balance.maxAmountError', { amount: maxRubles })); return }
const amountKopeks = Math.round(amountRubles * 100) const amountKopeks = Math.round(amountRubles * 100)
// Pre-open popup window to avoid browser blocking (must happen in user click context)
if (!isTelegramMiniApp) { if (!isTelegramMiniApp) {
try { popupRef.current = window.open('', '_blank') } catch { popupRef.current = null } try { popupRef.current = window.open('', '_blank') } catch { popupRef.current = null }
} }
if (isStarsMethod) { if (isStarsMethod) { starsPaymentMutation.mutate(amountKopeks); return }
starsPaymentMutation.mutate(amountKopeks); return
}
topUpMutation.mutate(amountKopeks) topUpMutation.mutate(amountKopeks)
} }
@@ -196,164 +170,138 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
return convertAmount(rubAmount).toFixed(currencyDecimals) return convertAmount(rubAmount).toFixed(currencyDecimals)
} }
const inputStep = currencyDecimals === 0 ? 1 : 0.01 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 const isPending = topUpMutation.isPending || starsPaymentMutation.isPending
return ( return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-end sm:items-center justify-center"> <div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-end sm:items-center justify-center">
{/* Backdrop click to close */}
<div className="absolute inset-0" onClick={onClose} /> <div className="absolute inset-0" onClick={onClose} />
{/* Modal content - bottom sheet on mobile, centered on desktop */} <div
<div className="relative w-full sm:max-w-md sm:mx-4 bg-dark-900 sm:rounded-2xl rounded-t-2xl border border-dark-700/50 shadow-2xl animate-slide-up max-h-[90vh] flex flex-col"> ref={containerRef}
{/* Header - sticky */} className="relative w-full sm:max-w-md sm:mx-4 bg-dark-900 sm:rounded-2xl rounded-t-2xl border border-dark-700/50 shadow-2xl animate-slide-up max-h-[85vh] overflow-hidden flex flex-col"
<div className="flex-shrink-0 flex justify-between items-center p-4 sm:p-5 border-b border-dark-800"> >
<div> {/* Compact Header */}
<h2 className="text-lg font-semibold text-dark-100">{t('balance.topUp')}</h2> <div className="flex-shrink-0 flex justify-between items-center px-4 py-3 border-b border-dark-800">
<p className="text-sm text-dark-400 mt-0.5">{methodName}</p> <div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-accent-500/20 flex items-center justify-center">
<svg className="w-4 h-4 text-accent-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
</div>
<div>
<h2 className="text-base font-semibold text-dark-100">{methodName}</h2>
</div>
</div> </div>
<button <button onClick={onClose} className="w-8 h-8 rounded-lg bg-dark-800 hover:bg-dark-700 flex items-center justify-center">
onClick={onClose} <svg className="w-4 h-4 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
className="w-10 h-10 rounded-xl bg-dark-800 hover:bg-dark-700 flex items-center justify-center transition-colors"
>
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
</div> </div>
{/* Scrollable content */} {/* Scrollable Content */}
<div className="flex-1 overflow-y-auto overscroll-contain"> <div className="flex-1 overflow-y-auto overscroll-contain">
<form ref={formRef} onSubmit={handleSubmit} className="p-4 sm:p-5 space-y-4"> <div className="p-4 space-y-3">
{/* Payment options */} {/* Payment options - compact grid */}
{hasOptions && method.options && ( {hasOptions && method.options && (
<div> <div className="grid grid-cols-2 gap-2">
<label className="block text-sm font-medium text-dark-300 mb-2">{t('balance.paymentOption', 'Способ оплаты')}</label> {method.options.map((option) => (
<div className="grid grid-cols-2 gap-2"> <button
{method.options.map((option) => ( key={option.id}
<button type="button"
key={option.id} onClick={() => setSelectedOption(option.id)}
type="button" className={`p-2.5 rounded-xl text-sm font-medium transition-all text-left ${
onClick={() => setSelectedOption(option.id)} selectedOption === option.id
className={`p-3 rounded-xl text-sm font-medium transition-all text-left ${ ? 'bg-accent-500 text-white'
selectedOption === option.id : 'bg-dark-800 text-dark-300 active:bg-dark-700'
? 'bg-accent-500 text-white ring-2 ring-accent-400' }`}
: 'bg-dark-800 text-dark-300 hover:bg-dark-700 active:bg-dark-600' >
}`} {option.name}
> </button>
<span className="block">{option.name}</span> ))}
{option.description && (
<span className={`block text-xs mt-0.5 ${selectedOption === option.id ? 'text-white/70' : 'text-dark-500'}`}>
{option.description}
</span>
)}
</button>
))}
</div>
</div> </div>
)} )}
{/* Amount input */} {/* Amount input with currency */}
<div> <div className="relative">
<label className="block text-sm font-medium text-dark-300 mb-2"> <input
{t('balance.amount')} ({currencySymbol}) ref={inputRef}
</label> type="number"
<div className="relative"> inputMode="decimal"
<input value={amount}
ref={inputRef} onChange={(e) => setAmount(e.target.value)}
type="number" placeholder={`${formatAmount(minRubles, 0)} ${formatAmount(maxRubles, 0)}`}
inputMode="decimal" step={inputStep}
value={amount} 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"
onChange={(e) => setAmount(e.target.value)} autoComplete="off"
placeholder={`${formatAmount(minRubles, currencyDecimals)} - ${formatAmount(maxRubles, currencyDecimals)}`} />
min={minInputValue} <span className="absolute right-4 top-1/2 -translate-y-1/2 text-dark-400 font-medium text-lg">
max={maxInputValue} {currencySymbol}
step={inputStep} </span>
className="input text-lg pr-14 h-14"
autoComplete="off"
/>
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-dark-500 font-medium">
{currencySymbol}
</span>
</div>
<p className="text-xs text-dark-500 mt-2">
{t('balance.minAmount')}: {formatAmount(minRubles, currencyDecimals)} {t('balance.maxAmount')}: {formatAmount(maxRubles, currencyDecimals)} {currencySymbol}
</p>
</div> </div>
{/* Quick amounts */} {/* Quick amounts - compact */}
{quickAmounts.length > 0 && ( {quickAmounts.length > 0 && (
<div> <div className="grid grid-cols-4 gap-2">
<label className="block text-sm font-medium text-dark-300 mb-2">{t('balance.quickAmount', 'Быстрый выбор')}</label> {quickAmounts.map((a) => {
<div className="grid grid-cols-4 gap-2"> const quickValue = getQuickAmountValue(a)
{quickAmounts.map((a) => { const isSelected = amount === quickValue
const quickValue = getQuickAmountValue(a) return (
const isSelected = amount === quickValue <button
return ( key={a}
<button type="button"
key={a} onClick={() => {
type="button" setAmount(quickValue)
onClick={() => { inputRef.current?.blur()
setAmount(quickValue) }}
inputRef.current?.blur() className={`py-2.5 rounded-xl text-sm font-medium transition-all ${
}} isSelected
className={`py-3 px-2 rounded-xl text-sm font-medium transition-all ${ ? 'bg-accent-500 text-white'
isSelected : 'bg-dark-800 text-dark-300 active:bg-dark-700'
? 'bg-accent-500 text-white' }`}
: 'bg-dark-800 text-dark-300 hover:bg-dark-700 active:bg-dark-600' >
}`} {formatAmount(a, 0)}
> </button>
{formatAmount(a, 0)} )
</button> })}
)
})}
</div>
</div> </div>
)} )}
{/* Error message */} {/* Error */}
{error && ( {error && (
<div className="bg-error-500/10 border border-error-500/30 text-error-400 p-3 rounded-xl text-sm"> <div className="bg-error-500/10 border border-error-500/30 text-error-400 px-3 py-2 rounded-xl text-sm">
{error} {error}
</div> </div>
)} )}
</form> </div>
</div> </div>
{/* Footer with buttons - sticky at bottom */} {/* Fixed Footer with Submit */}
<div className="flex-shrink-0 px-4 sm:px-5 pt-4 sm:pt-5 safe-area-inset-bottom border-t border-dark-800 bg-dark-900"> <div className="flex-shrink-0 p-4 pt-3 border-t border-dark-800 bg-dark-900 safe-area-inset-bottom">
<div className="flex gap-3"> <button
<button type="button"
type="button" onClick={handleSubmit}
onClick={onClose} disabled={isPending || !amount}
className="btn-secondary flex-1 h-12 text-base" className="btn-primary w-full h-12 text-base font-semibold"
> >
{t('common.cancel')} {isPending ? (
</button> <span className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
<button ) : (
type="submit" <span className="flex items-center justify-center gap-2">
onClick={handleSubmit} {t('balance.topUp')}
disabled={isPending || !amount} {amount && parseFloat(amount) > 0 && (
className="btn-primary flex-[2] h-12 text-base font-semibold" <span className="opacity-80">{formatAmount(parseFloat(amount), currencyDecimals)} {currencySymbol}</span>
> )}
{isPending ? ( </span>
<span className="flex items-center justify-center gap-2"> )}
<span className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" /> </button>
</span> <button
) : ( type="button"
<> onClick={onClose}
{t('balance.topUp')} className="w-full mt-2 py-2 text-sm text-dark-400 hover:text-dark-200"
{amount && ( >
<span className="ml-1 opacity-80"> {t('common.cancel')}
{formatAmount(parseFloat(amount) || 0, currencyDecimals)} {currencySymbol} </button>
</span>
)}
</>
)}
</button>
</div>
</div> </div>
</div> </div>
</div> </div>