mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
Enhance payment flow by adding InsufficientBalancePrompt component for better user feedback; update CI workflow for Node.js setup and dependency installation.
This commit is contained in:
13
.github/workflows/ci.yml
vendored
13
.github/workflows/ci.yml
vendored
@@ -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:
|
||||
|
||||
210
src/components/InsufficientBalancePrompt.tsx
Normal file
210
src/components/InsufficientBalancePrompt.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
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<PaymentMethod | null>(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 (
|
||||
<>
|
||||
<div className={`flex items-center justify-between gap-3 p-3 bg-error-500/10 border border-error-500/30 rounded-xl ${className}`}>
|
||||
<div className="flex items-center gap-2 text-sm text-error-400">
|
||||
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
<span>
|
||||
{message || t('balance.insufficientFunds')}: <span className="font-semibold">{displayAmount} {currencySymbol}</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowMethodSelect(true)}
|
||||
className="btn-primary text-xs py-1.5 px-3 whitespace-nowrap"
|
||||
>
|
||||
{t('balance.topUp')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showMethodSelect && (
|
||||
<PaymentMethodModal
|
||||
paymentMethods={paymentMethods}
|
||||
onSelect={handleMethodSelect}
|
||||
onClose={() => setShowMethodSelect(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedMethod && (
|
||||
<TopUpModal
|
||||
method={selectedMethod}
|
||||
initialAmountRubles={Math.ceil(missingRubles)}
|
||||
onClose={() => setSelectedMethod(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`p-4 bg-gradient-to-br from-error-500/10 to-warning-500/5 border border-error-500/30 rounded-xl ${className}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-error-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-error-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-error-400 font-medium mb-1">
|
||||
{t('balance.insufficientFunds')}
|
||||
</div>
|
||||
<div className="text-dark-300 text-sm">
|
||||
{message || t('balance.topUpToComplete')}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<div className="text-lg font-bold text-dark-100">
|
||||
{t('balance.missing')}: <span className="text-error-400">{displayAmount} {currencySymbol}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowMethodSelect(true)}
|
||||
className="btn-primary w-full mt-4 py-2.5 flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" 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>
|
||||
{t('balance.topUpBalance')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showMethodSelect && (
|
||||
<PaymentMethodModal
|
||||
paymentMethods={paymentMethods}
|
||||
onSelect={handleMethodSelect}
|
||||
onClose={() => setShowMethodSelect(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedMethod && (
|
||||
<TopUpModal
|
||||
method={selectedMethod}
|
||||
initialAmountRubles={Math.ceil(missingRubles)}
|
||||
onClose={() => 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 (
|
||||
<div className="fixed inset-0 bg-black/70 z-50 flex items-center justify-center px-4 pt-14 pb-28 sm:pt-0 sm:pb-0">
|
||||
<div className="absolute inset-0" onClick={onClose} />
|
||||
|
||||
<div className="relative w-full max-w-sm bg-dark-900 rounded-2xl border border-dark-700/50 shadow-2xl overflow-hidden max-h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-dark-800/50">
|
||||
<span className="font-semibold text-dark-100">{t('balance.selectPaymentMethod')}</span>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-dark-700 text-dark-400">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{!paymentMethods ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="w-6 h-6 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : paymentMethods.length === 0 ? (
|
||||
<div className="text-center py-6 text-dark-400 text-sm">
|
||||
{t('balance.noPaymentMethods')}
|
||||
</div>
|
||||
) : (
|
||||
paymentMethods.map((method) => {
|
||||
const methodKey = method.id.toLowerCase().replace(/-/g, '_')
|
||||
const translatedName = t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' })
|
||||
|
||||
return (
|
||||
<button
|
||||
key={method.id}
|
||||
disabled={!method.is_available}
|
||||
onClick={() => method.is_available && onSelect(method)}
|
||||
className={`w-full p-3 rounded-xl text-left flex items-center gap-3 ${
|
||||
method.is_available
|
||||
? 'bg-dark-800 hover:bg-dark-700 active:bg-dark-600'
|
||||
: 'bg-dark-800/50 opacity-50'
|
||||
}`}
|
||||
>
|
||||
<div className="w-9 h-9 rounded-lg bg-accent-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<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="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>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-dark-100 text-sm">{translatedName || method.name}</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{formatAmount(method.min_amount_kopeks / 100, 0)} – {formatAmount(method.max_amount_kopeks / 100, 0)} {currencySymbol}
|
||||
</div>
|
||||
</div>
|
||||
<svg className="w-4 h-4 text-dark-500" 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" />
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,55 +7,47 @@ 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
|
||||
|
||||
// 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) }
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
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 = 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 }
|
||||
interface TopUpModalProps {
|
||||
method: PaymentMethod
|
||||
onClose: () => void
|
||||
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('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const getInitialAmount = (): string => {
|
||||
if (!initialAmountRubles || initialAmountRubles <= 0) return ''
|
||||
const converted = convertAmount(initialAmountRubles)
|
||||
return (targetCurrency === 'IRR' || targetCurrency === 'RUB')
|
||||
? Math.ceil(converted).toString()
|
||||
: converted.toFixed(2)
|
||||
}
|
||||
|
||||
const [amount, setAmount] = useState(getInitialAmount)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedOption, setSelectedOption] = useState<string | null>(
|
||||
method.options && method.options.length > 0 ? method.options[0].id : null
|
||||
@@ -63,215 +55,181 @@ export default function TopUpModal({ method, onClose }: TopUpModalProps) {
|
||||
const popupRef = useRef<Window | null>(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)
|
||||
|
||||
// Stars payment using the same approach as Wheel.tsx
|
||||
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')) }
|
||||
})
|
||||
} 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 || 'Не удалось создать счёт'}`)
|
||||
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) => {
|
||||
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) }
|
||||
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')))
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault(); setError(null)
|
||||
const handleSubmit = () => {
|
||||
setError(null)
|
||||
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('Подождите ' + 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', 'Invalid amount')); 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)
|
||||
// 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
|
||||
}
|
||||
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 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 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
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="card max-w-md w-full animate-slide-up">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-lg font-semibold text-dark-100">{t('balance.topUp')} - {methodName}</h2>
|
||||
<button onClick={onClose} className="btn-icon">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<div className="fixed inset-0 bg-black/70 z-50 flex items-center justify-center px-4 pt-14 pb-28 sm:pt-0 sm:pb-0">
|
||||
<div className="absolute inset-0" onClick={onClose} />
|
||||
|
||||
<div className="relative w-full max-w-sm bg-dark-900 rounded-2xl border border-dark-700/50 shadow-2xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-dark-800/50">
|
||||
<span className="font-semibold text-dark-100">{methodName}</span>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-dark-700 text-dark-400">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Payment options */}
|
||||
{hasOptions && method.options && (
|
||||
<div>
|
||||
<label className="label">{t('balance.paymentOption', 'Способ оплаты')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{method.options.map((option) => (
|
||||
<div className="flex gap-2">
|
||||
{method.options.map((opt) => (
|
||||
<button
|
||||
key={option.id}
|
||||
key={opt.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedOption(option.id)}
|
||||
className={`px-4 py-2.5 rounded-xl text-sm font-medium transition-all flex flex-col items-start ${
|
||||
selectedOption === option.id
|
||||
? 'bg-accent-500 text-white ring-2 ring-accent-400 ring-offset-2 ring-offset-dark-900'
|
||||
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
|
||||
onClick={() => setSelectedOption(opt.id)}
|
||||
className={`flex-1 py-2 px-3 rounded-lg text-sm font-medium transition-all ${
|
||||
selectedOption === opt.id
|
||||
? 'bg-accent-500 text-white'
|
||||
: 'bg-dark-800 text-dark-300'
|
||||
}`}
|
||||
>
|
||||
<span>{option.name}</span>
|
||||
{option.description && (
|
||||
<span className={`text-xs mt-0.5 ${selectedOption === option.id ? 'text-white/70' : 'text-dark-500'}`}>
|
||||
{option.description}
|
||||
</span>
|
||||
)}
|
||||
{opt.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="label">{t('balance.amount')} ({currencySymbol})</label>
|
||||
{/* Amount input */}
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder={`${formatAmount(minRubles, currencyDecimals)} - ${formatAmount(maxRubles, currencyDecimals)}`}
|
||||
min={minInputValue}
|
||||
max={maxInputValue}
|
||||
step={inputStep}
|
||||
className="input"
|
||||
placeholder={`${formatAmount(minRubles, 0)} – ${formatAmount(maxRubles, 0)}`}
|
||||
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"
|
||||
/>
|
||||
<div className="text-xs text-dark-500 mt-2">
|
||||
{t('balance.minAmount')}: {formatAmount(minRubles, currencyDecimals)} {currencySymbol} | {t('balance.maxAmount')}: {formatAmount(maxRubles, currencyDecimals)} {currencySymbol}
|
||||
</div>
|
||||
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-dark-500 font-medium">
|
||||
{currencySymbol}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Quick amounts */}
|
||||
{quickAmounts.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex gap-2">
|
||||
{quickAmounts.map((a) => {
|
||||
const quickValue = getQuickAmountValue(a)
|
||||
const val = getQuickValue(a)
|
||||
return (
|
||||
<button
|
||||
key={a}
|
||||
type="button"
|
||||
onClick={() => setAmount(quickValue)}
|
||||
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all ${
|
||||
amount === quickValue ? 'bg-accent-500 text-white' : 'bg-dark-800 text-dark-300 hover:bg-dark-700'
|
||||
onClick={() => { setAmount(val); inputRef.current?.blur() }}
|
||||
className={`flex-1 py-2 rounded-lg text-sm font-medium ${
|
||||
amount === val ? 'bg-accent-500 text-white' : 'bg-dark-800 text-dark-300'
|
||||
}`}
|
||||
>
|
||||
{formatAmount(a, currencyDecimals)} {currencySymbol}
|
||||
{formatAmount(a, 0)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-error-500/10 border border-error-500/30 text-error-400 p-3 rounded-xl text-sm">{error}</div>
|
||||
<div className="text-error-400 text-sm text-center py-1">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="submit" disabled={topUpMutation.isPending || starsPaymentMutation.isPending || !amount} className="btn-primary flex-1">
|
||||
{topUpMutation.isPending || starsPaymentMutation.isPending ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
{t('common.loading')}
|
||||
</span>
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isPending || !amount}
|
||||
className="btn-primary w-full h-11 text-base font-semibold"
|
||||
>
|
||||
{isPending ? (
|
||||
<span className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
) : (
|
||||
t('balance.topUp')
|
||||
<>
|
||||
{t('balance.topUp')}
|
||||
{amount && parseFloat(amount) > 0 && (
|
||||
<span className="ml-2 opacity-80">{formatAmount(parseFloat(amount), currencyDecimals)} {currencySymbol}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button type="button" onClick={onClose} className="btn-secondary">{t('common.cancel')}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -283,6 +283,11 @@
|
||||
"maxAmountError": "Maximum amount: {{amount}} ₽",
|
||||
"paymentOption": "Payment option",
|
||||
"selectPaymentOption": "Select payment option",
|
||||
"selectPaymentMethod": "Select payment method",
|
||||
"insufficientFunds": "Insufficient funds",
|
||||
"topUpToComplete": "Top up your balance to complete the purchase",
|
||||
"missing": "Missing",
|
||||
"noPaymentMethods": "No payment methods available",
|
||||
"paymentMethods": {
|
||||
"yookassa": {
|
||||
"name": "YooKassa",
|
||||
|
||||
@@ -283,6 +283,11 @@
|
||||
"maxAmountError": "Максимальная сумма: {{amount}} ₽",
|
||||
"paymentOption": "Способ оплаты",
|
||||
"selectPaymentOption": "Выберите способ оплаты",
|
||||
"selectPaymentMethod": "Выберите способ оплаты",
|
||||
"insufficientFunds": "Недостаточно средств",
|
||||
"topUpToComplete": "Пополните баланс для завершения покупки",
|
||||
"missing": "Не хватает",
|
||||
"noPaymentMethods": "Способы оплаты недоступны",
|
||||
"paymentMethods": {
|
||||
"yookassa": {
|
||||
"name": "ЮKassa",
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{devicePriceData && purchaseOptions && devicePriceData.total_price_kopeks && devicePriceData.total_price_kopeks > purchaseOptions.balance_kopeks && (
|
||||
<InsufficientBalancePrompt
|
||||
missingAmountKopeks={devicePriceData.total_price_kopeks - purchaseOptions.balance_kopeks}
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => devicePurchaseMutation.mutate()}
|
||||
disabled={devicePurchaseMutation.isPending}
|
||||
disabled={devicePurchaseMutation.isPending || !!(devicePriceData?.total_price_kopeks && purchaseOptions && devicePriceData.total_price_kopeks > purchaseOptions.balance_kopeks)}
|
||||
className="btn-primary w-full py-3"
|
||||
>
|
||||
{devicePurchaseMutation.isPending ? (
|
||||
@@ -862,10 +870,23 @@ export default function Subscription() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedTrafficPackage && (
|
||||
{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 && (
|
||||
<InsufficientBalancePrompt
|
||||
missingAmountKopeks={missingAmount}
|
||||
compact
|
||||
className="mb-3"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={() => trafficPurchaseMutation.mutate(selectedTrafficPackage)}
|
||||
disabled={trafficPurchaseMutation.isPending}
|
||||
disabled={trafficPurchaseMutation.isPending || !hasEnoughBalance}
|
||||
className="btn-primary w-full py-3"
|
||||
>
|
||||
{trafficPurchaseMutation.isPending ? (
|
||||
@@ -876,7 +897,9 @@ export default function Subscription() {
|
||||
`Купить ${selectedTrafficPackage} ГБ`
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
|
||||
{trafficPurchaseMutation.isError && (
|
||||
<div className="text-sm text-error-400 text-center">
|
||||
@@ -1050,9 +1073,10 @@ export default function Subscription() {
|
||||
</div>
|
||||
|
||||
{!switchPreview.has_enough_balance && switchPreview.upgrade_cost_kopeks > 0 && (
|
||||
<div className="text-sm text-error-400 bg-error-500/10 px-3 py-2 rounded-lg text-center">
|
||||
{t('subscription.switchTariff.notEnoughBalance')}: {switchPreview.missing_amount_label}
|
||||
</div>
|
||||
<InsufficientBalancePrompt
|
||||
missingAmountKopeks={switchPreview.missing_amount_kopeks}
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
@@ -1341,11 +1365,11 @@ export default function Subscription() {
|
||||
return (
|
||||
<div className="mt-6">
|
||||
{purchaseOptions && !hasEnoughBalance && (
|
||||
<div className="text-sm text-error-400 bg-error-500/10 px-4 py-3 rounded-lg text-center mb-4">
|
||||
{t('subscription.insufficientBalance', {
|
||||
missing: ((dailyPrice - purchaseOptions.balance_kopeks) / 100).toFixed(2)
|
||||
})}
|
||||
</div>
|
||||
<InsufficientBalancePrompt
|
||||
missingAmountKopeks={dailyPrice - purchaseOptions.balance_kopeks}
|
||||
compact
|
||||
className="mb-4"
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
@@ -1624,11 +1648,11 @@ export default function Subscription() {
|
||||
</div>
|
||||
|
||||
{purchaseOptions && !hasEnoughBalance && (
|
||||
<div className="text-sm text-error-400 bg-error-500/10 px-4 py-3 rounded-lg text-center mb-4">
|
||||
{t('subscription.insufficientBalance', {
|
||||
missing: ((totalPrice - purchaseOptions.balance_kopeks) / 100).toFixed(2)
|
||||
})}
|
||||
</div>
|
||||
<InsufficientBalancePrompt
|
||||
missingAmountKopeks={totalPrice - purchaseOptions.balance_kopeks}
|
||||
compact
|
||||
className="mb-4"
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { wheelApi, type SpinResult, type SpinHistoryItem } from '../api/wheel'
|
||||
import FortuneWheel from '../components/wheel/FortuneWheel'
|
||||
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt'
|
||||
import { useCurrency } from '../hooks/useCurrency'
|
||||
|
||||
// Icons
|
||||
@@ -574,21 +575,22 @@ export default function Wheel() {
|
||||
|
||||
{/* Cannot spin hint */}
|
||||
{!config.can_spin && !isSpinning && (
|
||||
<div className="text-center p-4 rounded-xl bg-dark-800/50 border border-dark-700">
|
||||
<>
|
||||
{config.can_spin_reason === 'daily_limit_reached' ? (
|
||||
<div className="text-center p-4 rounded-xl bg-dark-800/50 border border-dark-700">
|
||||
<p className="text-dark-400">{t('wheel.errors.dailyLimitReached')}</p>
|
||||
</div>
|
||||
) : config.can_spin_reason === 'insufficient_balance' ? (
|
||||
<div className="space-y-1">
|
||||
<p className="text-warning-400 font-medium">{t('wheel.errors.insufficientBalance')}</p>
|
||||
<p className="text-dark-500 text-sm">
|
||||
{t('wheel.errors.topUpRequired')} ({formatAmount((config.required_balance_kopeks - config.user_balance_kopeks) / 100)} {currencySymbol})
|
||||
</p>
|
||||
</div>
|
||||
<InsufficientBalancePrompt
|
||||
missingAmountKopeks={config.required_balance_kopeks - config.user_balance_kopeks}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center p-4 rounded-xl bg-dark-800/50 border border-dark-700">
|
||||
<p className="text-dark-400">{t('wheel.errors.cannotSpin')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user