From 00a013f02b88e68554f72fedd8701d5afe19d27e Mon Sep 17 00:00:00 2001 From: firewookie Date: Fri, 6 Mar 2026 09:47:58 +0500 Subject: [PATCH 1/7] =?UTF-8?q?-=20=D0=98=D0=BD=D1=82=D0=B5=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D1=80=D0=B5=D0=BA=D1=83=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=BE=D0=B2=20=D0=BE=D1=82=20=D0=AE=D0=BA=D0=B0?= =?UTF-8?q?=D1=81=D1=81=D1=8B=20-=20=D0=91=D0=B0=D0=B3=D1=84=D0=B8=D0=BA?= =?UTF-8?q?=D1=81=20=D0=BB=D0=B8=D1=87=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=B1=D0=B8=D0=BD=D0=B5=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 11 +++ src/api/adminUsers.ts | 3 +- src/api/balance.ts | 14 ++++ src/locales/en.json | 14 ++++ src/locales/fa.json | 20 ++++++ src/locales/ru.json | 14 ++++ src/locales/zh.json | 20 ++++++ src/pages/AdminUserDetail.tsx | 7 +- src/pages/Balance.tsx | 28 +++++++- src/pages/SavedCards.tsx | 132 ++++++++++++++++++++++++++++++++++ src/pages/TopUpAmount.tsx | 6 +- src/types/index.ts | 10 +++ vite.config.ts | 1 + 13 files changed, 274 insertions(+), 6 deletions(-) create mode 100644 src/pages/SavedCards.tsx 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', From 1b3e884a5d4711c123ce5fa43e26ace598ab95ff Mon Sep 17 00:00:00 2001 From: firewookie Date: Fri, 6 Mar 2026 09:50:10 +0500 Subject: [PATCH 2/7] vite config fix --- vite.config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/vite.config.ts b/vite.config.ts index cb5e447..ce18db2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -20,7 +20,6 @@ export default defineConfig({ server: { port: 5173, host: true, - allowedHosts: true, proxy: { '/api': { target: 'http://localhost:8080', From 8063ec50141f08e65ad7a07934ee319c20c14e75 Mon Sep 17 00:00:00 2001 From: firewookie Date: Fri, 6 Mar 2026 10:17:39 +0500 Subject: [PATCH 3/7] =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20=D0=BF=D0=B8=D1=81=D1=8C?= =?UTF-8?q?=D0=BC=D0=B0=20=D0=BD=D0=B0=20=D0=BF=D0=BE=D1=87=D1=82=D1=83=20?= =?UTF-8?q?=D0=B8=D0=B7=20=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BA=D0=B0=D0=B1?= =?UTF-8?q?=D0=B8=D0=BD=D0=B5=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/client.ts b/src/api/client.ts index 1f27460..42f9f8c 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -69,7 +69,7 @@ const AUTH_ENDPOINTS = [ '/cabinet/auth/telegram', '/cabinet/auth/telegram/widget', '/cabinet/auth/email/login', - '/cabinet/auth/email/register', + '/cabinet/auth/email/register/standalone', '/cabinet/auth/email/verify', '/cabinet/auth/refresh', '/cabinet/auth/password/forgot', From fd84df00202c2e92e68deb42aad495a2134c960d Mon Sep 17 00:00:00 2001 From: firewookie Date: Fri, 6 Mar 2026 11:08:52 +0500 Subject: [PATCH 4/7] =?UTF-8?q?-=20=D0=9A=D0=B0=D1=80=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BA=D0=B8=20"=D0=91=D0=BE=D0=BD=D1=83=D1=81=20=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BE=D0=BC=D1=83=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8E"=20=D0=B8=20"=D0=91=D0=BE?= =?UTF-8?q?=D0=BD=D1=83=D1=81=20=D0=BF=D1=80=D0=B8=D0=B3=D0=BB=D0=B0=D1=81?= =?UTF-8?q?=D0=B8=D0=B2=D1=88=D0=B5=D0=BC=D1=83"=20=20=20=20=20=D1=81?= =?UTF-8?q?=D0=BA=D1=80=D1=8B=D1=82=D1=8B=20=D0=BA=D0=BE=D0=B3=D0=B4=D0=B0?= =?UTF-8?q?=20=D0=B7=D0=BD=D0=B0=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=3D=200?= =?UTF-8?q?=20-=20=D0=94=D0=B8=D0=BD=D0=B0=D0=BC=D0=B8=D1=87=D0=B5=D1=81?= =?UTF-8?q?=D0=BA=D0=B8=D0=B9=20grid-cols=20=D0=B2=20=D0=B7=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=81=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D0=B8=20=D0=BE=D1=82?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BB=D0=B8=D1=87=D0=B5=D1=81=D1=82=D0=B2=D0=B0?= =?UTF-8?q?=20=D0=B2=D0=B8=D0=B4=D0=B8=D0=BC=D1=8B=D1=85=20=D0=BA=D0=B0?= =?UTF-8?q?=D1=80=D1=82=D0=BE=D1=87=D0=B5=D0=BA=20-=20=D0=94=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=BE=20=D0=BF=D0=BE=D0=BB=D0=B5?= =?UTF-8?q?=20max=5Fcommission=5Fpayments=20=D0=B2=20=D1=82=D0=B8=D0=BF=20?= =?UTF-8?q?ReferralTerms=20-=20=D0=A3=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B1=D0=BE=D1=82=D0=B0:=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=BA=D0=B8=20=D1=81=20=D0=B1=D0=BE=D0=BD?= =?UTF-8?q?=D1=83=D1=81=D0=BE=D0=BC=20=D0=BD=D0=BE=D0=B2=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=B8=20=D0=B1=D0=BE=D0=BD=D1=83=D1=81=D0=BE?= =?UTF-8?q?=D0=BC=20=20=20=20=20=D0=BF=D1=80=D0=B8=D0=B3=D0=BB=D0=B0=D1=81?= =?UTF-8?q?=D0=B8=D0=B2=D1=88=D0=B5=D0=B3=D0=BE=20=D1=81=D0=BA=D1=80=D1=8B?= =?UTF-8?q?=D0=B2=D0=B0=D1=8E=D1=82=D1=81=D1=8F=20=D0=B5=D1=81=D0=BB=D0=B8?= =?UTF-8?q?=20=D1=81=D0=BE=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D1=81=D1=82=D0=B2?= =?UTF-8?q?=D1=83=D1=8E=D1=89=D0=B8=D0=B5=20=D0=BD=D0=B0=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B9=D0=BA=D0=B8=20=3D=200=20-=20=D0=A0=D0=B0=D0=B7?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=20"=D0=9A=D0=B0=D0=BA=20=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=B0=D1=8E=D1=82=20=D0=BD=D0=B0=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D0=B4=D1=8B":=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B1=D0=BE=D0=BD=D1=83=D1=81=D0=BE=D0=B2=20=D1=81?= =?UTF-8?q?=D0=BA=D1=80=D1=8B=D1=82=D1=8B=20=D0=BF=D1=80=D0=B8=20=D0=B7?= =?UTF-8?q?=D0=BD=D0=B0=D1=87=D0=B5=D0=BD=D0=B8=D0=B8=200=20-=20Invite=20m?= =?UTF-8?q?essage:=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=20=D0=B1=D0=BE=D0=BD=D1=83=D1=81=20=D0=B7=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=BE=D0=B5=20=D0=BF=D0=BE=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81=D0=BA=D1=80=D1=8B=D1=82?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=B8=200=20-=20=D0=A2=D0=B5=D0=BA=D1=81?= =?UTF-8?q?=D1=82=20=D0=BA=D0=BE=D0=BC=D0=B8=D1=81=D1=81=D0=B8=D0=B8:=20"?= =?UTF-8?q?=D1=81=20=D0=BA=D0=B0=D0=B6=D0=B4=D0=BE=D0=B3=D0=BE=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F"=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D0=B1=D0=B5=D0=B7=20=D0=BB=D0=B8=D0=BC?= =?UTF-8?q?=D0=B8=D1=82=D0=B0,=20=20=20=20=20"=D1=81=20=D0=BF=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D0=B9"=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=20=D0=BD=D0=B0=D0=BB=D0=B8=D1=87=D0=B8=D0=B8=20REFERRAL?= =?UTF-8?q?=5FMAX=5FCOMMISSION=5FPAYMENTS-=20API=20/terms:=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=BE=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=B5=20max=5Fcommission=5Fpayments=20-=20=D0=94=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=BA=D0=BB=D1=8E=D1=87=20?= =?UTF-8?q?=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB=D0=B8=20REFERRAL=5FREWARD=5FCOMM?= =?UTF-8?q?ISSION=5FLIMITED=20(ru/en/ua/zh/fa)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/Referral.tsx | 77 ++++++++++++++++++++++++++---------------- src/types/index.ts | 1 + 2 files changed, 49 insertions(+), 29 deletions(-) diff --git a/src/pages/Referral.tsx b/src/pages/Referral.tsx index 24ca797..b5d50d2 100644 --- a/src/pages/Referral.tsx +++ b/src/pages/Referral.tsx @@ -302,37 +302,56 @@ export default function Referral() {
{/* Program Terms */} - {terms && ( -
-

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

-
-
-
{t('referral.terms.commission')}
-
- {terms.commission_percent}% + {terms && + (() => { + 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 gridCols = + cardCount <= 2 + ? 'md:grid-cols-2' + : cardCount === 3 + ? 'md:grid-cols-3' + : '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)} +
+
+ )}
-
-
{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)} -
-
-
-
- )} + ); + })()} {/* Referrals List */}
diff --git a/src/types/index.ts b/src/types/index.ts index dc81781..08ddb30 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; } From 0e3cf303414430ac1ec3109347aa24851b179fe9 Mon Sep 17 00:00:00 2001 From: firewookie Date: Tue, 10 Mar 2026 15:28:46 +0500 Subject: [PATCH 5/7] FIX PR Problems --- src/api/balance.ts | 8 +-- src/components/icons/index.tsx | 24 ++++++++ src/pages/AdminUserDetail.tsx | 3 +- src/pages/Balance.tsx | 18 +----- src/pages/Referral.tsx | 102 ++++++++++++++++----------------- src/pages/SavedCards.tsx | 60 +++++++++++++++---- src/types/index.ts | 5 ++ 7 files changed, 136 insertions(+), 84 deletions(-) diff --git a/src/api/balance.ts b/src/api/balance.ts index 933ca97..85e164d 100644 --- a/src/api/balance.ts +++ b/src/api/balance.ts @@ -6,7 +6,7 @@ import type { PaginatedResponse, PendingPayment, ManualCheckResponse, - SavedCard, + SavedCardsResponse, } from '../types'; export const balanceApi = { @@ -124,10 +124,8 @@ export const balanceApi = { }, // Get saved payment methods (cards) for recurrent payments - getSavedCards: async (): Promise<{ cards: SavedCard[]; recurrent_enabled: boolean }> => { - const response = await apiClient.get<{ cards: SavedCard[]; recurrent_enabled: boolean }>( - '/cabinet/balance/saved-cards', - ); + getSavedCards: async (): Promise => { + const response = await apiClient.get('/cabinet/balance/saved-cards'); return response.data; }, diff --git a/src/components/icons/index.tsx b/src/components/icons/index.tsx index 8b9e26e..f65513b 100644 --- a/src/components/icons/index.tsx +++ b/src/components/icons/index.tsx @@ -33,6 +33,30 @@ export const BackIcon = ({ className }: IconProps) => ( ); +export const ArrowLeftIcon = ({ 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) return; setActionLoading(true); try { - const action = overrideAction || subAction; const data: UpdateSubscriptionRequest = { action: action as UpdateSubscriptionRequest['action'], ...(action === 'extend' || action === 'shorten' ? { days: toNumber(subDays, 30) } : {}), diff --git a/src/pages/Balance.tsx b/src/pages/Balance.tsx index a02d30a..6ea57b9 100644 --- a/src/pages/Balance.tsx +++ b/src/pages/Balance.tsx @@ -13,21 +13,9 @@ import type { PaginatedResponse, Transaction } from '../types'; import { Card } from '@/components/data-display/Card'; import { Button } from '@/components/primitives/Button'; +import { ChevronDownIcon, ChevronRightIcon } from '@/components/icons'; import { staggerContainer, staggerItem } from '@/components/motion/transitions'; -// Icons -const ChevronDownIcon = ({ className = 'h-5 w-5' }: { className?: string }) => ( - - - -); - -const ChevronRightIcon = ({ className = 'h-5 w-5' }: { className?: string }) => ( - - - -); - const WalletIcon = ({ className = 'h-8 w-8' }: { className?: string }) => ( 0 && ( - +

{t('balance.topUpBalance')} @@ -435,7 +423,7 @@ export default function Balance() { {/* Saved Cards Navigation */} {savedCardsData?.recurrent_enabled && ( - + navigate('/balance/saved-cards')}>
diff --git a/src/pages/Referral.tsx b/src/pages/Referral.tsx index b5d50d2..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,56 +351,7 @@ export default function Referral() {
{/* Program Terms */} - {terms && - (() => { - 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 gridCols = - cardCount <= 2 - ? 'md:grid-cols-2' - : cardCount === 3 - ? 'md:grid-cols-3' - : '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)} -
-
- )} -
-
- ); - })()} + {programTerms} {/* Referrals List */}
diff --git a/src/pages/SavedCards.tsx b/src/pages/SavedCards.tsx index e7b4454..eb73d3a 100644 --- a/src/pages/SavedCards.tsx +++ b/src/pages/SavedCards.tsx @@ -6,24 +6,31 @@ 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 { ArrowLeftIcon } from '@/components/icons'; import { staggerContainer, staggerItem } from '@/components/motion/transitions'; -const ArrowLeftIcon = ({ className = 'h-5 w-5' }: { className?: string }) => ( - - - -); +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, refetch: refetchSavedCards } = useQuery({ + const { data: savedCardsData, isLoading } = useQuery({ queryKey: ['saved-cards'], queryFn: balanceApi.getSavedCards, }); @@ -32,12 +39,16 @@ export default function SavedCards() { const [deletingCardId, setDeletingCardId] = useState(null); const handleDeleteCard = async (cardId: number) => { - if (!confirm(t('balance.savedCards.confirmUnlink'))) return; + 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 refetchSavedCards(); - queryClient.invalidateQueries({ queryKey: ['saved-cards'] }); + await queryClient.invalidateQueries({ queryKey: ['saved-cards'] }); showToast({ type: 'success', title: t('balance.savedCards.unlinkSuccess'), @@ -76,8 +87,33 @@ export default function SavedCards() {

+ {/* Loading state */} + {isLoading && ( + + +
+ {[1, 2].map((i) => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+ + + )} + {/* Cards List */} - {savedCards && savedCards.length > 0 ? ( + {!isLoading && savedCards && savedCards.length > 0 ? (
@@ -95,7 +131,7 @@ export default function SavedCards() {
{t('balance.savedCards.linkedAt', { - date: new Date(card.created_at).toLocaleDateString(), + date: formatCardDate(card.created_at), })}
@@ -114,7 +150,7 @@ export default function SavedCards() {
- ) : savedCards ? ( + ) : !isLoading && savedCards ? ( /* Empty state - only show when data loaded and empty */ diff --git a/src/types/index.ts b/src/types/index.ts index 08ddb30..933318b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -586,6 +586,11 @@ export interface SavedCard { created_at: string; } +export interface SavedCardsResponse { + cards: SavedCard[]; + recurrent_enabled: boolean; +} + // Ticket notifications types export interface TicketNotification { id: number; From 9467a0886162671cc2bd657c869993dadd588d34 Mon Sep 17 00:00:00 2001 From: firewookie Date: Tue, 10 Mar 2026 15:42:56 +0500 Subject: [PATCH 6/7] FIX PR Problems --- src/pages/Balance.tsx | 4 ++++ src/pages/TopUpAmount.tsx | 6 ++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/pages/Balance.tsx b/src/pages/Balance.tsx index 3f434c3..d22e5fe 100644 --- a/src/pages/Balance.tsx +++ b/src/pages/Balance.tsx @@ -94,9 +94,13 @@ export default function Balance() { 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; diff --git a/src/pages/TopUpAmount.tsx b/src/pages/TopUpAmount.tsx index 2f07318..4f3ac73 100644 --- a/src/pages/TopUpAmount.tsx +++ b/src/pages/TopUpAmount.tsx @@ -469,8 +469,7 @@ export default function TopUpAmount() { {/* Error message */} {error && (
From 8c9eadc273ed647fa6d82ea0ee9cd878785b44ce Mon Sep 17 00:00:00 2001 From: firewookie Date: Tue, 10 Mar 2026 15:58:02 +0500 Subject: [PATCH 7/7] FIX PR Problems --- src/components/icons/index.tsx | 12 ------------ src/locales/en.json | 6 ++++-- src/locales/fa.json | 6 ++++-- src/locales/ru.json | 6 ++++-- src/locales/zh.json | 6 ++++-- src/pages/AdminUserDetail.tsx | 5 ++++- src/pages/SavedCards.tsx | 28 ++++++++++++++++++++++------ 7 files changed, 42 insertions(+), 27 deletions(-) diff --git a/src/components/icons/index.tsx b/src/components/icons/index.tsx index f65513b..3464619 100644 --- a/src/components/icons/index.tsx +++ b/src/components/icons/index.tsx @@ -33,18 +33,6 @@ export const BackIcon = ({ className }: IconProps) => ( ); -export const ArrowLeftIcon = ({ className }: IconProps) => ( - - - -); - export const ChevronRightIcon = ({ className }: IconProps) => ( { if (!userId) return; const action = overrideAction || subAction; - if ((action === 'extend' || action === 'shorten') && toNumber(subDays, 0) <= 0) return; + if ((action === 'extend' || action === 'shorten') && toNumber(subDays, 0) <= 0) { + notify.error(t('admin.users.detail.subscription.invalidDays')); + return; + } setActionLoading(true); try { const data: UpdateSubscriptionRequest = { diff --git a/src/pages/SavedCards.tsx b/src/pages/SavedCards.tsx index eb73d3a..ef8e5b0 100644 --- a/src/pages/SavedCards.tsx +++ b/src/pages/SavedCards.tsx @@ -10,7 +10,7 @@ import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog'; import { Card } from '@/components/data-display/Card'; import { Button } from '@/components/primitives/Button'; -import { ArrowLeftIcon } from '@/components/icons'; +import { BackIcon } from '@/components/icons'; import { staggerContainer, staggerItem } from '@/components/motion/transitions'; function formatCardDate(dateStr: string): string { @@ -30,7 +30,11 @@ export default function SavedCards() { const { showToast } = useToast(); const confirmDelete = useDestructiveConfirm(); - const { data: savedCardsData, isLoading } = useQuery({ + const { + data: savedCardsData, + isLoading, + isError, + } = useQuery({ queryKey: ['saved-cards'], queryFn: balanceApi.getSavedCards, }); @@ -55,7 +59,8 @@ export default function SavedCards() { message: '', duration: 3000, }); - } catch { + } catch (error) { + console.error('Failed to unlink card:', error); showToast({ type: 'error', title: t('balance.savedCards.unlinkError'), @@ -80,7 +85,7 @@ export default function SavedCards() { onClick={() => navigate('/balance')} className="flex h-10 w-10 items-center justify-center rounded-linear border border-dark-700/30 bg-dark-800/50 text-dark-300 transition-colors hover:bg-dark-700/50 hover:text-dark-100" > - +

{t('balance.savedCards.pageTitle')} @@ -112,8 +117,19 @@ export default function SavedCards() { )} + {/* Error state */} + {isError && ( + + +
+
{t('balance.savedCards.loadError')}
+
+
+
+ )} + {/* Cards List */} - {!isLoading && savedCards && savedCards.length > 0 ? ( + {!isLoading && !isError && savedCards && savedCards.length > 0 ? (
@@ -150,7 +166,7 @@ export default function SavedCards() {
- ) : !isLoading && savedCards ? ( + ) : !isLoading && !isError && savedCards ? ( /* Empty state - only show when data loaded and empty */