diff --git a/src/App.tsx b/src/App.tsx index f0c973f..295b137 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,6 +29,7 @@ import Dashboard from './pages/Dashboard'; const Subscription = lazy(() => import('./pages/Subscription')); const SubscriptionPurchase = lazy(() => import('./pages/SubscriptionPurchase')); const Balance = lazy(() => import('./pages/Balance')); +const SavedCards = lazy(() => import('./pages/SavedCards')); const Referral = lazy(() => import('./pages/Referral')); const Support = lazy(() => import('./pages/Support')); const Profile = lazy(() => import('./pages/Profile')); @@ -280,6 +281,16 @@ function App() { } /> + + + + + + } + /> => { + const response = await apiClient.get('/cabinet/balance/saved-cards'); + return response.data; + }, + + // Unlink (delete) a saved payment method + deleteSavedCard: async (id: number): Promise => { + await apiClient.delete(`/cabinet/balance/saved-cards/${id}`); + }, }; diff --git a/src/components/icons/index.tsx b/src/components/icons/index.tsx index 8b9e26e..3464619 100644 --- a/src/components/icons/index.tsx +++ b/src/components/icons/index.tsx @@ -33,6 +33,18 @@ export const BackIcon = ({ className }: IconProps) => ( ); +export const ChevronRightIcon = ({ className }: IconProps) => ( + + + +); + export const MenuIcon = ({ className }: IconProps) => ( { if (!userId) return; + const action = overrideAction || subAction; + if ((action === 'extend' || action === 'shorten') && toNumber(subDays, 0) <= 0) { + notify.error(t('admin.users.detail.subscription.invalidDays')); + return; + } setActionLoading(true); try { - const action = overrideAction || subAction; const data: UpdateSubscriptionRequest = { action: action as UpdateSubscriptionRequest['action'], - ...(action === 'extend' ? { days: toNumber(subDays, 30) } : {}), + ...(action === 'extend' || action === 'shorten' ? { days: toNumber(subDays, 30) } : {}), ...(action === 'change_tariff' && selectedTariffId ? { tariff_id: selectedTariffId } : {}), ...(action === 'create' ? { @@ -1374,6 +1378,9 @@ export default function AdminUserDetail() { + @@ -1385,7 +1392,7 @@ export default function AdminUserDetail() { - {subAction === 'extend' && ( + {(subAction === 'extend' || subAction === 'shorten') && (
+
+ 💳 + {t('balance.savedCards.title')} +
+ +
+ + + )} ); } diff --git a/src/pages/Referral.tsx b/src/pages/Referral.tsx index 24ca797..99b2381 100644 --- a/src/pages/Referral.tsx +++ b/src/pages/Referral.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; @@ -164,6 +164,55 @@ export default function Referral() { }, }); + const programTerms = useMemo(() => { + if (!terms) return null; + const showNewUserBonus = terms.first_topup_bonus_kopeks > 0; + const showInviterBonus = terms.inviter_bonus_kopeks > 0; + const cardCount = 2 + (showNewUserBonus ? 1 : 0) + (showInviterBonus ? 1 : 0); + const gridColsMap: Record = { + 2: 'md:grid-cols-2', + 3: 'md:grid-cols-3', + 4: 'md:grid-cols-4', + }; + const gridCols = gridColsMap[cardCount] ?? 'md:grid-cols-4'; + + return ( +
+

{t('referral.terms.title')}

+
+
+
{t('referral.terms.commission')}
+
+ {terms.commission_percent}% +
+
+
+
{t('referral.terms.minTopup')}
+
+ {formatAmount(terms.minimum_topup_rubles)} {currencySymbol} +
+
+ {showNewUserBonus && ( +
+
{t('referral.terms.newUserBonus')}
+
+ {formatPositive(terms.first_topup_bonus_rubles)} +
+
+ )} + {showInviterBonus && ( +
+
{t('referral.terms.inviterBonus')}
+
+ {formatPositive(terms.inviter_bonus_rubles)} +
+
+ )} +
+
+ ); + }, [terms, t, formatAmount, formatPositive, currencySymbol]); + const copyLink = async () => { if (!referralLink) return; try { @@ -302,37 +351,7 @@ export default function Referral() { {/* Program Terms */} - {terms && ( -
-

{t('referral.terms.title')}

-
-
-
{t('referral.terms.commission')}
-
- {terms.commission_percent}% -
-
-
-
{t('referral.terms.minTopup')}
-
- {formatAmount(terms.minimum_topup_rubles)} {currencySymbol} -
-
-
-
{t('referral.terms.newUserBonus')}
-
- {formatPositive(terms.first_topup_bonus_rubles)} -
-
-
-
{t('referral.terms.inviterBonus')}
-
- {formatPositive(terms.inviter_bonus_rubles)} -
-
-
-
- )} + {programTerms} {/* Referrals List */}
diff --git a/src/pages/SavedCards.tsx b/src/pages/SavedCards.tsx new file mode 100644 index 0000000..ef8e5b0 --- /dev/null +++ b/src/pages/SavedCards.tsx @@ -0,0 +1,184 @@ +import { useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router'; +import { motion } from 'framer-motion'; + +import { balanceApi } from '../api/balance'; +import { useToast } from '../components/Toast'; +import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog'; + +import { Card } from '@/components/data-display/Card'; +import { Button } from '@/components/primitives/Button'; +import { BackIcon } from '@/components/icons'; +import { staggerContainer, staggerItem } from '@/components/motion/transitions'; + +function formatCardDate(dateStr: string): string { + try { + const date = new Date(dateStr); + if (isNaN(date.getTime())) return dateStr; + return date.toLocaleDateString(); + } catch { + return dateStr; + } +} + +export default function SavedCards() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { showToast } = useToast(); + const confirmDelete = useDestructiveConfirm(); + + const { + data: savedCardsData, + isLoading, + isError, + } = useQuery({ + queryKey: ['saved-cards'], + queryFn: balanceApi.getSavedCards, + }); + const savedCards = savedCardsData?.cards; + + const [deletingCardId, setDeletingCardId] = useState(null); + + const handleDeleteCard = async (cardId: number) => { + if (deletingCardId !== null) return; + const confirmed = await confirmDelete( + t('balance.savedCards.confirmUnlink'), + t('balance.savedCards.unlink'), + ); + if (!confirmed) return; + setDeletingCardId(cardId); + try { + await balanceApi.deleteSavedCard(cardId); + await queryClient.invalidateQueries({ queryKey: ['saved-cards'] }); + showToast({ + type: 'success', + title: t('balance.savedCards.unlinkSuccess'), + message: '', + duration: 3000, + }); + } catch (error) { + console.error('Failed to unlink card:', error); + showToast({ + type: 'error', + title: t('balance.savedCards.unlinkError'), + message: '', + duration: 3000, + }); + } finally { + setDeletingCardId(null); + } + }; + + return ( + + {/* Header */} + + +

+ {t('balance.savedCards.pageTitle')} +

+
+ + {/* Loading state */} + {isLoading && ( + + +
+ {[1, 2].map((i) => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+ + + )} + + {/* Error state */} + {isError && ( + + +
+
{t('balance.savedCards.loadError')}
+
+
+
+ )} + + {/* Cards List */} + {!isLoading && !isError && savedCards && savedCards.length > 0 ? ( + + +
+ {savedCards.map((card) => ( +
+
+ 💳 +
+
+ {card.title || + `${card.card_type || t('balance.savedCards.card')} ${card.card_last4 ? `*${card.card_last4}` : ''}`} +
+
+ {t('balance.savedCards.linkedAt', { + date: formatCardDate(card.created_at), + })} +
+
+
+ +
+ ))} +
+
+
+ ) : !isLoading && !isError && savedCards ? ( + /* Empty state - only show when data loaded and empty */ + + +
+
+ 💳 +
+
{t('balance.savedCards.empty')}
+
+
+
+ ) : null} + + ); +} diff --git a/src/types/index.ts b/src/types/index.ts index 2a230ea..933318b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -437,6 +437,7 @@ export interface ReferralTerms { first_topup_bonus_rubles: number; inviter_bonus_kopeks: number; inviter_bonus_rubles: number; + max_commission_payments: number; partner_section_visible?: boolean; } @@ -575,6 +576,21 @@ export interface ManualCheckResponse { new_status: string | null; } +// Saved payment method (card) for recurrent payments +export interface SavedCard { + id: number; + method_type: string; + card_last4: string | null; + card_type: string | null; + title: string | null; + created_at: string; +} + +export interface SavedCardsResponse { + cards: SavedCard[]; + recurrent_enabled: boolean; +} + // Ticket notifications types export interface TicketNotification { id: number;