mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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:
@@ -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 (
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -402,6 +402,14 @@
|
||||
"selectServersHint": "سرورها را برای اتصال یا قطع انتخاب کنید",
|
||||
"noServersAvailable": "سرور قابل مدیریتی وجود ندارد"
|
||||
},
|
||||
"revoke": {
|
||||
"title": "صدور مجدد اشتراک",
|
||||
"button": "صدور مجدد اشتراک",
|
||||
"description": "لینک جدید، بازنشانی دستگاهها",
|
||||
"warning": "این عمل لینک اتصال جدیدی تولید میکند، همه دستگاههای متصل را بازنشانی میکند و لینک قدیمی را نامعتبر میکند. ادامه؟",
|
||||
"confirmBtn": "صدور مجدد",
|
||||
"cooldown": "در {{minutes}} دقیقه {{seconds}} ثانیه دیگر"
|
||||
},
|
||||
"legacy": {
|
||||
"selectTariffTitle": "یک تعرفه برای تمدید انتخاب کنید",
|
||||
"selectTariffDescription": "اشتراک فعلی شما قبل از معرفی تعرفهها ایجاد شده بود. برای تمدید، لطفاً یکی از تعرفههای موجود را انتخاب کنید.",
|
||||
|
||||
@@ -599,6 +599,14 @@
|
||||
"selectServersHint": "Выберите серверы для подключения или отключения",
|
||||
"noServersAvailable": "Нет доступных серверов для управления"
|
||||
},
|
||||
"revoke": {
|
||||
"title": "Перевыпуск подписки",
|
||||
"button": "Перевыпустить подписку",
|
||||
"description": "Новая ссылка, сброс устройств",
|
||||
"warning": "Это действие сгенерирует новую ссылку подключения, сбросит все подключённые устройства и сделает старую ссылку недействительной. Продолжить?",
|
||||
"confirmBtn": "Перевыпустить",
|
||||
"cooldown": "Доступно через {{minutes}} мин. {{seconds}} сек."
|
||||
},
|
||||
"legacy": {
|
||||
"selectTariffTitle": "Выберите тариф для продления",
|
||||
"selectTariffDescription": "Ваша текущая подписка была создана до введения тарифов. Для продления необходимо выбрать один из доступных тарифов.",
|
||||
|
||||
@@ -402,6 +402,14 @@
|
||||
"selectServersHint": "选择要连接或断开的服务器",
|
||||
"noServersAvailable": "没有可管理的服务器"
|
||||
},
|
||||
"revoke": {
|
||||
"title": "重新签发订阅",
|
||||
"button": "重新签发订阅",
|
||||
"description": "新链接,设备重置",
|
||||
"warning": "此操作将生成新的连接链接,重置所有已连接设备,并使旧链接失效。继续?",
|
||||
"confirmBtn": "重新签发",
|
||||
"cooldown": "{{minutes}}分{{seconds}}秒后可用"
|
||||
},
|
||||
"legacy": {
|
||||
"selectTariffTitle": "选择续费套餐",
|
||||
"selectTariffDescription": "您的当前订阅是在引入套餐之前创建的。要续费,请选择一个可用的套餐。",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user