import { useState, useEffect, useRef, useCallback, useMemo, memo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { Navigate, useNavigate, useParams } from 'react-router'; import { subscriptionApi } from '../api/subscription'; import { DEVICE_ALIAS_MAX_LENGTH } from '../constants/devices'; import { WebBackButton } from '../components/WebBackButton'; import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog'; import TrafficProgressBar from '../components/dashboard/TrafficProgressBar'; import { HoverBorderGradient } from '../components/ui/hover-border-gradient'; import { useTrafficZone } from '../hooks/useTrafficZone'; import { formatTraffic } from '../utils/formatTraffic'; import { getGlassColors } from '../utils/glassTheme'; import { copyToClipboard } from '../utils/clipboard'; import { useTheme } from '../hooks/useTheme'; import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt'; import { useCurrency } from '../hooks/useCurrency'; import { useCloseOnSuccessNotification } from '../store/successNotification'; import PurchaseCTAButton from '../components/subscription/PurchaseCTAButton'; import { CopyIcon, CheckIcon, PauseIcon, CalendarIcon, RefreshIcon, DevicesIcon, DownloadIcon, TrashIcon, } from '../components/icons'; import { useHaptic } from '../platform'; import { resolveConnectionUrlForUi } from '../utils/connectionLink'; import { getErrorMessage, getInsufficientBalanceError, getFlagEmoji, } from '../utils/subscriptionHelpers'; import Twemoji from 'react-twemoji'; import { DeviceTopupSheet } from '../components/subscription/sheets/DeviceTopupSheet'; import { DeviceReductionSheet } from '../components/subscription/sheets/DeviceReductionSheet'; import { TrafficTopupSheet } from '../components/subscription/sheets/TrafficTopupSheet'; import { ServerManagementSheet } from '../components/subscription/sheets/ServerManagementSheet'; import { DeleteSubscriptionSheet } from '../components/subscription/sheets/DeleteSubscriptionSheet'; /** Isolated countdown so 1s interval doesn't re-render the whole page */ const CountdownTimer = memo(function CountdownTimer({ endDate, isActive, glassColors: g, }: { endDate: string; isActive: boolean; glassColors: ReturnType; }) { const { t } = useTranslation(); const [countdown, setCountdown] = useState({ days: 0, hours: 0, minutes: 0, seconds: 0 }); useEffect(() => { const endTime = new Date(endDate).getTime(); const tick = () => { const diff = Math.max(0, endTime - Date.now()); setCountdown({ days: Math.floor(diff / 86_400_000), hours: Math.floor((diff % 86_400_000) / 3_600_000), minutes: Math.floor((diff % 3_600_000) / 60_000), seconds: Math.floor((diff % 60_000) / 1_000), }); }; tick(); const id = setInterval(tick, 1000); return () => clearInterval(id); }, [endDate]); const isExpired = !isActive; const isUrgent = countdown.days <= 3; const formattedDate = new Date(endDate).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric', }); return (
{t('dashboard.remaining')}
{isExpired ? (
{t('subscription.expired')}
) : (
{countdown.days > 0 && ( <> {countdown.days} {t('subscription.daysShort')} )} {String(countdown.hours).padStart(2, '0')} : {String(countdown.minutes).padStart(2, '0')} : {String(countdown.seconds).padStart(2, '0')}
{t('subscription.expiresAt')}: {formattedDate}
)}
); }); export default function Subscription() { const { t } = useTranslation(); const queryClient = useQueryClient(); const { formatAmount, currencySymbol } = useCurrency(); const navigate = useNavigate(); const { subscriptionId: subIdParam } = useParams<{ subscriptionId?: string }>(); const subscriptionId = subIdParam ? parseInt(subIdParam, 10) : undefined; const { isDark } = useTheme(); const g = getGlassColors(isDark); const haptic = useHaptic(); const [copied, setCopied] = useState(false); const [showDeleteSheet, setShowDeleteSheet] = useState(false); const destructiveConfirm = useDestructiveConfirm(); // Helper to format price from kopeks const formatPrice = (kopeks: number) => kopeks === 0 ? t('subscription.free', 'Бесплатно') : `${formatAmount(kopeks / 100)} ${currencySymbol}`; // Device/traffic topup state const [showDeviceTopup, setShowDeviceTopup] = useState(false); const [devicesToAdd, setDevicesToAdd] = useState(1); const [showDeviceReduction, setShowDeviceReduction] = useState(false); const [targetDeviceLimit, setTargetDeviceLimit] = useState(1); const [showTrafficTopup, setShowTrafficTopup] = useState(false); const [selectedTrafficPackage, setSelectedTrafficPackage] = useState(null); const [showServerManagement, setShowServerManagement] = useState(false); const [selectedServersToUpdate, setSelectedServersToUpdate] = useState([]); // 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; is_unlimited: boolean; } | null>(null); // Detect multi-tariff mode from cached subscriptions-list const { data: multiSubData } = useQuery({ queryKey: ['subscriptions-list'], queryFn: () => subscriptionApi.getSubscriptions(), staleTime: 60_000, }); const isMultiTariff = multiSubData?.multi_tariff_enabled ?? false; const { data: subscriptionResponse, isLoading } = useQuery({ queryKey: ['subscription', subscriptionId], queryFn: () => subscriptionApi.getSubscription(subscriptionId), retry: false, staleTime: 0, refetchOnMount: 'always', }); const { data: connectionLink, isLoading: isConnectionLinkLoading } = useQuery({ queryKey: ['connection-link', subscriptionId], queryFn: () => subscriptionApi.getConnectionLink(subscriptionId), retry: false, staleTime: 0, }); // Extract subscription from response (null if no subscription) const subscription = subscriptionResponse?.subscription ?? null; const displayedConnectionUrl = useMemo( () => resolveConnectionUrlForUi({ mode: connectionLink?.connect_mode, happSchemeLink: connectionLink?.happ_scheme_link, displayLink: connectionLink?.display_link, subscriptionUrl: connectionLink?.subscription_url, happCryptLink: connectionLink?.happ_cryptolink, happCryptoLink: connectionLink?.happ_crypto_link, happLink: connectionLink?.happ_link, fallbackUrl: isConnectionLinkLoading ? null : (subscription?.subscription_url ?? null), }), [ connectionLink?.connect_mode, connectionLink?.display_link, connectionLink?.happ_cryptolink, connectionLink?.happ_crypto_link, connectionLink?.happ_link, connectionLink?.happ_scheme_link, connectionLink?.subscription_url, isConnectionLinkLoading, subscription?.subscription_url, ], ); const shouldHideConnectionLink = subscription?.hide_subscription_link || connectionLink?.hide_link; // Traffic zone (theme-aware) — called unconditionally at top level const usedPercent = trafficData?.traffic_used_percent ?? subscription?.traffic_used_percent ?? 0; const zone = useTrafficZone(usedPercent); // Purchase options (needed for balance_kopeks in device/traffic/server management) const { data: purchaseOptions } = useQuery({ queryKey: ['purchase-options', subscriptionId], queryFn: () => subscriptionApi.getPurchaseOptions(subscriptionId), staleTime: 0, refetchOnMount: 'always', }); const isTariffsMode = purchaseOptions?.sales_mode === 'tariffs'; const autopayMutation = useMutation({ mutationFn: (enabled: boolean) => subscriptionApi.updateAutopay(enabled, undefined, subscriptionId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] }); queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] }); }, }); // Devices query const { data: devicesData, isLoading: devicesLoading } = useQuery({ queryKey: ['devices', subscriptionId], queryFn: () => subscriptionApi.getDevices(subscriptionId), enabled: !!subscription, }); // Delete device mutation const deleteDeviceMutation = useMutation({ mutationFn: (hwid: string) => subscriptionApi.deleteDevice(hwid, subscriptionId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] }); }, }); // Delete all devices mutation const deleteAllDevicesMutation = useMutation({ mutationFn: () => subscriptionApi.deleteAllDevices(subscriptionId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] }); }, }); // Local device alias (rename) state. Only one device can be in edit-mode // at a time — `editingDeviceHwid` doubles as both the toggle and the // identifier of the row being edited. const [editingDeviceHwid, setEditingDeviceHwid] = useState(null); const [editingDeviceName, setEditingDeviceName] = useState(''); const renameDeviceMutation = useMutation({ mutationFn: ({ hwid, name }: { hwid: string; name: string | null }) => subscriptionApi.renameDevice(hwid, name, subscriptionId), onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] }); // Soft success-tap, like other mutations on this page. haptic.notification('success'); // Не сбрасываем edit-state, если пользователь уже перешёл на другой // девайс пока шёл запрос — иначе теряем его новый input. Имя не чистим // безусловно: оно либо принадлежит уже другому девайсу (нужно сохранить), // либо инпут уже закрылся (значение не отображается). setEditingDeviceHwid((current) => (current === variables.hwid ? null : current)); }, onError: () => { haptic.notification('error'); }, }); // Pause subscription mutation const pauseMutation = useMutation({ mutationFn: () => subscriptionApi.togglePause(subscriptionId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] }); queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] }); queryClient.invalidateQueries({ queryKey: ['balance'] }); }, }); // Auto-close all modals/forms when success notification appears const handleCloseAllModals = useCallback(() => { setShowDeviceTopup(false); setShowDeviceReduction(false); setShowTrafficTopup(false); setShowServerManagement(false); }, []); useCloseOnSuccessNotification(handleCloseAllModals); // (device price + purchase moved into ) // (device reduction info + mutation moved into ) // (traffic packages + purchase moved into ) // (countries query + update mutation moved into ) // Traffic refresh mutation const refreshTrafficMutation = useMutation({ mutationFn: () => subscriptionApi.refreshTraffic(subscriptionId), onSuccess: (data) => { setTrafficData({ traffic_used_gb: data.traffic_used_gb, traffic_used_percent: data.traffic_used_percent, is_unlimited: data.is_unlimited, }); localStorage.setItem( `traffic_refresh_ts_${subscriptionId ?? 'default'}`, Date.now().toString(), ); if (data.rate_limited && data.retry_after_seconds) { setTrafficRefreshCooldown(data.retry_after_seconds); } else { setTrafficRefreshCooldown(30); } queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] }); }, onError: (error: { response?: { status?: number; headers?: { get?: (key: string) => string } }; }) => { if (error.response?.status === 429) { const retryAfter = error.response.headers?.get?.('Retry-After'); setTrafficRefreshCooldown(retryAfter ? parseInt(retryAfter, 10) : 30); } }, }); // Track if we've already triggered auto-refresh this session const hasAutoRefreshed = useRef(false); // Cooldown timer for traffic refresh useEffect(() => { if (trafficRefreshCooldown <= 0) return; const timer = setInterval(() => { setTrafficRefreshCooldown((prev) => Math.max(0, prev - 1)); }, 1000); 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'] }); // Remnawave resets device HWIDs on revoke — make sure the cabinet // re-reads the now-empty device list instead of showing the stale cache. queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] }); 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; if (hasAutoRefreshed.current) return; hasAutoRefreshed.current = true; const lastRefresh = localStorage.getItem(`traffic_refresh_ts_${subscriptionId ?? 'default'}`); const now = Date.now(); const cacheMs = 30 * 1000; if (lastRefresh && now - parseInt(lastRefresh, 10) < cacheMs) { const elapsed = now - parseInt(lastRefresh, 10); const remaining = Math.ceil((cacheMs - elapsed) / 1000); if (remaining > 0) { setTrafficRefreshCooldown(remaining); } return; } refreshTrafficMutation.mutate(); }, [subscription, refreshTrafficMutation, subscriptionId]); const copyUrl = () => { if (displayedConnectionUrl) { void copyToClipboard(displayedConnectionUrl); setCopied(true); setTimeout(() => setCopied(false), 2000); } }; 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 ; } if (isLoading) { return (
); } if (!subscription && subscriptionId) { return (
😕

{t('subscription.notFound', 'Подписка не найдена')}

{t('subscription.notFoundDesc', 'Возможно, подписка была удалена или не существует')}

); } return (
{/* Page title */}

{isMultiTariff && subscription?.tariff_name ? subscription.tariff_name : t('subscription.title')}

{/* Current Subscription */} {subscription ? ( (() => { const usedGb = trafficData?.traffic_used_gb ?? subscription.traffic_used_gb; const isUnlimited = (trafficData?.is_unlimited ?? false) || subscription.traffic_limit_gb === 0; const connectedDevices = devicesData?.total ?? 0; const isAtDeviceLimit = subscription.device_limit > 0 && connectedDevices >= subscription.device_limit; return (
{/* Decorative ambient radial + trial shimmer border were removed: they carried no information, leaked zone/accent hue into pure decoration (violates DESIGN.md Tunable-but-Scarce + Status-Hue Lockout rules), and the same chrome was distilled out of SubscriptionCardActive earlier in this branch. Trial state is conveyed by the header badge. */} {/* ─── Header ─── */}
{/* Zone indicator */}
{/* Plan name */}

{subscription.tariff_name || t('subscription.currentPlan')}

{/* Status badge */} {subscription.is_active ? subscription.is_trial ? t('subscription.trialStatus') : t('subscription.active') : subscription.is_limited ? t('subscription.trafficLimited') : subscription.status === 'disabled' ? t('subscription.pause.suspended') : t('subscription.expired')}
{/* ─── Traffic Limited Banner ─── */} {subscription.is_limited && (

{t('subscription.trafficLimitedTitle')}

{t('subscription.trafficLimitedDescription')}

)} {/* ─── Trial Info Banner ─── */} {subscription.is_trial && subscription.is_active && (
{t('subscription.trialInfo.title')}
{t('subscription.trialInfo.description')}
{subscription.days_left > 0 ? t('subscription.days', { count: subscription.days_left }) : `${subscription.hours_left}${t('subscription.hours')} ${subscription.minutes_left}${t('subscription.minutes')}`} {t('subscription.trialInfo.remaining')}
{subscription.traffic_limit_gb || '∞'} {t('common.units.gb')} {t('subscription.traffic')}
{subscription.device_limit === 0 ? '∞' : subscription.device_limit} {t('subscription.devices')}
)} {/* ─── Traffic Progress ─── */}
{t('subscription.traffic')}
{isUnlimited ? formatTraffic(usedGb) : `${formatTraffic(usedGb)} / ${formatTraffic(subscription.traffic_limit_gb)}`}
{subscription.traffic_reset_mode && subscription.traffic_reset_mode !== 'NO_RESET' && (
{t(`subscription.trafficReset.${subscription.traffic_reset_mode}`)}
)}
{/* ─── Connect Device Button ─── */} {subscription.subscription_url && ( { if (isAtDeviceLimit) { haptic.notification('error'); return; } navigate(subscriptionId ? `/connection?sub=${subscriptionId}` : '/connection'); }} className={`mb-5 flex w-full items-center gap-3.5 rounded-[14px] p-3.5 text-left transition-shadow duration-300${isAtDeviceLimit ? 'cursor-not-allowed opacity-50' : ''}`} style={{ fontFamily: 'inherit' }} >
{t('dashboard.connectDevice')}
{subscription.device_limit === 0 ? t('dashboard.devicesConnectedUnlimited', { used: connectedDevices }) : t('dashboard.devicesOfMax', { used: connectedDevices, max: subscription.device_limit, })}
{isAtDeviceLimit && (
{t('dashboard.deviceLimitReached')}
)}
{subscription.device_limit === 0 ? ( ) : subscription.device_limit <= 10 ? (