mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
200
src/components/InsufficientBalancePrompt.tsx
Normal file
200
src/components/InsufficientBalancePrompt.tsx
Normal file
@@ -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<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/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="card max-w-lg w-full animate-slide-up max-h-[80vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-lg font-semibold text-dark-100">{t('balance.selectPaymentMethod')}</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}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!paymentMethods ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : paymentMethods.length === 0 ? (
|
||||
<div className="text-center py-8 text-dark-400">
|
||||
{t('balance.noPaymentMethods')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{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 (
|
||||
<button
|
||||
key={method.id}
|
||||
disabled={!method.is_available}
|
||||
onClick={() => method.is_available && onSelect(method)}
|
||||
className={`p-4 rounded-xl border text-left transition-all ${
|
||||
method.is_available
|
||||
? 'border-dark-700/50 hover:border-accent-500/50 bg-dark-800/30 cursor-pointer hover:bg-dark-800/50'
|
||||
: 'border-dark-800/30 bg-dark-900/30 opacity-50 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold text-dark-100">{translatedName || method.name}</div>
|
||||
{(translatedDesc || method.description) && (
|
||||
<div className="text-sm text-dark-500 mt-1">{translatedDesc || method.description}</div>
|
||||
)}
|
||||
<div className="text-xs text-dark-600 mt-2">
|
||||
{formatAmount(method.min_amount_kopeks / 100, 0)} – {formatAmount(method.max_amount_kopeks / 100, 0)} {currencySymbol}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string | null>(null)
|
||||
const [selectedOption, setSelectedOption] = useState<string | null>(
|
||||
method.options && method.options.length > 0 ? method.options[0].id : null
|
||||
|
||||
@@ -269,6 +269,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",
|
||||
|
||||
@@ -269,6 +269,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>
|
||||
|
||||
Reference in New Issue
Block a user