import { useState, useEffect, useRef } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { useSearchParams, useNavigate } from 'react-router'; import { motion, AnimatePresence } from 'framer-motion'; import { useAuthStore } from '../store/auth'; import { balanceApi } from '../api/balance'; import { useCurrency } from '../hooks/useCurrency'; import { API } from '../config/constants'; import type { PaginatedResponse, Transaction } from '../types'; import { Card } from '@/components/data-display/Card'; import { Button } from '@/components/primitives/Button'; import { ChevronDownIcon, ChevronRightIcon, CreditCardIcon } from '@/components/icons'; import { staggerContainer, staggerItem } from '@/components/motion/transitions'; import { isPaidStatus, isFailedStatus } from '../utils/paymentStatus'; const WalletIcon = ({ className = 'h-8 w-8' }: { className?: string }) => ( ); export default function Balance() { const { t } = useTranslation(); const refreshUser = useAuthStore((state) => state.refreshUser); const queryClient = useQueryClient(); const { formatAmount, currencySymbol } = useCurrency(); const [searchParams] = useSearchParams(); const navigate = useNavigate(); const paymentHandledRef = useRef(false); // Fetch balance from API const { data: balanceData, refetch: refetchBalance } = useQuery({ queryKey: ['balance'], queryFn: balanceApi.getBalance, staleTime: API.BALANCE_STALE_TIME_MS, refetchOnMount: 'always', }); // Refresh user data on mount to sync balance in store useEffect(() => { refreshUser(); }, [refreshUser]); // Handle payment return from payment gateway useEffect(() => { if (paymentHandledRef.current) return; const paymentStatus = searchParams.get('payment') || searchParams.get('status'); const normalised = paymentStatus?.toLowerCase() ?? ''; const isSuccess = isPaidStatus(normalised) || searchParams.get('success') === 'true'; const isFailed = isFailedStatus(normalised); if (isSuccess) { paymentHandledRef.current = true; navigate('/balance/top-up/result?status=success', { replace: true }); } else if (isFailed) { paymentHandledRef.current = true; navigate('/balance/top-up/result?status=failed', { replace: true }); } }, [searchParams, navigate]); const [promocode, setPromocode] = useState(''); const [promocodeLoading, setPromocodeLoading] = useState(false); const [promocodeError, setPromocodeError] = useState(null); const [promocodeSuccess, setPromocodeSuccess] = useState<{ message: string; amount: number; } | null>(null); const [promoSelectSubs, setPromoSelectSubs] = useState | null>(null); const [promoSelectCode, setPromoSelectCode] = useState(null); const [transactionsPage, setTransactionsPage] = useState(1); const [isHistoryOpen, setIsHistoryOpen] = useState(false); const { data: transactions, isLoading } = useQuery>({ queryKey: ['transactions', transactionsPage], queryFn: () => balanceApi.getTransactions({ per_page: 20, page: transactionsPage }), placeholderData: (previousData) => previousData, }); const { data: paymentMethods } = useQuery({ queryKey: ['payment-methods'], queryFn: balanceApi.getPaymentMethods, }); // Deferred: only fetch saved cards after payment methods loaded to avoid extra request on first render. // The recurrent_enabled flag is cached for 5 min to prevent refetching on every Balance visit. const { data: savedCardsData } = useQuery({ queryKey: ['saved-cards'], queryFn: balanceApi.getSavedCards, enabled: !!paymentMethods, staleTime: 5 * 60 * 1000, }); const normalizeType = (type: string) => type?.toUpperCase?.() ?? type; const getTypeBadge = (type: string) => { switch (normalizeType(type)) { case 'DEPOSIT': return 'badge-success'; case 'SUBSCRIPTION_PAYMENT': return 'badge-info'; case 'REFERRAL_REWARD': return 'badge-warning'; case 'WITHDRAWAL': return 'badge-error'; default: return 'badge-neutral'; } }; const getTypeLabel = (type: string) => { switch (normalizeType(type)) { case 'DEPOSIT': return t('balance.deposit'); case 'SUBSCRIPTION_PAYMENT': return t('balance.subscriptionPayment'); case 'REFERRAL_REWARD': return t('balance.referralReward'); case 'WITHDRAWAL': return t('balance.withdrawal'); default: return type; } }; const handlePromocodeActivate = async (subscriptionId?: number) => { const code = subscriptionId ? promoSelectCode || '' : promocode.trim(); if (!code) return; setPromocodeLoading(true); setPromocodeError(null); setPromocodeSuccess(null); try { const result = await balanceApi.activatePromocode(code, subscriptionId); if (result.error === 'select_subscription' && result.eligible_subscriptions) { setPromoSelectSubs(result.eligible_subscriptions); setPromoSelectCode(result.code || code); return; } if (result.success) { const bonusAmount = (result.balance_after || 0) - (result.balance_before || 0); setPromocodeSuccess({ message: result.bonus_description || t('balance.promocode.success'), amount: bonusAmount, }); setTransactionsPage(1); setPromocode(''); setPromoSelectSubs(null); setPromoSelectCode(null); await refetchBalance(); await refreshUser(); queryClient.invalidateQueries({ queryKey: ['transactions'] }); queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] }); } } catch (error: unknown) { const axiosError = error as { response?: { data?: { detail?: string } } }; const errorDetail = axiosError.response?.data?.detail || 'server_error'; const detail = errorDetail.toLowerCase(); const errorKey = detail.includes('not found') ? 'not_found' : detail.includes('deactivated') ? 'inactive' : detail.includes('not yet active') ? 'not_yet_valid' : detail.includes('expired') ? 'expired' : detail.includes('fully used') ? 'used' : detail.includes('already used') ? 'already_used_by_user' : 'server_error'; setPromocodeError(t(`balance.promocode.errors.${errorKey}`)); setPromoSelectSubs(null); setPromoSelectCode(null); } finally { setPromocodeLoading(false); } }; return (

{t('balance.title')}

{/* Balance Card — flat surface; the giant numeric carries the weight. The previous accent gradient + glow leaked accent into decoration (DESIGN.md Tunable-but-Scarce Rule) and read as the SaaS hero-metric template. */}
{t('balance.currentBalance')}
{formatAmount(balanceData?.balance_rubles || 0)} {currencySymbol}
{/* Promo Code Section */}

{t('balance.promocode.title')}

setPromocode(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handlePromocodeActivate()} placeholder={t('balance.promocode.placeholder')} className="input flex-1" disabled={promocodeLoading} />
{promocodeError && ( {promocodeError} )} {promocodeSuccess && (
{promocodeSuccess.message}
{promocodeSuccess.amount > 0 && (
{t('balance.promocode.balanceAdded', { amount: promocodeSuccess.amount.toFixed(2), })}
)}
)}
{promoSelectSubs && promoSelectSubs.length > 0 && (
{t('balance.promocode.selectSubscription', 'К какой подписке применить промокод?')}
{promoSelectSubs.map((sub) => ( ))}
)}
{/* Payment Methods */} {paymentMethods && paymentMethods.length > 0 && (

{t('balance.topUpBalance')}

{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 ( method.is_available && navigate(`/balance/top-up/${method.id}`)} >
{translatedName || method.name}
{(translatedDesc || method.description) && (
{translatedDesc || method.description}
)}
{formatAmount(method.min_amount_kopeks / 100, 0)} {t('common.rangeTo', 'to')}{' '} {formatAmount(method.max_amount_kopeks / 100, 0)} {currencySymbol}
); })}
)} {/* Transaction History */} {isHistoryOpen && (
{isLoading ? (
) : transactions?.items && transactions.items.length > 0 ? ( {transactions.items.map((tx) => { const isZero = tx.amount_rubles === 0; const isPositive = tx.amount_rubles > 0; const displayAmount = Math.abs(tx.amount_rubles); const sign = isZero ? '' : isPositive ? '+' : '-'; const colorClass = isZero ? 'text-dark-400' : isPositive ? 'text-success-400' : 'text-error-400'; return (
{getTypeLabel(tx.type)} {new Date(tx.created_at).toLocaleDateString()}
{tx.description && (
{tx.description}
)}
{sign} {formatAmount(displayAmount)} {currencySymbol}
); })}
) : (
{t('balance.noTransactions')}
)} {transactions && transactions.pages > 1 && (
{t('balance.page', { current: transactions.page, total: transactions.pages, })}
)}
)} {/* Saved Cards Navigation */} {savedCardsData?.recurrent_enabled && ( navigate('/balance/saved-cards')}>
{t('balance.savedCards.title')}
)} ); }