From 00a013f02b88e68554f72fedd8701d5afe19d27e Mon Sep 17 00:00:00 2001 From: firewookie Date: Fri, 6 Mar 2026 09:47:58 +0500 Subject: [PATCH 01/24] =?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 02/24] 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 03/24] =?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 04/24] =?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 ea374d68057c6842618fdf829734755bd06a8efe Mon Sep 17 00:00:00 2001 From: firewookie Date: Fri, 6 Mar 2026 11:44:22 +0500 Subject: [PATCH 05/24] - RioPay payment system integration --- src/components/PaymentMethodIcon.tsx | 18 ++++++++++++++++++ src/components/sales-stats/DepositsTab.tsx | 1 + src/pages/AdminPaymentMethodEdit.tsx | 1 + 3 files changed, 20 insertions(+) diff --git a/src/components/PaymentMethodIcon.tsx b/src/components/PaymentMethodIcon.tsx index 8fd00a4..fa179e5 100644 --- a/src/components/PaymentMethodIcon.tsx +++ b/src/components/PaymentMethodIcon.tsx @@ -189,6 +189,24 @@ export default function PaymentMethodIcon({ ); + case 'riopay': + return ( + + + + + + + + + + + RP + + + + ); + default: return ( diff --git a/src/components/sales-stats/DepositsTab.tsx b/src/components/sales-stats/DepositsTab.tsx index cbcf3f2..19aca2b 100644 --- a/src/components/sales-stats/DepositsTab.tsx +++ b/src/components/sales-stats/DepositsTab.tsx @@ -29,6 +29,7 @@ const METHOD_LABELS: Record = { cloudpayments: 'CloudPayments', freekassa: 'FreeKassa', kassa_ai: 'Kassa AI', + riopay: 'RioPay', }; export function DepositsTab({ params }: DepositsTabProps) { diff --git a/src/pages/AdminPaymentMethodEdit.tsx b/src/pages/AdminPaymentMethodEdit.tsx index a0ebdc4..0169239 100644 --- a/src/pages/AdminPaymentMethodEdit.tsx +++ b/src/pages/AdminPaymentMethodEdit.tsx @@ -33,6 +33,7 @@ const METHOD_LABELS: Record = { freekassa_card: 'Freekassa Карта', cloudpayments: 'CloudPayments', kassa_ai: 'Kassa AI', + riopay: 'RioPay', }; const CheckIcon = () => ( From af3e535c698e7046f420b80a781991505f0c0ffb Mon Sep 17 00:00:00 2001 From: Fringg Date: Tue, 10 Mar 2026 05:37:18 +0300 Subject: [PATCH 06/24] =?UTF-8?q?feat:=20gift=20subscription=20redesign=20?= =?UTF-8?q?=E2=80=94=20code-only=20purchase=20+=203-tab=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove recipient input from buy flow (code-only gifts) - 3-tab UI: Buy, Activate, My Gifts with animations - After purchase, switch to My Gifts tab to show gift code - Gift code display with copy/share buttons - Activate tab: enter gift code to activate subscription - My Gifts tab: sent/received gifts with status badges - Fix: use navigator.language instead of hardcoded ru-RU for dates - Fix: spread array before sort to prevent React Query cache mutation - Fix: ARIA tab linkage with proper id/aria-labelledby - Fix: guard VITE_BOT_USERNAME against undefined in share URLs - Fix: wrap clipboard/share fallbacks in try/catch - Add error state to MyGiftsTabContent --- src/api/gift.ts | 48 +- src/locales/en.json | 37 +- src/locales/ru.json | 37 +- src/pages/GiftSubscription.tsx | 1456 ++++++++++++++++++++------------ 4 files changed, 1018 insertions(+), 560 deletions(-) diff --git a/src/api/gift.ts b/src/api/gift.ts index c96c180..a009b5a 100644 --- a/src/api/gift.ts +++ b/src/api/gift.ts @@ -45,8 +45,8 @@ export interface GiftConfig { export interface GiftPurchaseRequest { tariff_id: number; period_days: number; - recipient_type: 'email' | 'telegram'; - recipient_value: string; + recipient_type?: 'email' | 'telegram'; + recipient_value?: string; gift_message?: string; payment_mode: 'balance' | 'gateway'; payment_method?: string; @@ -86,6 +86,35 @@ export interface PendingGift { created_at: string | null; } +export interface SentGift { + token: string; + tariff_name: string | null; + period_days: number; + device_limit: number; + status: string; + gift_recipient_value: string | null; + gift_message: string | null; + activated_by_username: string | null; + created_at: string | null; +} + +export interface ReceivedGift { + token: string; + tariff_name: string | null; + period_days: number; + device_limit: number; + status: string; + sender_display: string | null; + gift_message: string | null; + created_at: string | null; +} + +export interface ActivateGiftResponse { + status: string; + tariff_name: string | null; + period_days: number | null; +} + // API export const giftApi = { @@ -108,4 +137,19 @@ export const giftApi = { const { data } = await apiClient.get('/cabinet/gift/pending'); return data; }, + + getSentGifts: async (): Promise => { + const { data } = await apiClient.get('/cabinet/gift/sent'); + return data; + }, + + getReceivedGifts: async (): Promise => { + const { data } = await apiClient.get('/cabinet/gift/received'); + return data; + }, + + activateGiftCode: async (code: string): Promise => { + const { data } = await apiClient.post('/cabinet/gift/activate', { code }); + return data; + }, }; diff --git a/src/locales/en.json b/src/locales/en.json index c3175bc..6d9951d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -4201,6 +4201,41 @@ }, "warning": { "telegram_unresolvable": "Could not verify this Telegram username. The recipient may not receive a notification about the gift." - } + }, + "pageTitle": "Gifts", + "tabBuy": "Buy", + "tabActivate": "Activate", + "tabMyGifts": "My Gifts", + "selectTariff": "SELECT TARIFF", + "selectPeriod": "SUBSCRIPTION PERIOD", + "deviceCount": "{{count}} device", + "deviceCount_other": "{{count}} devices", + "activateTitle": "Enter gift code", + "activateDescription": "Enter the gift code you received from a friend", + "activateCodePlaceholder": "GIFT-XXXXXXXXXXXX", + "activateButton": "Activate gift", + "activating": "Activating...", + "activateSuccess": "Gift activated!", + "activateSuccessDesc": "{{tariff}} — {{days}} days added to your subscription", + "activateError": "Failed to activate gift", + "myGiftsEmpty": "No gifts yet", + "myGiftsEmptyDesc": "Buy a gift for a friend or activate a received code", + "sentGiftsTitle": "Sent", + "receivedGiftsTitle": "Received", + "statusAvailable": "AVAILABLE", + "statusActivated": "ACTIVATED", + "statusPending": "PENDING", + "statusDelivered": "DELIVERED", + "statusFailed": "FAILED", + "statusExpired": "EXPIRED", + "statusPendingActivation": "PENDING ACTIVATION", + "copyCode": "COPY", + "codeCopied": "Code copied!", + "shareGift": "SHARE", + "shareText": "I have a gift for you! Activate it here:", + "activatedBy": "Activated by {{username}}", + "sentTo": "Sent to {{recipient}}", + "daysShort": "days", + "devicesShort": "dev." } } diff --git a/src/locales/ru.json b/src/locales/ru.json index 88b897b..fb98fb2 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -4763,6 +4763,41 @@ }, "warning": { "telegram_unresolvable": "Не удалось проверить этот Telegram-юзернейм. Получатель может не получить уведомление о подарке." - } + }, + "pageTitle": "Подарки", + "tabBuy": "Купить", + "tabActivate": "Активировать", + "tabMyGifts": "Мои подарки", + "selectTariff": "ВЫБЕРИТЕ ТАРИФ", + "selectPeriod": "ПЕРИОД ПОДПИСКИ", + "deviceCount": "{{count}} устройство", + "deviceCount_other": "{{count}} устройств", + "activateTitle": "Введите код подарка", + "activateDescription": "Введите код подарка, полученный от друга или знакомого", + "activateCodePlaceholder": "GIFT-XXXXXXXXXXXX", + "activateButton": "Активировать подарок", + "activating": "Активация...", + "activateSuccess": "Подарок активирован!", + "activateSuccessDesc": "{{tariff}} — {{days}} дн. добавлено к вашей подписке", + "activateError": "Не удалось активировать подарок", + "myGiftsEmpty": "У вас пока нет подарков", + "myGiftsEmptyDesc": "Купите подарок для друга или активируйте полученный код", + "sentGiftsTitle": "Отправленные", + "receivedGiftsTitle": "Полученные", + "statusAvailable": "ДОСТУПЕН", + "statusActivated": "АКТИВИРОВАН", + "statusPending": "ОЖИДАЕТ", + "statusDelivered": "ДОСТАВЛЕН", + "statusFailed": "ОШИБКА", + "statusExpired": "ПРОСРОЧЕН", + "statusPendingActivation": "ОЖИДАЕТ АКТИВАЦИИ", + "copyCode": "КОПИРОВАТЬ", + "codeCopied": "Код скопирован!", + "shareGift": "ПОДЕЛИТЬСЯ", + "shareText": "У меня есть подарок для тебя! Активируй его здесь:", + "activatedBy": "Активирован пользователем {{username}}", + "sentTo": "Отправлен {{recipient}}", + "daysShort": "дн.", + "devicesShort": "устр." } } diff --git a/src/pages/GiftSubscription.tsx b/src/pages/GiftSubscription.tsx index 649e653..cfd9c4e 100644 --- a/src/pages/GiftSubscription.tsx +++ b/src/pages/GiftSubscription.tsx @@ -10,6 +10,8 @@ import type { GiftTariffPeriod, GiftPaymentMethod, GiftPurchaseRequest, + SentGift, + ReceivedGift, } from '../api/gift'; import { cn } from '../lib/utils'; @@ -17,22 +19,136 @@ import { getApiErrorMessage } from '../utils/api-error'; import { formatPrice } from '../utils/format'; // ============================================================ -// Helpers +// SVG Icons // ============================================================ -function detectContactType(value: string): 'email' | 'telegram' { - return value.startsWith('@') ? 'telegram' : 'email'; +function GiftIcon({ className }: { className?: string }) { + return ( + + + + + + + + ); } -function isValidContact(value: string): boolean { - const trimmed = value.trim(); - if (!trimmed) return false; - if (trimmed.startsWith('@')) { - return /^@[a-zA-Z][a-zA-Z0-9_]{4,31}$/.test(trimmed); - } - return /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(trimmed); +function CheckIcon({ className }: { className?: string }) { + return ( + + + + ); } +function CopyIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +function ShareIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} + +function CheckCircleIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +function KeyIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} + +function InboxIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +// ============================================================ +// Helpers +// ============================================================ + function formatPeriodLabel( days: number, t: (key: string, options?: Record) => string, @@ -49,8 +165,44 @@ function formatPeriodLabel( return t('landing.periodLabels.nDays', { count: days }); } +function getGiftStatusKey(status: string): string { + const statusMap: Record = { + pending_activation: 'gift.statusPendingActivation', + delivered: 'gift.statusDelivered', + paid: 'gift.statusAvailable', + pending: 'gift.statusPending', + failed: 'gift.statusFailed', + expired: 'gift.statusExpired', + }; + return statusMap[status] ?? 'gift.statusPending'; +} + +function isGiftAvailable(status: string): boolean { + return status === 'paid' || status === 'delivered' || status === 'pending_activation'; +} + +function isGiftActivated(gift: SentGift): boolean { + return gift.status === 'delivered' && gift.activated_by_username != null; +} + +function formatGiftDate(dateStr: string | null): string { + if (!dateStr) return ''; + const date = new Date(dateStr); + return date.toLocaleDateString(navigator.language || 'ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); +} + // ============================================================ -// Sub-components +// Tab type +// ============================================================ + +type TabId = 'buy' | 'activate' | 'myGifts'; + +// ============================================================ +// Sub-components: Shared // ============================================================ function LoadingSkeleton() { @@ -84,7 +236,7 @@ function ErrorState({ message }: { message: string }) { />
-

{t('gift.failedTitle', 'Error')}

+

{t('gift.failedTitle')}

{message}

@@ -118,56 +270,24 @@ function DisabledState() { /> -

- {t('gift.featureDisabled', 'Gift subscriptions are currently unavailable')} -

-

{t('gift.redirecting', 'Redirecting...')}

+

{t('gift.featureDisabled')}

+

{t('gift.redirecting')}

); } -function PeriodTabs({ - periods, - selectedDays, - onSelect, -}: { - periods: GiftTariffPeriod[]; - selectedDays: number; - onSelect: (days: number) => void; -}) { - const { t } = useTranslation(); - - return ( -
- {periods.map((period) => ( - - ))} -
- ); -} +// ============================================================ +// Sub-components: Buy Tab +// ============================================================ function TariffCard({ tariff, isSelected, - selectedPeriod, onSelect, }: { tariff: GiftTariff; isSelected: boolean; - selectedPeriod: GiftTariffPeriod | undefined; onSelect: () => void; }) { const { t } = useTranslation(); @@ -179,100 +299,105 @@ function TariffCard({ aria-checked={isSelected} onClick={onSelect} className={cn( - 'relative flex w-full flex-col rounded-2xl border p-5 text-start transition-all duration-200', + 'flex w-full items-center gap-4 rounded-2xl border p-4 text-start transition-all duration-200', isSelected - ? 'border-accent-500/50 bg-accent-500/5 ring-1 ring-accent-500/25' - : 'border-dark-800/50 bg-dark-900/50 hover:border-dark-700/50 hover:bg-dark-800/30', + ? 'border-accent-500/50 bg-accent-500/5' + : 'border-dark-800/50 bg-dark-900/50 hover:border-dark-700/50', )} > - {/* Header */} -
-
-

{tariff.name}

- {tariff.description && ( -

{tariff.description}

- )} -
-
+ +
+ + {/* Info */} +
+

{tariff.name}

+

- {isSelected && ( - - - - )} -

+ {t('gift.deviceCount', { count: tariff.device_limit })} +

- {/* Info row */} -
- - - - - {tariff.traffic_limit_gb === 0 ? '\u221E' : tariff.traffic_limit_gb}{' '} - {t('landing.gb', 'GB')} - - - - - - {tariff.device_limit} {t('landing.devices', 'devices')} - + {/* Checkmark circle */} +
+ {isSelected && }
+ + ); +} - {/* Price */} - {selectedPeriod && ( -
-
- - {formatPrice(selectedPeriod.price_kopeks)} - - {selectedPeriod.original_price_kopeks != null && - selectedPeriod.original_price_kopeks > selectedPeriod.price_kopeks && ( - <> - - {formatPrice(selectedPeriod.original_price_kopeks)} - - {selectedPeriod.discount_percent != null && ( - - -{selectedPeriod.discount_percent}% - - )} - - )} -
-
+function PeriodCard({ + period, + isSelected, + onSelect, +}: { + period: GiftTariffPeriod; + isSelected: boolean; + onSelect: () => void; +}) { + const { t } = useTranslation(); + const hasDiscount = + period.original_price_kopeks != null && period.original_price_kopeks > period.price_kopeks; + + return ( + ); } @@ -291,7 +416,7 @@ function PaymentModeToggle({ return (
); @@ -345,7 +470,7 @@ function PaymentMethodCard({ 'rounded-2xl border transition-all duration-200', isSelected ? 'border-accent-500/50 bg-accent-500/5' - : 'border-dark-800/50 bg-dark-900/50 hover:border-dark-700/50 hover:bg-dark-800/30', + : 'border-dark-800/50 bg-dark-900/50 hover:border-dark-700/50', )} > - {/* Sub-options */} {isSelected && hasSubOptions && (
@@ -394,7 +513,7 @@ function PaymentMethodCard({ 'rounded-xl px-4 py-2 text-sm font-medium transition-all duration-200', selectedSubOption === opt.id ? 'bg-accent-500 text-white shadow-sm shadow-accent-500/25' - : 'bg-dark-800/50 text-dark-300 hover:bg-dark-700/50 hover:text-dark-100', + : 'bg-dark-800/50 text-dark-300 hover:bg-dark-700/50', )} > {opt.name} @@ -407,246 +526,19 @@ function PaymentMethodCard({ ); } -function RecipientSection({ value, onChange }: { value: string; onChange: (v: string) => void }) { - const { t } = useTranslation(); - - return ( -
-
- - onChange(e.target.value)} - placeholder={t('gift.recipientPlaceholder', 'email@example.com or @telegram')} - className="w-full rounded-xl border border-dark-700/50 bg-dark-800/50 px-4 py-3 text-sm text-dark-50 placeholder-dark-500 outline-none transition-colors focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/25" - /> -

- {t( - 'gift.recipientHint', - 'Enter the email or Telegram username of the person you want to gift', - )} -

-
-
- ); -} - -function GiftMessageSection({ value, onChange }: { value: string; onChange: (v: string) => void }) { - const { t } = useTranslation(); - - return ( - - -
- -