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)
This commit is contained in:
Fringg
2026-05-04 17:22:05 +03:00
parent 7d940091fa
commit f7cc445127
6 changed files with 138 additions and 0 deletions

View File

@@ -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 (

View File

@@ -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.",

View File

@@ -402,6 +402,14 @@
"selectServersHint": "سرورها را برای اتصال یا قطع انتخاب کنید",
"noServersAvailable": "سرور قابل مدیریتی وجود ندارد"
},
"revoke": {
"title": "صدور مجدد اشتراک",
"button": "صدور مجدد اشتراک",
"description": "لینک جدید، بازنشانی دستگاه‌ها",
"warning": "این عمل لینک اتصال جدیدی تولید می‌کند، همه دستگاه‌های متصل را بازنشانی می‌کند و لینک قدیمی را نامعتبر می‌کند. ادامه؟",
"confirmBtn": "صدور مجدد",
"cooldown": "در {{minutes}} دقیقه {{seconds}} ثانیه دیگر"
},
"legacy": {
"selectTariffTitle": "یک تعرفه برای تمدید انتخاب کنید",
"selectTariffDescription": "اشتراک فعلی شما قبل از معرفی تعرفه‌ها ایجاد شده بود. برای تمدید، لطفاً یکی از تعرفه‌های موجود را انتخاب کنید.",

View File

@@ -599,6 +599,14 @@
"selectServersHint": "Выберите серверы для подключения или отключения",
"noServersAvailable": "Нет доступных серверов для управления"
},
"revoke": {
"title": "Перевыпуск подписки",
"button": "Перевыпустить подписку",
"description": "Новая ссылка, сброс устройств",
"warning": "Это действие сгенерирует новую ссылку подключения, сбросит все подключённые устройства и сделает старую ссылку недействительной. Продолжить?",
"confirmBtn": "Перевыпустить",
"cooldown": "Доступно через {{minutes}} мин. {{seconds}} сек."
},
"legacy": {
"selectTariffTitle": "Выберите тариф для продления",
"selectTariffDescription": "Ваша текущая подписка была создана до введения тарифов. Для продления необходимо выбрать один из доступных тарифов.",

View File

@@ -402,6 +402,14 @@
"selectServersHint": "选择要连接或断开的服务器",
"noServersAvailable": "没有可管理的服务器"
},
"revoke": {
"title": "重新签发订阅",
"button": "重新签发订阅",
"description": "新链接,设备重置",
"warning": "此操作将生成新的连接链接,重置所有已连接设备,并使旧链接失效。继续?",
"confirmBtn": "重新签发",
"cooldown": "{{minutes}}分{{seconds}}秒后可用"
},
"legacy": {
"selectTariffTitle": "选择续费套餐",
"selectTariffDescription": "您的当前订阅是在引入套餐之前创建的。要续费,请选择一个可用的套餐。",

View File

@@ -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 <Navigate to="/subscriptions" replace />;
@@ -2244,6 +2292,53 @@ export default function Subscription() {
)}
</div>
)}
{/* Reissue Subscription */}
<div className="mt-4">
<button
onClick={handleRevoke}
disabled={revokeMutation.isPending || revokeCooldown > 0}
className="w-full rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-left transition-colors hover:bg-amber-500/20 disabled:opacity-50"
>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-amber-400">
{t('subscription.revoke.button')}
</div>
<div className="mt-1 text-sm text-dark-400">
{revokeCooldown > 0
? t('subscription.revoke.cooldown', {
minutes: Math.floor(revokeCooldown / 60),
seconds: revokeCooldown % 60,
})
: t('subscription.revoke.description')}
</div>
</div>
<div className="text-amber-400">
{revokeMutation.isPending ? (
<div className="h-5 w-5 animate-spin rounded-full border-2 border-amber-400/30 border-t-amber-400" />
) : (
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182"
/>
</svg>
)}
</div>
</div>
</button>
{revokeMutation.error && (
<p className="mt-2 text-sm text-red-400">{getErrorMessage(revokeMutation.error)}</p>
)}
</div>
</div>
)}