mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 18:13:47 +00:00
PRODUCT.md anti-references call out the SaaS-template / emoji-in-chrome register: the reference products (Linear, Stripe, Things 3) don't put platform emoji in product chrome. Three small drift spots: - Balance saved-cards link used a 💳 emoji as the leading icon. Replaced with a new CreditCardIcon added to the barrel (Heroicons- style stroke, matches the chevron / chat / wallet siblings). - Subscription paused-info banner used a ⏸️ emoji. Replaced with a new PauseIcon added to the barrel. Same 24×24 stroke, currentColor, preserves the urgent-400 tint via inline style. - Balance payment-method min/max range used a literal en-dash ('–') in source. PRODUCT.md's anti-em-dash rule covers the en-dash by the same logic. Switched to a translated 'to' connector (t('common.rangeTo', 'to')) so other locales can translate freely.
499 lines
20 KiB
TypeScript
499 lines
20 KiB
TypeScript
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 }) => (
|
||
<svg
|
||
className={className}
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
strokeWidth={1.5}
|
||
>
|
||
<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>
|
||
);
|
||
|
||
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<string | null>(null);
|
||
const [promocodeSuccess, setPromocodeSuccess] = useState<{
|
||
message: string;
|
||
amount: number;
|
||
} | null>(null);
|
||
const [promoSelectSubs, setPromoSelectSubs] = useState<Array<{
|
||
id: number;
|
||
tariff_name: string;
|
||
days_left: number;
|
||
}> | null>(null);
|
||
const [promoSelectCode, setPromoSelectCode] = useState<string | null>(null);
|
||
const [transactionsPage, setTransactionsPage] = useState(1);
|
||
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
||
|
||
const { data: transactions, isLoading } = useQuery<PaginatedResponse<Transaction>>({
|
||
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 (
|
||
<motion.div
|
||
className="space-y-6"
|
||
variants={staggerContainer}
|
||
initial="initial"
|
||
animate="animate"
|
||
>
|
||
<motion.div variants={staggerItem}>
|
||
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('balance.title')}</h1>
|
||
</motion.div>
|
||
|
||
{/* 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. */}
|
||
<motion.div variants={staggerItem}>
|
||
<Card>
|
||
<div className="mb-2 text-sm text-dark-400">{t('balance.currentBalance')}</div>
|
||
<div className="text-4xl font-bold text-dark-50 sm:text-5xl">
|
||
{formatAmount(balanceData?.balance_rubles || 0)}
|
||
<span className="ml-2 text-2xl text-dark-400">{currencySymbol}</span>
|
||
</div>
|
||
</Card>
|
||
</motion.div>
|
||
|
||
{/* Promo Code Section */}
|
||
<motion.div variants={staggerItem}>
|
||
<Card>
|
||
<h2 className="mb-4 text-lg font-semibold text-dark-100">
|
||
{t('balance.promocode.title')}
|
||
</h2>
|
||
<div className="flex gap-3">
|
||
<input
|
||
type="text"
|
||
value={promocode}
|
||
onChange={(e) => setPromocode(e.target.value)}
|
||
onKeyDown={(e) => e.key === 'Enter' && handlePromocodeActivate()}
|
||
placeholder={t('balance.promocode.placeholder')}
|
||
className="input flex-1"
|
||
disabled={promocodeLoading}
|
||
/>
|
||
<Button
|
||
onClick={() => handlePromocodeActivate()}
|
||
disabled={!promocode.trim()}
|
||
loading={promocodeLoading}
|
||
>
|
||
{t('balance.promocode.activate')}
|
||
</Button>
|
||
</div>
|
||
<AnimatePresence mode="wait">
|
||
{promocodeError && (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: -10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: -10 }}
|
||
className="mt-3 rounded-linear border border-error-500/30 bg-error-500/10 p-3 text-sm text-error-400"
|
||
>
|
||
{promocodeError}
|
||
</motion.div>
|
||
)}
|
||
{promocodeSuccess && (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: -10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: -10 }}
|
||
className="mt-3 rounded-linear border border-success-500/30 bg-success-500/10 p-3 text-sm text-success-400"
|
||
>
|
||
<div className="font-medium">{promocodeSuccess.message}</div>
|
||
{promocodeSuccess.amount > 0 && (
|
||
<div className="mt-1">
|
||
{t('balance.promocode.balanceAdded', {
|
||
amount: promocodeSuccess.amount.toFixed(2),
|
||
})}
|
||
</div>
|
||
)}
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
{promoSelectSubs && promoSelectSubs.length > 0 && (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: -10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
className="mt-3 space-y-2 rounded-linear border border-accent-500/30 bg-accent-500/10 p-3"
|
||
>
|
||
<div className="text-sm font-medium text-dark-200">
|
||
{t('balance.promocode.selectSubscription', 'К какой подписке применить промокод?')}
|
||
</div>
|
||
{promoSelectSubs.map((sub) => (
|
||
<button
|
||
key={sub.id}
|
||
onClick={() => handlePromocodeActivate(sub.id)}
|
||
disabled={promocodeLoading}
|
||
className="flex w-full items-center justify-between rounded-linear border border-dark-600 bg-dark-700 px-3 py-2 text-sm text-dark-200 transition-colors hover:border-accent-500/50 hover:bg-dark-600"
|
||
>
|
||
<span>{sub.tariff_name}</span>
|
||
<span className="text-dark-400">
|
||
{t('balance.promocode.daysLeft', '{{count}} дн.', { count: sub.days_left })}
|
||
</span>
|
||
</button>
|
||
))}
|
||
<button
|
||
onClick={() => {
|
||
setPromoSelectSubs(null);
|
||
setPromoSelectCode(null);
|
||
}}
|
||
className="text-xs text-dark-400 hover:text-dark-200"
|
||
>
|
||
{t('common.cancel', 'Отмена')}
|
||
</button>
|
||
</motion.div>
|
||
)}
|
||
</Card>
|
||
</motion.div>
|
||
|
||
{/* Payment Methods */}
|
||
{paymentMethods && paymentMethods.length > 0 && (
|
||
<motion.div variants={staggerItem}>
|
||
<Card>
|
||
<h2 className="mb-4 text-lg font-semibold text-dark-100">
|
||
{t('balance.topUpBalance')}
|
||
</h2>
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-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 (
|
||
<Card
|
||
key={method.id}
|
||
interactive={method.is_available}
|
||
className={!method.is_available ? 'cursor-not-allowed opacity-50' : ''}
|
||
onClick={() => method.is_available && navigate(`/balance/top-up/${method.id}`)}
|
||
>
|
||
<div className="font-semibold text-dark-100">
|
||
{translatedName || method.name}
|
||
</div>
|
||
{(translatedDesc || method.description) && (
|
||
<div className="mt-1 text-sm text-dark-500">
|
||
{translatedDesc || method.description}
|
||
</div>
|
||
)}
|
||
<div className="mt-3 text-xs text-dark-600">
|
||
{formatAmount(method.min_amount_kopeks / 100, 0)} {t('common.rangeTo', 'to')}{' '}
|
||
{formatAmount(method.max_amount_kopeks / 100, 0)} {currencySymbol}
|
||
</div>
|
||
</Card>
|
||
);
|
||
})}
|
||
</div>
|
||
</Card>
|
||
</motion.div>
|
||
)}
|
||
|
||
{/* Transaction History */}
|
||
<motion.div variants={staggerItem}>
|
||
<Card className="overflow-hidden">
|
||
<button
|
||
onClick={() => setIsHistoryOpen(!isHistoryOpen)}
|
||
className="flex w-full items-center justify-between text-left"
|
||
>
|
||
<h2 className="text-lg font-semibold text-dark-100">
|
||
{t('balance.transactionHistory')}
|
||
</h2>
|
||
<ChevronDownIcon
|
||
className={`h-5 w-5 text-dark-400 transition-transform duration-200 ${isHistoryOpen ? 'rotate-180' : ''}`}
|
||
/>
|
||
</button>
|
||
|
||
<AnimatePresence>
|
||
{isHistoryOpen && (
|
||
<motion.div
|
||
initial={{ height: 0, opacity: 0 }}
|
||
animate={{ height: 'auto', opacity: 1 }}
|
||
exit={{ height: 0, opacity: 0 }}
|
||
transition={{ duration: 0.2 }}
|
||
className="overflow-hidden"
|
||
>
|
||
<div className="mt-4">
|
||
{isLoading ? (
|
||
<div className="flex items-center justify-center py-12">
|
||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||
</div>
|
||
) : transactions?.items && transactions.items.length > 0 ? (
|
||
<motion.div
|
||
className="space-y-3"
|
||
variants={staggerContainer}
|
||
initial="initial"
|
||
animate="animate"
|
||
>
|
||
{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 (
|
||
<motion.div
|
||
key={tx.id}
|
||
variants={staggerItem}
|
||
className="flex items-center justify-between rounded-linear border border-dark-700/30 bg-dark-800/30 p-4"
|
||
>
|
||
<div className="flex-1">
|
||
<div className="mb-1 flex items-center gap-3">
|
||
<span className={getTypeBadge(tx.type)}>
|
||
{getTypeLabel(tx.type)}
|
||
</span>
|
||
<span className="text-xs text-dark-500">
|
||
{new Date(tx.created_at).toLocaleDateString()}
|
||
</span>
|
||
</div>
|
||
{tx.description && (
|
||
<div className="text-sm text-dark-400">{tx.description}</div>
|
||
)}
|
||
</div>
|
||
<div className={`text-lg font-semibold ${colorClass}`}>
|
||
{sign}
|
||
{formatAmount(displayAmount)} {currencySymbol}
|
||
</div>
|
||
</motion.div>
|
||
);
|
||
})}
|
||
</motion.div>
|
||
) : (
|
||
<div className="py-12 text-center">
|
||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-linear-lg bg-dark-800">
|
||
<WalletIcon className="h-8 w-8 text-dark-500" />
|
||
</div>
|
||
<div className="text-dark-400">{t('balance.noTransactions')}</div>
|
||
</div>
|
||
)}
|
||
|
||
{transactions && transactions.pages > 1 && (
|
||
<div className="mt-4 flex flex-wrap items-center gap-3 text-sm text-dark-500">
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => setTransactionsPage((prev) => Math.max(1, prev - 1))}
|
||
disabled={transactions.page <= 1}
|
||
className="min-w-[120px] flex-1 sm:flex-none"
|
||
>
|
||
{t('common.back')}
|
||
</Button>
|
||
<div className="flex-1 text-center">
|
||
{t('balance.page', {
|
||
current: transactions.page,
|
||
total: transactions.pages,
|
||
})}
|
||
</div>
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() =>
|
||
setTransactionsPage((prev) =>
|
||
transactions.pages ? Math.min(transactions.pages, prev + 1) : prev + 1,
|
||
)
|
||
}
|
||
disabled={transactions.page >= transactions.pages}
|
||
className="min-w-[120px] flex-1 sm:flex-none"
|
||
>
|
||
{t('common.next')}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</Card>
|
||
</motion.div>
|
||
|
||
{/* Saved Cards Navigation */}
|
||
{savedCardsData?.recurrent_enabled && (
|
||
<motion.div variants={staggerItem}>
|
||
<Card interactive onClick={() => navigate('/balance/saved-cards')}>
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<CreditCardIcon className="h-5 w-5 text-dark-400" />
|
||
<span className="font-medium text-dark-100">{t('balance.savedCards.title')}</span>
|
||
</div>
|
||
<ChevronRightIcon className="h-5 w-5 text-dark-400" />
|
||
</div>
|
||
</Card>
|
||
</motion.div>
|
||
)}
|
||
</motion.div>
|
||
);
|
||
}
|