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 { usePlatform } from '../platform'; 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 } 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'; /** 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 [deleteLoading, setDeleteLoading] = useState(false); const { platform } = usePlatform(); 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 query const { data: deviceReductionInfo } = useQuery({ queryKey: ['device-reduction-info', subscriptionId], queryFn: () => subscriptionApi.getDeviceReductionInfo(subscriptionId), enabled: showDeviceReduction && !!subscription, }); // Initialize target device limit when reduction info loads useEffect(() => { if (deviceReductionInfo && showDeviceReduction) { setTargetDeviceLimit( Math.max( deviceReductionInfo.min_device_limit, deviceReductionInfo.current_device_limit - 1, ), ); } }, [deviceReductionInfo, showDeviceReduction]); // Device reduction mutation const deviceReductionMutation = useMutation({ mutationFn: () => subscriptionApi.reduceDevices(targetDeviceLimit, subscriptionId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] }); queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] }); queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] }); queryClient.invalidateQueries({ queryKey: ['device-reduction-info', subscriptionId] }); setShowDeviceReduction(false); }, }); // Traffic packages query const { data: trafficPackages } = useQuery({ queryKey: ['traffic-packages', subscriptionId], queryFn: () => subscriptionApi.getTrafficPackages(subscriptionId), enabled: showTrafficTopup && !!subscription, }); // Traffic purchase mutation const trafficPurchaseMutation = useMutation({ mutationFn: (gb: number) => subscriptionApi.purchaseTraffic(gb, subscriptionId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] }); queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] }); queryClient.invalidateQueries({ queryKey: ['balance'] }); queryClient.invalidateQueries({ queryKey: ['traffic-packages', subscriptionId] }); setShowTrafficTopup(false); setSelectedTrafficPackage(null); }, }); // Countries/servers query const { data: countriesData, isLoading: countriesLoading } = useQuery({ queryKey: ['countries', subscriptionId], queryFn: () => subscriptionApi.getCountries(subscriptionId), enabled: showServerManagement && !!subscription && !subscription.is_trial, }); // Initialize selected servers when data loads useEffect(() => { if (countriesData && showServerManagement) { const connected = countriesData.countries.filter((c) => c.is_connected).map((c) => c.uuid); setSelectedServersToUpdate(connected); } }, [countriesData, showServerManagement]); // Countries update mutation const updateCountriesMutation = useMutation({ mutationFn: (countries: string[]) => subscriptionApi.updateCountries(countries, subscriptionId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] }); queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] }); queryClient.invalidateQueries({ queryKey: ['countries', subscriptionId] }); setShowServerManagement(false); }, }); // 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 (
{/* Trial shimmer border */} {subscription.is_trial && (