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

@@ -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>
)}