From f7cc445127e010a8e1c1dd471459e87a85996cf5 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 4 May 2026 17:22:05 +0300 Subject: [PATCH] feat: add subscription reissue button with cooldown timer - Add revokeSubscription API method - Add reissue button in Additional Options with amber styling, destructive confirm dialog, 15-min cooldown with countdown timer, localStorage persistence across page reloads - Add i18n keys in all 4 locales (en, ru, zh, fa) --- src/api/subscription.ts | 11 +++++ src/locales/en.json | 8 ++++ src/locales/fa.json | 8 ++++ src/locales/ru.json | 8 ++++ src/locales/zh.json | 8 ++++ src/pages/Subscription.tsx | 95 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 138 insertions(+) diff --git a/src/api/subscription.ts b/src/api/subscription.ts index 2af14bf..832954d 100644 --- a/src/api/subscription.ts +++ b/src/api/subscription.ts @@ -540,6 +540,17 @@ export const subscriptionApi = { return response.data; }, + // ── Revoke (reissue) ──────────────────────────────────────────────── + + revokeSubscription: async (subscriptionId?: number) => { + const response = await apiClient.post( + '/cabinet/subscription/revoke', + undefined, + withSubId(subscriptionId), + ); + return response.data; + }, + // ── Daily subscription ────────────────────────────────────────────── togglePause: async ( diff --git a/src/locales/en.json b/src/locales/en.json index 37af017..09e9cf5 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -571,6 +571,14 @@ "selectServersHint": "Select servers to connect or disconnect", "noServersAvailable": "No servers available to manage" }, + "revoke": { + "title": "Reissue Subscription", + "button": "Reissue Subscription", + "description": "New link, device reset", + "warning": "This will generate a new connection link, reset all connected devices, and invalidate the old link. Continue?", + "confirmBtn": "Reissue", + "cooldown": "Available in {{minutes}} min {{seconds}} sec" + }, "legacy": { "selectTariffTitle": "Select a tariff to renew", "selectTariffDescription": "Your current subscription was created before tariffs were introduced. To renew, please select one of the available tariffs.", diff --git a/src/locales/fa.json b/src/locales/fa.json index bba6774..0b50443 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -402,6 +402,14 @@ "selectServersHint": "سرورها را برای اتصال یا قطع انتخاب کنید", "noServersAvailable": "سرور قابل مدیریتی وجود ندارد" }, + "revoke": { + "title": "صدور مجدد اشتراک", + "button": "صدور مجدد اشتراک", + "description": "لینک جدید، بازنشانی دستگاه‌ها", + "warning": "این عمل لینک اتصال جدیدی تولید می‌کند، همه دستگاه‌های متصل را بازنشانی می‌کند و لینک قدیمی را نامعتبر می‌کند. ادامه؟", + "confirmBtn": "صدور مجدد", + "cooldown": "در {{minutes}} دقیقه {{seconds}} ثانیه دیگر" + }, "legacy": { "selectTariffTitle": "یک تعرفه برای تمدید انتخاب کنید", "selectTariffDescription": "اشتراک فعلی شما قبل از معرفی تعرفه‌ها ایجاد شده بود. برای تمدید، لطفاً یکی از تعرفه‌های موجود را انتخاب کنید.", diff --git a/src/locales/ru.json b/src/locales/ru.json index 61e906f..f108714 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -599,6 +599,14 @@ "selectServersHint": "Выберите серверы для подключения или отключения", "noServersAvailable": "Нет доступных серверов для управления" }, + "revoke": { + "title": "Перевыпуск подписки", + "button": "Перевыпустить подписку", + "description": "Новая ссылка, сброс устройств", + "warning": "Это действие сгенерирует новую ссылку подключения, сбросит все подключённые устройства и сделает старую ссылку недействительной. Продолжить?", + "confirmBtn": "Перевыпустить", + "cooldown": "Доступно через {{minutes}} мин. {{seconds}} сек." + }, "legacy": { "selectTariffTitle": "Выберите тариф для продления", "selectTariffDescription": "Ваша текущая подписка была создана до введения тарифов. Для продления необходимо выбрать один из доступных тарифов.", diff --git a/src/locales/zh.json b/src/locales/zh.json index 83fd5ab..852dda9 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -402,6 +402,14 @@ "selectServersHint": "选择要连接或断开的服务器", "noServersAvailable": "没有可管理的服务器" }, + "revoke": { + "title": "重新签发订阅", + "button": "重新签发订阅", + "description": "新链接,设备重置", + "warning": "此操作将生成新的连接链接,重置所有已连接设备,并使旧链接失效。继续?", + "confirmBtn": "重新签发", + "cooldown": "{{minutes}}分{{seconds}}秒后可用" + }, "legacy": { "selectTariffTitle": "选择续费套餐", "selectTariffDescription": "您的当前订阅是在引入套餐之前创建的。要续费,请选择一个可用的套餐。", diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 4b42ae6..2c4c22a 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -202,6 +202,9 @@ export default function Subscription() { // Traffic refresh state const [trafficRefreshCooldown, setTrafficRefreshCooldown] = useState(0); + + // Revoke (reissue) cooldown state + const [revokeCooldown, setRevokeCooldown] = useState(0); const [trafficData, setTrafficData] = useState<{ traffic_used_gb: number; traffic_used_percent: number; @@ -464,6 +467,41 @@ export default function Subscription() { return () => clearInterval(timer); }, [trafficRefreshCooldown]); + // Initialize revoke cooldown from localStorage on mount + useEffect(() => { + const ts = localStorage.getItem(`revoke_ts_${subscriptionId ?? 'default'}`); + if (ts) { + const elapsed = Math.floor((Date.now() - parseInt(ts, 10)) / 1000); + const remaining = Math.max(0, 900 - elapsed); + setRevokeCooldown(remaining); + } + }, [subscriptionId]); + + // Countdown timer for revoke cooldown + useEffect(() => { + if (revokeCooldown <= 0) return; + const timer = setInterval(() => { + setRevokeCooldown((prev) => Math.max(0, prev - 1)); + }, 1000); + return () => clearInterval(timer); + }, [revokeCooldown]); + + // Revoke (reissue) subscription mutation + const revokeMutation = useMutation({ + mutationFn: () => subscriptionApi.revokeSubscription(subscriptionId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['subscription'] }); + queryClient.invalidateQueries({ queryKey: ['connection-link', subscriptionId] }); + queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] }); + haptic.notification('success'); + localStorage.setItem(`revoke_ts_${subscriptionId ?? 'default'}`, Date.now().toString()); + setRevokeCooldown(900); + }, + onError: () => { + haptic.notification('error'); + }, + }); + // Auto-refresh traffic on mount (with 30s caching) useEffect(() => { if (!subscription) return; @@ -494,6 +532,16 @@ export default function Subscription() { } }; + const handleRevoke = async () => { + const confirmed = await destructiveConfirm( + t('subscription.revoke.warning'), + t('subscription.revoke.confirmBtn'), + t('subscription.revoke.title'), + ); + if (!confirmed) return; + revokeMutation.mutate(); + }; + // In multi-tariff mode without a specific subscription ID, redirect to list if (isMultiTariff && !subscriptionId && !isLoading) { return ; @@ -2244,6 +2292,53 @@ export default function Subscription() { )} )} + + {/* Reissue Subscription */} +
+ + {revokeMutation.error && ( +

{getErrorMessage(revokeMutation.error)}

+ )} +
)}