import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { useLocation, useNavigate } from 'react-router'; import { AxiosError } from 'axios'; import { subscriptionApi } from '../api/subscription'; import { promoApi } from '../api/promo'; import TrafficProgressBar from '../components/dashboard/TrafficProgressBar'; import { getTrafficZone } from '../utils/trafficZone'; import { formatTraffic } from '../utils/formatTraffic'; import { getGlassColors } from '../utils/glassTheme'; import { useTheme } from '../hooks/useTheme'; import { HoverBorderGradient } from '../components/ui/hover-border-gradient'; import type { PurchaseSelection, PeriodOption, Tariff, TariffPeriod, ClassicPurchaseOptions, } from '../types'; import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt'; import { useCurrency } from '../hooks/useCurrency'; import { useCloseOnSuccessNotification } from '../store/successNotification'; import i18n from '../i18n'; // Helper to extract error message from axios/api errors const getErrorMessage = (error: unknown): string => { if (error instanceof AxiosError) { const detail = error.response?.data?.detail; if (typeof detail === 'string') return detail; if (typeof detail === 'object' && detail?.message) return detail.message; } if (error instanceof Error) return error.message; return i18n.t('common.error'); }; // Helper to extract insufficient balance error details const getInsufficientBalanceError = ( error: unknown, ): { required: number; balance: number; missingAmount?: number } | null => { if (error instanceof AxiosError) { const detail = error.response?.data?.detail; // Support both 'insufficient_balance' and 'insufficient_funds' codes if ( typeof detail === 'object' && (detail?.code === 'insufficient_balance' || detail?.code === 'insufficient_funds') ) { return { required: detail.required || detail.total_price || 0, balance: detail.balance || 0, missingAmount: detail.missing_amount || detail.missingAmount || 0, }; } } return null; }; // Icons const CopyIcon = () => ( ); const CheckIcon = () => ( ); // Convert country code to flag emoji const getFlagEmoji = (countryCode: string): string => { if (!countryCode || countryCode.length !== 2) return ''; const codePoints = countryCode .toUpperCase() .split('') .map((char) => 127397 + char.charCodeAt(0)); return String.fromCodePoint(...codePoints); }; type PurchaseStep = 'period' | 'traffic' | 'servers' | 'devices' | 'confirm'; export default function Subscription() { const { t } = useTranslation(); const queryClient = useQueryClient(); const location = useLocation(); const navigate = useNavigate(); const { formatAmount, currencySymbol } = useCurrency(); const { isDark } = useTheme(); const g = getGlassColors(isDark); const [copied, setCopied] = useState(false); // Helper to format price from kopeks const formatPrice = (kopeks: number) => `${formatAmount(kopeks / 100)} ${currencySymbol}`; // Helper to apply promo discount to a price, stacking with existing promo group discount const applyPromoDiscount = ( priceKopeks: number, existingOriginalPrice?: number | null, ): { price: number; original: number | null; percent: number | null; isPromoGroup: boolean; } => { const hasExisting = (existingOriginalPrice ?? 0) > priceKopeks; const hasPromo = !!activeDiscount?.is_active && !!activeDiscount.discount_percent; if (!hasExisting && !hasPromo) { return { price: priceKopeks, original: null, percent: null, isPromoGroup: false }; } let finalPrice = priceKopeks; if (hasPromo) { finalPrice = Math.round(priceKopeks * (1 - activeDiscount!.discount_percent! / 100)); } if (hasExisting) { // Promo group discount exists — calculate combined percent from deepest original const combinedPercent = hasPromo ? Math.round((1 - finalPrice / existingOriginalPrice!) * 100) : Math.round((1 - priceKopeks / existingOriginalPrice!) * 100); return { price: finalPrice, original: existingOriginalPrice!, percent: combinedPercent, isPromoGroup: true, }; } // Only promo offer discount (no promo group) return { price: finalPrice, original: priceKopeks, percent: activeDiscount!.discount_percent!, isPromoGroup: false, }; }; // Purchase state (classic mode) const [currentStep, setCurrentStep] = useState('period'); const [selectedPeriod, setSelectedPeriod] = useState(null); const [selectedTraffic, setSelectedTraffic] = useState(null); const [selectedServers, setSelectedServers] = useState([]); const [selectedDevices, setSelectedDevices] = useState(1); const [showPurchaseForm, setShowPurchaseForm] = useState(false); // Tariffs mode state const [selectedTariff, setSelectedTariff] = useState(null); const [selectedTariffPeriod, setSelectedTariffPeriod] = useState(null); const [showTariffPurchase, setShowTariffPurchase] = useState(false); // Custom days/traffic state const [customDays, setCustomDays] = useState(30); const [customTrafficGb, setCustomTrafficGb] = useState(50); const [useCustomDays, setUseCustomDays] = useState(false); const [useCustomTraffic, setUseCustomTraffic] = useState(false); // 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); const [trafficData, setTrafficData] = useState<{ traffic_used_gb: number; traffic_used_percent: number; is_unlimited: boolean; } | null>(null); const { data: subscriptionResponse, isLoading } = useQuery({ queryKey: ['subscription'], queryFn: subscriptionApi.getSubscription, retry: false, staleTime: 0, // Always refetch to get latest data refetchOnMount: 'always', }); // Extract subscription from response (null if no subscription) const subscription = subscriptionResponse?.subscription ?? null; const { data: purchaseOptions, isLoading: optionsLoading } = useQuery({ queryKey: ['purchase-options'], queryFn: subscriptionApi.getPurchaseOptions, }); // Fetch active promo discount const { data: activeDiscount } = useQuery({ queryKey: ['active-discount'], queryFn: promoApi.getActiveDiscount, staleTime: 30000, }); // Check if in tariffs mode (moved up to be available for useEffect) const isTariffsMode = purchaseOptions?.sales_mode === 'tariffs'; const classicOptions = !isTariffsMode ? (purchaseOptions as ClassicPurchaseOptions) : null; const tariffs = isTariffsMode && purchaseOptions && 'tariffs' in purchaseOptions ? purchaseOptions.tariffs : []; // Get truly available servers for a given period (same filter as rendering) const getAvailableServers = useCallback( (period: PeriodOption | null) => { if (!period?.servers.options) return []; return period.servers.options.filter((server) => { if (!server.is_available) return false; if (subscription?.is_trial && server.name.toLowerCase().includes('trial')) return false; return true; }); }, [subscription?.is_trial], ); // Determine which steps are needed const steps = useMemo(() => { const result: PurchaseStep[] = ['period']; if (selectedPeriod?.traffic.selectable && (selectedPeriod.traffic.options?.length ?? 0) > 0) { result.push('traffic'); } const availableServers = getAvailableServers(selectedPeriod); // Skip server selection step if only 1 server available (auto-select it) if (availableServers.length > 1) { result.push('servers'); } if (selectedPeriod && selectedPeriod.devices.max > selectedPeriod.devices.min) { result.push('devices'); } result.push('confirm'); return result; }, [selectedPeriod, getAvailableServers]); const currentStepIndex = steps.indexOf(currentStep); const isFirstStep = currentStepIndex === 0; const isLastStep = currentStepIndex === steps.length - 1; // Initialize selection from options (classic mode only) useEffect(() => { if (classicOptions && !selectedPeriod) { const defaultPeriod = classicOptions.periods.find((p) => p.id === classicOptions.selection.period_id) || classicOptions.periods[0]; setSelectedPeriod(defaultPeriod); setSelectedTraffic(classicOptions.selection.traffic_value); const availableServers = getAvailableServers(defaultPeriod); const availableServerUuids = new Set(availableServers.map((s) => s.uuid)); // If only 1 server available, auto-select it (step will be skipped) if (availableServers.length === 1) { setSelectedServers([availableServers[0].uuid]); } else { setSelectedServers( classicOptions.selection.servers.filter((uuid) => availableServerUuids.has(uuid)), ); } setSelectedDevices(classicOptions.selection.devices); } }, [classicOptions, selectedPeriod, getAvailableServers]); // Build selection object const currentSelection: PurchaseSelection = useMemo( () => ({ period_id: selectedPeriod?.id, period_days: selectedPeriod?.period_days, traffic_value: selectedTraffic ?? undefined, servers: selectedServers, devices: selectedDevices, }), [selectedPeriod, selectedTraffic, selectedServers, selectedDevices], ); // Preview query const { data: preview, isLoading: previewLoading } = useQuery({ queryKey: ['purchase-preview', currentSelection], queryFn: () => subscriptionApi.previewPurchase(currentSelection), enabled: !!selectedPeriod && showPurchaseForm && currentStep === 'confirm', }); const purchaseMutation = useMutation({ mutationFn: () => subscriptionApi.submitPurchase(currentSelection), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['subscription'] }); queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); setShowPurchaseForm(false); setCurrentStep('period'); }, }); const autopayMutation = useMutation({ mutationFn: (enabled: boolean) => subscriptionApi.updateAutopay(enabled), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['subscription'] }); }, }); // Devices query const { data: devicesData, isLoading: devicesLoading } = useQuery({ queryKey: ['devices'], queryFn: subscriptionApi.getDevices, enabled: !!subscription, }); // Delete device mutation const deleteDeviceMutation = useMutation({ mutationFn: (hwid: string) => subscriptionApi.deleteDevice(hwid), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['devices'] }); }, }); // Delete all devices mutation const deleteAllDevicesMutation = useMutation({ mutationFn: () => subscriptionApi.deleteAllDevices(), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['devices'] }); }, }); // Pause subscription mutation const pauseMutation = useMutation({ mutationFn: () => subscriptionApi.togglePause(), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['subscription'] }); }, }); // Refs for auto-scroll const switchModalRef = useRef(null); const tariffPurchaseRef = useRef(null); const tariffsCardRef = useRef(null); // Tariff switch preview const [switchTariffId, setSwitchTariffId] = useState(null); // Auto-close all modals/forms when success notification appears (e.g., subscription purchased via WebSocket) const handleCloseAllModals = useCallback(() => { setShowPurchaseForm(false); setShowTariffPurchase(false); setShowDeviceTopup(false); setShowDeviceReduction(false); setShowTrafficTopup(false); setShowServerManagement(false); setSwitchTariffId(null); setSelectedTariff(null); setSelectedTariffPeriod(null); }, []); useCloseOnSuccessNotification(handleCloseAllModals); const { data: switchPreview, isLoading: switchPreviewLoading } = useQuery({ queryKey: ['tariff-switch-preview', switchTariffId], queryFn: () => subscriptionApi.previewTariffSwitch(switchTariffId!), enabled: !!switchTariffId, }); // Tariff switch mutation const switchTariffMutation = useMutation({ mutationFn: (tariffId: number) => subscriptionApi.switchTariff(tariffId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['subscription'] }); queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); setSwitchTariffId(null); }, onError: (error: unknown) => { // Handle subscription_expired error - redirect to purchase flow if (error instanceof AxiosError) { const detail = error.response?.data?.detail; if ( typeof detail === 'object' && detail?.error_code === 'subscription_expired' && detail?.use_purchase_flow === true ) { // Find the tariff user was trying to switch to and open purchase form const targetTariff = tariffs.find((t) => t.id === switchTariffId); if (targetTariff) { setSwitchTariffId(null); setSelectedTariff(targetTariff); setSelectedTariffPeriod(targetTariff.periods[0] || null); setShowTariffPurchase(true); // Refetch purchase-options to get updated expired status queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); } } } }, }); // Tariff purchase mutation const tariffPurchaseMutation = useMutation({ mutationFn: () => { if (!selectedTariff) { throw new Error('Tariff not selected'); } // For daily tariffs, always use 1 day const isDailyTariff = selectedTariff.is_daily || (selectedTariff.daily_price_kopeks && selectedTariff.daily_price_kopeks > 0); const days = isDailyTariff ? 1 : useCustomDays ? customDays : selectedTariffPeriod?.days || 30; const trafficGb = useCustomTraffic && selectedTariff.custom_traffic_enabled ? customTrafficGb : undefined; return subscriptionApi.purchaseTariff(selectedTariff.id, days, trafficGb); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['subscription'] }); queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); setShowTariffPurchase(false); setSelectedTariff(null); setSelectedTariffPeriod(null); setUseCustomDays(false); setUseCustomTraffic(false); }, }); // Device price query const { data: devicePriceData } = useQuery({ queryKey: ['device-price', devicesToAdd], queryFn: () => subscriptionApi.getDevicePrice(devicesToAdd), enabled: showDeviceTopup && !!subscription, }); // Device purchase mutation const devicePurchaseMutation = useMutation({ mutationFn: () => subscriptionApi.purchaseDevices(devicesToAdd), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['subscription'] }); queryClient.invalidateQueries({ queryKey: ['devices'] }); setShowDeviceTopup(false); setDevicesToAdd(1); }, }); // Device reduction info query const { data: deviceReductionInfo } = useQuery({ queryKey: ['device-reduction-info'], queryFn: subscriptionApi.getDeviceReductionInfo, 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), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['subscription'] }); queryClient.invalidateQueries({ queryKey: ['devices'] }); queryClient.invalidateQueries({ queryKey: ['device-reduction-info'] }); setShowDeviceReduction(false); }, }); // Traffic packages query const { data: trafficPackages } = useQuery({ queryKey: ['traffic-packages'], queryFn: subscriptionApi.getTrafficPackages, enabled: showTrafficTopup && !!subscription, }); // Traffic purchase mutation const trafficPurchaseMutation = useMutation({ mutationFn: (gb: number) => subscriptionApi.purchaseTraffic(gb), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['subscription'] }); setShowTrafficTopup(false); setSelectedTrafficPackage(null); }, }); // Countries/servers query const { data: countriesData, isLoading: countriesLoading } = useQuery({ queryKey: ['countries'], queryFn: subscriptionApi.getCountries, 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), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['subscription'] }); queryClient.invalidateQueries({ queryKey: ['countries'] }); setShowServerManagement(false); }, }); // Traffic refresh mutation const refreshTrafficMutation = useMutation({ mutationFn: subscriptionApi.refreshTraffic, 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', Date.now().toString()); if (data.rate_limited && data.retry_after_seconds) { setTrafficRefreshCooldown(data.retry_after_seconds); } else { setTrafficRefreshCooldown(30); } queryClient.invalidateQueries({ queryKey: ['subscription'] }); }, 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]); // 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'); 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]); // Auto-scroll to switch tariff modal when it appears useEffect(() => { if (switchTariffId && switchModalRef.current) { const timer = setTimeout(() => { switchModalRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 100); return () => clearTimeout(timer); } }, [switchTariffId]); // Auto-scroll to tariff purchase form when it appears useEffect(() => { if (showTariffPurchase && tariffPurchaseRef.current) { const timer = setTimeout(() => { tariffPurchaseRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 100); return () => clearTimeout(timer); } }, [showTariffPurchase]); // Auto-scroll to tariffs section when coming from Dashboard "Продлить" button useEffect(() => { const state = location.state as { scrollToExtend?: boolean } | null; // Wait for tariffs to load before scrolling if (state?.scrollToExtend && tariffsCardRef.current && tariffs.length > 0) { const timer = setTimeout(() => { tariffsCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 100); // Clear the state to prevent re-scrolling on subsequent renders window.history.replaceState({}, document.title); return () => clearTimeout(timer); } }, [location.state, tariffs.length]); const copyUrl = () => { if (subscription?.subscription_url) { navigator.clipboard.writeText(subscription.subscription_url); setCopied(true); setTimeout(() => setCopied(false), 2000); } }; const toggleServer = (uuid: string) => { if (selectedServers.includes(uuid)) { if (selectedServers.length > 1) { setSelectedServers(selectedServers.filter((s) => s !== uuid)); } } else { setSelectedServers([...selectedServers, uuid]); } }; const goToNextStep = () => { const nextIndex = currentStepIndex + 1; if (nextIndex < steps.length) { setCurrentStep(steps[nextIndex]); } }; const goToPrevStep = () => { const prevIndex = currentStepIndex - 1; if (prevIndex >= 0) { setCurrentStep(steps[prevIndex]); } }; const resetPurchase = () => { setShowPurchaseForm(false); setCurrentStep('period'); }; const getStepLabel = (step: PurchaseStep) => { switch (step) { case 'period': return t('subscription.stepPeriod'); case 'traffic': return t('subscription.stepTraffic'); case 'servers': return t('subscription.stepServers'); case 'devices': return t('subscription.stepDevices'); case 'confirm': return t('subscription.stepConfirm'); } }; if (isLoading || optionsLoading) { return (
); } return (

{t('subscription.title')}

{/* Current Subscription */} {subscription ? ( (() => { const usedPercent = trafficData?.traffic_used_percent ?? subscription.traffic_used_percent; const usedGb = trafficData?.traffic_used_gb ?? subscription.traffic_used_gb; const isUnlimited = (trafficData?.is_unlimited ?? false) || subscription.traffic_limit_gb === 0; const zone = getTrafficZone(usedPercent); const connectedDevices = devicesData?.total ?? 0; const formattedDate = new Date(subscription.end_date).toLocaleDateString(); const daysLeft = subscription.days_left; return (
{/* Trial shimmer border */} {subscription.is_trial && (