diff --git a/src/App.tsx b/src/App.tsx index 26e0dad..030e1be 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,6 +28,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')); @@ -236,6 +237,16 @@ function App() { } /> + + + + + + } + /> => { + const response = await apiClient.get<{ cards: SavedCard[]; recurrent_enabled: boolean }>( + '/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/locales/en.json b/src/locales/en.json index ec4ad25..edd7a08 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -661,6 +661,19 @@ "checkStatus": "Check Status", "checking": "Checking..." }, + "savedCards": { + "title": "Saved Cards", + "card": "Card", + "unlink": "Unlink", + "confirmUnlink": "Are you sure you want to unlink this card? Automatic balance top-up will no longer be possible.", + "unlinkSuccess": "Card unlinked successfully", + "unlinkError": "Failed to unlink card", + "linkedAt": "Linked {{date}}", + "noCards": "Card will be saved automatically on next top-up", + "pageTitle": "Saved Cards", + "backToBalance": "Back", + "empty": "No saved cards. A card will be saved automatically on your next top-up." + }, "paymentReady": "Payment link is ready", "clickToOpenPayment": "Click the button below to open the payment page in a new tab", "openPaymentPage": "Open payment page", @@ -2654,6 +2667,7 @@ "devices": "Devices", "actions": "Actions", "extend": "Extend", + "shorten": "Deduct days", "changeTariff": "Change tariff", "cancel": "Cancel", "activate": "Activate", diff --git a/src/locales/fa.json b/src/locales/fa.json index 7530135..40e443d 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -508,6 +508,25 @@ "noPaymentMethods": "روش پرداخت در دسترس نیست", "selectPaymentMethod": "روش پرداخت را انتخاب کنید", "topUpToComplete": "برای تکمیل خرید شارژ کنید", + "pendingPayments": { + "title": "پرداخت‌های در انتظار", + "pay": "پرداخت", + "checkStatus": "بررسی وضعیت", + "checking": "درحال بررسی..." + }, + "savedCards": { + "title": "کارت‌های ذخیره شده", + "card": "کارت", + "unlink": "حذف", + "confirmUnlink": "آیا مطمئن هستید که می‌خواهید این کارت را حذف کنید؟ شارژ خودکار موجودی دیگر امکان‌پذیر نخواهد بود.", + "unlinkSuccess": "کارت با موفقیت حذف شد", + "unlinkError": "خطا در حذف کارت", + "linkedAt": "متصل شده {{date}}", + "noCards": "کارت به طور خودکار در شارژ بعدی ذخیره می‌شود", + "pageTitle": "کارت‌های ذخیره شده", + "backToBalance": "بازگشت", + "empty": "کارتی ذخیره نشده است. کارت به طور خودکار در شارژ بعدی ذخیره می‌شود." + }, "paymentSuccess": { "title": "پرداخت موفق", "message": "موجودی شما با موفقیت شارژ شد. وجوه اکنون در دسترس است." @@ -2272,6 +2291,7 @@ "devices": "دستگاه‌ها", "actions": "عملیات", "extend": "تمدید", + "shorten": "کسر روزها", "changeTariff": "تغییر تعرفه", "cancel": "لغو", "activate": "فعال‌سازی", diff --git a/src/locales/ru.json b/src/locales/ru.json index 17146de..2b8c431 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -689,6 +689,19 @@ "checkStatus": "Проверить статус", "checking": "Проверка..." }, + "savedCards": { + "title": "Сохранённые карты", + "card": "Карта", + "unlink": "Отвязать", + "confirmUnlink": "Вы уверены, что хотите отвязать эту карту? Автоматическое пополнение баланса с неё станет невозможным.", + "unlinkSuccess": "Карта успешно отвязана", + "unlinkError": "Не удалось отвязать карту", + "linkedAt": "Привязана {{date}}", + "noCards": "Карта будет сохранена автоматически при следующем пополнении", + "pageTitle": "Сохранённые карты", + "backToBalance": "Назад", + "empty": "Нет привязанных карт. Карта будет сохранена автоматически при следующем пополнении." + }, "paymentReady": "Ссылка на оплату готова", "clickToOpenPayment": "Нажмите кнопку ниже, чтобы открыть страницу оплаты в новой вкладке", "openPaymentPage": "Открыть страницу оплаты", @@ -3179,6 +3192,7 @@ "devices": "Устройств", "actions": "Действия", "extend": "Продлить", + "shorten": "Списать дни", "changeTariff": "Сменить тариф", "cancel": "Отменить", "activate": "Активировать", diff --git a/src/locales/zh.json b/src/locales/zh.json index 86b3489..f8e6886 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -508,6 +508,25 @@ "noPaymentMethods": "支付方式不可用", "selectPaymentMethod": "选择支付方式", "topUpToComplete": "充值以完成购买", + "pendingPayments": { + "title": "待支付", + "pay": "支付", + "checkStatus": "检查状态", + "checking": "检查中..." + }, + "savedCards": { + "title": "已保存的银行卡", + "card": "银行卡", + "unlink": "解绑", + "confirmUnlink": "确定要解绑此卡吗?解绑后将无法自动充值余额。", + "unlinkSuccess": "银行卡已成功解绑", + "unlinkError": "解绑失败", + "linkedAt": "绑定于 {{date}}", + "noCards": "银行卡将在下次充值时自动保存", + "pageTitle": "已保存的银行卡", + "backToBalance": "返回", + "empty": "暂无已保存的银行卡。银行卡将在下次充值时自动保存。" + }, "paymentSuccess": { "title": "支付成功", "message": "您的余额已成功充值,资金现已可用。" @@ -2271,6 +2290,7 @@ "devices": "设备数", "actions": "操作", "extend": "延长", + "shorten": "扣除天数", "changeTariff": "更换套餐", "cancel": "取消", "activate": "激活", diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index 2399957..6ef3949 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -435,7 +435,7 @@ export default function AdminUserDetail() { 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 +1374,9 @@ export default function AdminUserDetail() { + @@ -1385,7 +1388,7 @@ export default function AdminUserDetail() { - {subAction === 'extend' && ( + {(subAction === 'extend' || subAction === 'shorten') && ( ( ); +const ChevronRightIcon = ({ className = 'h-5 w-5' }: { className?: string }) => ( + + + +); + const WalletIcon = ({ className = 'h-8 w-8' }: { className?: string }) => ( type?.toUpperCase?.() ?? type; const getTypeBadge = (type: string) => { @@ -263,7 +274,7 @@ export default function Balance() { {/* Payment Methods */} {paymentMethods && paymentMethods.length > 0 && ( - +

{t('balance.topUpBalance')} @@ -421,6 +432,21 @@ export default function Balance() { + + {/* Saved Cards Navigation */} + {savedCardsData?.recurrent_enabled && ( + + navigate('/balance/saved-cards')}> +
+
+ 💳 + {t('balance.savedCards.title')} +
+ +
+
+
+ )} ); } diff --git a/src/pages/SavedCards.tsx b/src/pages/SavedCards.tsx new file mode 100644 index 0000000..e7b4454 --- /dev/null +++ b/src/pages/SavedCards.tsx @@ -0,0 +1,132 @@ +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 { Card } from '@/components/data-display/Card'; +import { Button } from '@/components/primitives/Button'; +import { staggerContainer, staggerItem } from '@/components/motion/transitions'; + +const ArrowLeftIcon = ({ className = 'h-5 w-5' }: { className?: string }) => ( + + + +); + +export default function SavedCards() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { showToast } = useToast(); + + const { data: savedCardsData, refetch: refetchSavedCards } = useQuery({ + queryKey: ['saved-cards'], + queryFn: balanceApi.getSavedCards, + }); + const savedCards = savedCardsData?.cards; + + const [deletingCardId, setDeletingCardId] = useState(null); + + const handleDeleteCard = async (cardId: number) => { + if (!confirm(t('balance.savedCards.confirmUnlink'))) return; + setDeletingCardId(cardId); + try { + await balanceApi.deleteSavedCard(cardId); + await refetchSavedCards(); + queryClient.invalidateQueries({ queryKey: ['saved-cards'] }); + showToast({ + type: 'success', + title: t('balance.savedCards.unlinkSuccess'), + message: '', + duration: 3000, + }); + } catch { + showToast({ + type: 'error', + title: t('balance.savedCards.unlinkError'), + message: '', + duration: 3000, + }); + } finally { + setDeletingCardId(null); + } + }; + + return ( + + {/* Header */} + + +

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

+
+ + {/* Cards List */} + {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: new Date(card.created_at).toLocaleDateString(), + })} +
+
+
+ +
+ ))} +
+
+
+ ) : savedCards ? ( + /* Empty state - only show when data loaded and empty */ + + +
+
+ 💳 +
+
{t('balance.savedCards.empty')}
+
+
+
+ ) : null} +
+ ); +} diff --git a/src/pages/TopUpAmount.tsx b/src/pages/TopUpAmount.tsx index 3032224..c05fefa 100644 --- a/src/pages/TopUpAmount.tsx +++ b/src/pages/TopUpAmount.tsx @@ -454,7 +454,8 @@ export default function TopUpAmount() { {/* Error message */} {error && (
diff --git a/src/types/index.ts b/src/types/index.ts index 2a230ea..dc81781 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -575,6 +575,16 @@ 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; +} + // Ticket notifications types export interface TicketNotification { id: number; diff --git a/vite.config.ts b/vite.config.ts index ce18db2..cb5e447 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ server: { port: 5173, host: true, + allowedHosts: true, proxy: { '/api': { target: 'http://localhost:8080',