Files
bedolaga-cabinet/src/pages/Subscription.tsx
Fringg 70e1ed60bd feat: add animated gradient border to Connect Device buttons
Pure CSS conic-gradient animation with @property --border-angle,
dynamic accent color from traffic zone, light theme + reduced motion support.
2026-02-25 11:18:39 +03:00

3931 lines
182 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
/>
</svg>
);
const CheckIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
);
// 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<PurchaseStep>('period');
const [selectedPeriod, setSelectedPeriod] = useState<PeriodOption | null>(null);
const [selectedTraffic, setSelectedTraffic] = useState<number | null>(null);
const [selectedServers, setSelectedServers] = useState<string[]>([]);
const [selectedDevices, setSelectedDevices] = useState<number>(1);
const [showPurchaseForm, setShowPurchaseForm] = useState(false);
// Tariffs mode state
const [selectedTariff, setSelectedTariff] = useState<Tariff | null>(null);
const [selectedTariffPeriod, setSelectedTariffPeriod] = useState<TariffPeriod | null>(null);
const [showTariffPurchase, setShowTariffPurchase] = useState(false);
// Custom days/traffic state
const [customDays, setCustomDays] = useState<number>(30);
const [customTrafficGb, setCustomTrafficGb] = useState<number>(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<number>(1);
const [showTrafficTopup, setShowTrafficTopup] = useState(false);
const [selectedTrafficPackage, setSelectedTrafficPackage] = useState<number | null>(null);
const [showServerManagement, setShowServerManagement] = useState(false);
const [selectedServersToUpdate, setSelectedServersToUpdate] = useState<string[]>([]);
// 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<PurchaseStep[]>(() => {
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<HTMLDivElement>(null);
const tariffPurchaseRef = useRef<HTMLDivElement>(null);
const tariffsCardRef = useRef<HTMLDivElement>(null);
// Tariff switch preview
const [switchTariffId, setSwitchTariffId] = useState<number | null>(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 (
<div className="flex min-h-64 items-center justify-center">
<div className="h-10 w-10 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
);
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('subscription.title')}</h1>
{/* 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 (
<div
className="relative overflow-hidden rounded-3xl backdrop-blur-xl"
style={{
background: g.cardBg,
border: subscription.is_trial
? '1px solid rgba(62,219,176,0.15)'
: `1px solid ${g.cardBorder}`,
boxShadow: g.shadow,
padding: '28px 28px 24px',
}}
>
{/* Trial shimmer border */}
{subscription.is_trial && (
<div
className="pointer-events-none absolute inset-[-1px] animate-trial-glow rounded-3xl"
aria-hidden="true"
/>
)}
{/* Background glow */}
<div
className="pointer-events-none absolute"
style={{
top: -60,
right: -60,
width: 200,
height: 200,
borderRadius: '50%',
background: `radial-gradient(circle, ${zone.mainHex}${g.glowAlpha} 0%, transparent 70%)`,
transition: 'background 0.8s ease',
}}
aria-hidden="true"
/>
{/* ─── Header ─── */}
<div className="mb-6 flex items-start justify-between">
<div>
{/* Zone indicator */}
<div className="mb-1 flex items-center gap-2">
<div
className="h-2 w-2 rounded-full"
style={{
background: zone.mainHex,
boxShadow: `0 0 8px ${zone.mainHex}80`,
transition: 'all 0.6s ease',
}}
aria-hidden="true"
/>
<span
className="font-mono text-[11px] font-semibold uppercase tracking-widest"
style={{ color: zone.mainHex, transition: 'color 0.6s ease' }}
>
{isUnlimited ? t('dashboard.unlimited') : t(zone.labelKey)}
</span>
{subscription.is_trial && (
<span
className="inline-flex animate-trial-glow items-center gap-1 rounded-md px-2 py-0.5 font-mono text-[9px] font-bold uppercase tracking-widest"
style={{
background:
'linear-gradient(135deg, rgba(62,219,176,0.15), rgba(62,219,176,0.06))',
border: '1px solid rgba(62,219,176,0.2)',
color: '#3EDBB0',
}}
>
{t('subscription.trialStatus')}
</span>
)}
</div>
{/* Plan name */}
<h2 className="text-lg font-bold tracking-tight text-dark-50">
{subscription.tariff_name || t('subscription.currentPlan')}
</h2>
</div>
{/* Status badge */}
<span
className="rounded-full px-3 py-1 font-mono text-[10px] font-semibold uppercase tracking-wider"
style={{
background: subscription.is_active
? `${zone.mainHex}15`
: 'rgba(255,59,92,0.12)',
border: subscription.is_active
? `1px solid ${zone.mainHex}30`
: '1px solid rgba(255,59,92,0.25)',
color: subscription.is_active ? zone.mainHex : '#FF3B5C',
}}
>
{subscription.is_trial
? t('subscription.trialStatus')
: subscription.is_active
? t('subscription.active')
: t('subscription.expired')}
</span>
</div>
{/* ─── Trial Info Banner ─── */}
{subscription.is_trial && subscription.is_active && (
<div
className="mb-6 rounded-[14px] p-4"
style={{
background:
'linear-gradient(135deg, rgba(62,219,176,0.08), rgba(62,219,176,0.03))',
border: '1px solid rgba(62,219,176,0.12)',
}}
>
<div className="flex items-start gap-3">
<div
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px]"
style={{ background: 'rgba(62,219,176,0.12)' }}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="#3EDBB0"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="flex-1">
<div className="text-sm font-semibold" style={{ color: '#3EDBB0' }}>
{t('subscription.trialInfo.title')}
</div>
<div className="mt-1 text-[12px] text-dark-50/40">
{t('subscription.trialInfo.description')}
</div>
<div className="mt-3 flex flex-wrap gap-4">
<div className="flex items-center gap-1.5">
<span
className="font-mono text-[12px] font-semibold"
style={{ color: '#3EDBB0' }}
>
{subscription.days_left > 0
? t('subscription.days', { count: subscription.days_left })
: `${subscription.hours_left}${t('subscription.hours')} ${subscription.minutes_left}${t('subscription.minutes')}`}
</span>
<span className="text-[11px] text-dark-50/30">
{t('subscription.trialInfo.remaining')}
</span>
</div>
<div className="flex items-center gap-1.5">
<span
className="font-mono text-[12px] font-semibold"
style={{ color: '#3EDBB0' }}
>
{subscription.traffic_limit_gb || '∞'} {t('common.units.gb')}
</span>
<span className="text-[11px] text-dark-50/30">
{t('subscription.traffic')}
</span>
</div>
<div className="flex items-center gap-1.5">
<span
className="font-mono text-[12px] font-semibold"
style={{ color: '#3EDBB0' }}
>
{subscription.device_limit}
</span>
<span className="text-[11px] text-dark-50/30">
{t('subscription.devices')}
</span>
</div>
</div>
</div>
</div>
</div>
)}
{/* ─── Traffic Progress ─── */}
<div className="mb-6">
<div className="mb-2.5 flex items-center justify-between">
<span className="text-[11px] font-medium uppercase tracking-wider text-dark-50/40">
{t('subscription.traffic')}
</span>
<div className="flex items-center gap-2">
<span className="font-mono text-[11px] text-dark-50/30">
{isUnlimited
? formatTraffic(usedGb)
: `${formatTraffic(usedGb)} / ${formatTraffic(subscription.traffic_limit_gb)}`}
</span>
<button
onClick={() => refreshTrafficMutation.mutate()}
disabled={refreshTrafficMutation.isPending || trafficRefreshCooldown > 0}
className="flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium text-dark-50/30 transition-colors hover:bg-dark-50/[0.05] hover:text-dark-50/50 disabled:cursor-not-allowed disabled:opacity-50"
>
<svg
className={`h-3 w-3 ${refreshTrafficMutation.isPending ? 'animate-spin' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
{trafficRefreshCooldown > 0
? `${trafficRefreshCooldown}s`
: t('common.refresh')}
</button>
</div>
</div>
{subscription.traffic_reset_mode &&
subscription.traffic_reset_mode !== 'NO_RESET' && (
<div className="mb-2 text-[10px] text-dark-50/25">
{t(`subscription.trafficReset.${subscription.traffic_reset_mode}`)}
</div>
)}
<TrafficProgressBar
usedGb={usedGb}
limitGb={subscription.traffic_limit_gb}
percent={usedPercent}
isUnlimited={isUnlimited}
compact
/>
</div>
{/* ─── Connect Device Button ─── */}
{subscription.subscription_url && (
<HoverBorderGradient
as="button"
accentColor={zone.mainHex}
onClick={() => navigate('/connection')}
className="mb-5 flex w-full items-center gap-3.5 rounded-[14px] p-3.5 text-left transition-shadow duration-300"
style={{ fontFamily: 'inherit' }}
>
<div
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px] transition-colors duration-500"
style={{ background: `${zone.mainHex}12` }}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke={zone.mainHex}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="2" y="3" width="20" height="14" rx="2" />
<path d="M12 17v4M8 21h8" />
<path d="M12 8v4M10 10h4" opacity="0.7" />
</svg>
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold tracking-tight text-dark-50">
{t('dashboard.connectDevice')}
</div>
<div className="mt-0.5 text-[11px] text-dark-50/30">
{t('dashboard.devicesOfMax', {
used: connectedDevices,
max: subscription.device_limit,
})}
</div>
</div>
<div className="flex flex-shrink-0 gap-1.5" aria-hidden="true">
{Array.from({ length: subscription.device_limit }, (_, i) => (
<div
key={i}
className="h-[7px] w-[7px] rounded-full transition-all duration-300"
style={{
background: i < connectedDevices ? zone.mainHex : g.textGhost,
boxShadow: i < connectedDevices ? `0 0 6px ${zone.mainHex}50` : 'none',
}}
/>
))}
</div>
</HoverBorderGradient>
)}
{/* ─── Subscription URL ─── */}
{subscription.subscription_url && !subscription.hide_subscription_link && (
<div className="mb-5 flex gap-2">
<code
className="scrollbar-hide flex-1 overflow-x-auto break-all rounded-[10px] px-3 py-2 font-mono text-[11px] text-dark-50/30"
style={{
background: g.codeBg,
border: `1px solid ${g.codeBorder}`,
}}
>
{subscription.subscription_url}
</code>
<button
onClick={copyUrl}
className="flex h-auto items-center rounded-[10px] px-3 transition-all duration-300"
style={{
background: copied ? 'rgba(62,219,176,0.12)' : g.innerBorder,
border: copied ? '1px solid rgba(62,219,176,0.2)' : `1px solid ${g.trackBg}`,
color: copied ? '#3EDBB0' : g.textMuted,
}}
title={t('subscription.copyLink')}
>
{copied ? <CheckIcon /> : <CopyIcon />}
</button>
</div>
)}
{/* ─── Stats Row ─── */}
<div className="mb-5 grid grid-cols-2 gap-2.5">
{/* Days left */}
<div
className="rounded-[14px] p-3.5"
style={{
background: daysLeft <= 3 ? 'rgba(255,184,0,0.06)' : g.innerBg,
border:
daysLeft <= 3
? '1px solid rgba(255,184,0,0.15)'
: `1px solid ${g.innerBorder}`,
}}
>
<div className="mb-1 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wider text-dark-50/35">
<div
className="flex h-6 w-6 items-center justify-center rounded-[7px]"
style={{
background: daysLeft <= 3 ? 'rgba(255,184,0,0.1)' : g.hoverBg,
}}
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke={daysLeft <= 3 ? '#FFB800' : g.textSecondary}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" />
</svg>
</div>
{t('dashboard.remaining')}
</div>
<div className="flex items-baseline gap-1">
<span
className="text-[22px] font-bold tracking-tight"
style={{ color: daysLeft <= 3 ? '#FFB800' : g.text }}
>
{daysLeft > 0
? daysLeft
: subscription.hours_left > 0
? `${subscription.hours_left}h`
: `${subscription.minutes_left}m`}
</span>
<span className="text-xs font-medium text-dark-50/25">
{daysLeft > 0 ? t('subscription.daysShort') : ''}
</span>
</div>
</div>
{/* Devices */}
<div
className="rounded-[14px] p-3.5"
style={{
background: g.innerBg,
border: `1px solid ${g.innerBorder}`,
}}
>
<div className="mb-1 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wider text-dark-50/35">
<div
className="flex h-6 w-6 items-center justify-center rounded-[7px]"
style={{ background: `${zone.mainHex}12` }}
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke={zone.mainHex}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="2" y="3" width="20" height="14" rx="2" />
<path d="M12 17v4M8 21h8" />
</svg>
</div>
{t('subscription.devices')}
</div>
<div className="flex items-baseline gap-1">
<span className="text-[22px] font-bold tracking-tight text-dark-50">
{connectedDevices}
</span>
<span className="text-xs font-medium text-dark-50/25">
/ {subscription.device_limit}
</span>
</div>
</div>
{/* Expires */}
<div
className="rounded-[14px] p-3.5"
style={{
background: g.innerBg,
border: `1px solid ${g.innerBorder}`,
}}
>
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-dark-50/35">
{t('subscription.expiresAt')}
</div>
<div className="text-sm font-semibold tracking-tight text-dark-50">
{formattedDate}
</div>
</div>
{/* Traffic */}
<div
className="rounded-[14px] p-3.5"
style={{
background: `linear-gradient(135deg, ${zone.mainHex}08, ${zone.mainHex}03)`,
border: `1px solid ${zone.mainHex}12`,
}}
>
<div
className="mb-1 text-[10px] font-semibold uppercase tracking-wider opacity-70"
style={{ color: zone.mainHex }}
>
{t('subscription.traffic')}
</div>
<div className="text-sm font-semibold tracking-tight text-dark-50">
{isUnlimited ? '∞' : `${usedPercent.toFixed(0)}%`}
</div>
</div>
</div>
{/* ─── Locations ─── */}
{subscription.servers && subscription.servers.length > 0 && (
<div className="mb-5">
<div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-dark-50/35">
{t('subscription.locationsLabel')}
</div>
<div className="flex flex-wrap gap-1.5">
{subscription.servers.map((server) => (
<span
key={server.uuid}
className="inline-flex items-center gap-1.5 rounded-[8px] px-2.5 py-1 text-[11px] font-medium text-dark-50/50"
style={{
background: g.innerBorder,
border: `1px solid ${g.trackBg}`,
}}
>
{server.country_code && (
<span className="text-xs">{getFlagEmoji(server.country_code)}</span>
)}
{server.name}
</span>
))}
</div>
</div>
)}
{/* ─── Purchased Traffic Packages ─── */}
{subscription.traffic_purchases && subscription.traffic_purchases.length > 0 && (
<div className="mb-5">
<div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-dark-50/35">
{t('subscription.purchasedTraffic')}
</div>
<div className="space-y-2">
{subscription.traffic_purchases.map((purchase) => (
<div
key={purchase.id}
className="rounded-[12px] p-3"
style={{
background: g.innerBg,
border: `1px solid ${g.innerBorder}`,
}}
>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<div
className="flex h-7 w-7 items-center justify-center rounded-[8px]"
style={{ background: `${zone.mainHex}12` }}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke={zone.mainHex}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
</div>
<span className="text-sm font-semibold text-dark-50">
{purchase.traffic_gb} {t('common.units.gb')}
</span>
</div>
<div className="text-right">
<div
className="text-[11px] font-medium"
style={{
color: purchase.days_remaining === 0 ? '#FF6B35' : g.textSecondary,
}}
>
{purchase.days_remaining === 0
? t('subscription.expired')
: t('subscription.days', { count: purchase.days_remaining })}
</div>
<div className="mt-0.5 font-mono text-[9px] text-dark-50/20">
{t('subscription.trafficResetAt')}:{' '}
{new Date(purchase.expires_at).toLocaleDateString(undefined, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})}
</div>
</div>
</div>
<div
className="relative h-1.5 overflow-hidden rounded-full"
style={{ background: g.trackBg }}
>
<div
className="absolute inset-0 rounded-full transition-all duration-500"
style={{
width: `${purchase.progress_percent}%`,
background: `linear-gradient(90deg, ${zone.mainHex}, ${zone.mainHex}80)`,
}}
/>
</div>
<div className="mt-1 flex justify-between font-mono text-[9px] text-dark-50/20">
<span>{new Date(purchase.created_at).toLocaleDateString()}</span>
<span>{new Date(purchase.expires_at).toLocaleDateString()}</span>
</div>
</div>
))}
</div>
</div>
)}
{/* ─── Autopay Toggle ─── */}
{!subscription.is_trial && !subscription.is_daily && (
<div
className="flex items-center justify-between rounded-[14px] p-3.5"
style={{
background: g.innerBg,
border: `1px solid ${g.innerBorder}`,
}}
>
<div>
<div className="text-sm font-semibold text-dark-50">
{t('subscription.autoRenewal')}
</div>
<div className="mt-0.5 text-[11px] text-dark-50/30">
{t('subscription.daysBeforeExpiry', {
count: subscription.autopay_days_before,
})}
</div>
</div>
<button
onClick={() => autopayMutation.mutate(!subscription.autopay_enabled)}
disabled={autopayMutation.isPending}
className="relative h-7 w-[52px] rounded-full transition-all duration-300"
style={{
background: subscription.autopay_enabled ? zone.mainHex : g.textGhost,
}}
>
<span
className="absolute top-[3px] h-[22px] w-[22px] rounded-full bg-white transition-all duration-300"
style={{
left: subscription.autopay_enabled ? '26px' : '3px',
boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
}}
/>
</button>
</div>
)}
</div>
);
})()
) : (
<div
className="relative overflow-hidden rounded-3xl py-12 text-center"
style={{
background: g.cardBg,
border: `1px solid ${g.cardBorder}`,
boxShadow: g.shadow,
}}
>
<div
className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl"
style={{ background: g.hoverBg }}
>
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke={g.textFaint}
strokeWidth="1.5"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
/>
</svg>
</div>
<div className="text-sm text-dark-50/30">{t('subscription.noSubscription')}</div>
</div>
)}
{/* Daily Subscription Pause */}
{subscription && subscription.is_daily && !subscription.is_trial && (
<div
className="relative overflow-hidden rounded-3xl"
style={{
background: g.cardBg,
border: `1px solid ${g.cardBorder}`,
boxShadow: g.shadow,
padding: '24px 28px',
}}
>
<div className="flex items-center justify-between">
<div>
<h2 className="text-base font-bold tracking-tight text-dark-50">
{t('subscription.pause.title')}
</h2>
<div className="mt-1 text-[12px] text-dark-50/35">
{subscription.is_daily_paused
? t('subscription.pause.paused')
: t('subscription.pause.active')}
</div>
</div>
<button
onClick={() => pauseMutation.mutate()}
disabled={pauseMutation.isPending}
className="rounded-[10px] px-4 py-2 text-sm font-semibold transition-all duration-300"
style={{
background: subscription.is_daily_paused
? 'rgba(62,219,176,0.12)'
: 'rgba(255,184,0,0.12)',
border: subscription.is_daily_paused
? '1px solid rgba(62,219,176,0.2)'
: '1px solid rgba(255,184,0,0.2)',
color: subscription.is_daily_paused ? '#3EDBB0' : '#FFB800',
}}
>
{pauseMutation.isPending ? (
<span className="flex items-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
</span>
) : subscription.is_daily_paused ? (
t('subscription.pause.resumeBtn')
) : (
t('subscription.pause.pauseBtn')
)}
</button>
</div>
{/* Pause mutation error */}
{pauseMutation.isError &&
(() => {
const balanceError = getInsufficientBalanceError(pauseMutation.error);
if (balanceError) {
const missingAmount = balanceError.required - balanceError.balance;
return (
<div className="mt-4">
<InsufficientBalancePrompt
missingAmountKopeks={missingAmount}
message={t('subscription.pause.insufficientBalance')}
compact
/>
</div>
);
}
return (
<div
className="mt-4 rounded-[10px] p-3 text-center text-sm"
style={{
background: 'rgba(255,59,92,0.08)',
border: '1px solid rgba(255,59,92,0.15)',
color: '#FF3B5C',
}}
>
{getErrorMessage(pauseMutation.error)}
</div>
);
})()}
{/* Paused info or Next charge progress bar */}
{subscription.is_daily_paused ? (
<div
className="mt-4 rounded-[12px] p-4"
style={{
background: 'rgba(255,184,0,0.06)',
border: '1px solid rgba(255,184,0,0.12)',
}}
>
<div className="flex items-start gap-3">
<div className="text-lg" style={{ color: '#FFB800' }}>
</div>
<div>
<div className="text-sm font-semibold" style={{ color: '#FFB800' }}>
{t('subscription.pause.pausedInfo')}
</div>
<div className="mt-1 text-[12px] text-dark-50/35">
{t('subscription.pause.pausedDescription')}{' '}
{new Date(subscription.end_date).toLocaleDateString()} (
{t('subscription.pause.days', { count: subscription.days_left })})
</div>
</div>
</div>
</div>
) : (
subscription.next_daily_charge_at &&
(() => {
const now = new Date();
const nextChargeStr = subscription.next_daily_charge_at.endsWith('Z')
? subscription.next_daily_charge_at
: subscription.next_daily_charge_at + 'Z';
const nextCharge = new Date(nextChargeStr);
const totalMs = 24 * 60 * 60 * 1000;
const remainingMs = Math.max(0, nextCharge.getTime() - now.getTime());
const elapsedMs = totalMs - remainingMs;
const progress = Math.min(100, (elapsedMs / totalMs) * 100);
const hours = Math.floor(remainingMs / (1000 * 60 * 60));
const minutes = Math.floor((remainingMs % (1000 * 60 * 60)) / (1000 * 60));
return (
<div className="mt-4">
<div className="mb-2 flex items-center justify-between">
<span className="text-[11px] font-medium uppercase tracking-wider text-dark-50/35">
{t('subscription.pause.nextCharge')}
</span>
<span className="font-mono text-[12px] font-semibold text-dark-50">
{hours > 0
? `${hours}${t('subscription.pause.hours')} ${minutes}${t('subscription.pause.minutes')}`
: `${minutes}${t('subscription.pause.minutes')}`}
</span>
</div>
<div
className="relative h-2 overflow-hidden rounded-full"
style={{ background: g.trackBg }}
>
<div
className="absolute inset-0 rounded-full transition-all duration-500"
style={{
width: `${progress}%`,
background: 'linear-gradient(90deg, #00E5A0, #00C987)',
}}
/>
</div>
{subscription.daily_price_kopeks && (
<div className="mt-2 text-center text-[11px] text-dark-50/25">
{t('subscription.pause.willBeCharged')}:{' '}
{formatPrice(subscription.daily_price_kopeks)}
</div>
)}
</div>
);
})()
)}
</div>
)}
{/* Additional Options (Buy Devices) */}
{subscription && subscription.is_active && !subscription.is_trial && (
<div
className="relative overflow-hidden rounded-3xl"
style={{
background: g.cardBg,
border: `1px solid ${g.cardBorder}`,
boxShadow: g.shadow,
padding: '24px 28px',
}}
>
<h2 className="mb-4 text-base font-bold tracking-tight text-dark-50">
{t('subscription.additionalOptions.title')}
</h2>
{/* Buy Devices */}
{!showDeviceTopup ? (
<button
onClick={() => setShowDeviceTopup(true)}
className="w-full rounded-xl border border-dark-700/50 bg-dark-800/50 p-4 text-left transition-colors hover:border-dark-600"
>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-dark-100">
{t('subscription.additionalOptions.buyDevices')}
</div>
<div className="mt-1 text-sm text-dark-400">
{t('subscription.additionalOptions.currentDeviceLimit', {
count: subscription.device_limit,
})}
</div>
</div>
<svg
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
) : (
<div className="rounded-xl border border-dark-700/50 bg-dark-800/50 p-5">
<div className="mb-4 flex items-center justify-between">
<h3 className="font-medium text-dark-100">{t('subscription.buyDevices')}</h3>
<button
onClick={() => setShowDeviceTopup(false)}
className="text-sm text-dark-400 hover:text-dark-200"
>
</button>
</div>
{/* Check if completely unavailable (no subscription, price not set, etc.) */}
{devicePriceData?.available === false && !devicePriceData?.max_device_limit ? (
<div className="py-4 text-center text-sm text-dark-400">
{devicePriceData.reason || t('subscription.additionalOptions.devicesUnavailable')}
</div>
) : (
<div className="space-y-4">
{/* Device selector - show even at max limit */}
<div className="flex items-center justify-center gap-6">
<button
onClick={() => setDevicesToAdd(Math.max(1, devicesToAdd - 1))}
disabled={devicesToAdd <= 1}
className="btn-secondary flex h-12 w-12 items-center justify-center !p-0 text-2xl"
>
-
</button>
<div className="text-center">
<div className="text-4xl font-bold text-dark-100">{devicesToAdd}</div>
<div className="text-sm text-dark-500">
{t('subscription.additionalOptions.devicesUnit')}
</div>
</div>
<button
onClick={() => setDevicesToAdd(devicesToAdd + 1)}
disabled={
devicePriceData?.max_device_limit
? (devicePriceData.current_device_limit || 0) + devicesToAdd >=
devicePriceData.max_device_limit
: false
}
className="btn-secondary flex h-12 w-12 items-center justify-center !p-0 text-2xl"
>
+
</button>
</div>
{/* Show limit info when at or near max */}
{devicePriceData?.max_device_limit && (
<div className="text-center text-sm text-dark-400">
{t('subscription.additionalOptions.currentDeviceLimit', {
count: devicePriceData.current_device_limit || subscription.device_limit,
})}{' '}
/{' '}
{t('subscription.additionalOptions.maxDevices', {
count: devicePriceData.max_device_limit,
})}
</div>
)}
{/* Show reason if can't add requested amount */}
{devicePriceData?.available === false && devicePriceData?.reason && (
<div className="rounded-lg bg-warning-500/10 p-3 text-center text-sm text-warning-400">
{devicePriceData.reason}
</div>
)}
{/* Price info - only when available */}
{devicePriceData?.available && devicePriceData.price_per_device_label && (
<div className="text-center">
<div className="mb-2 text-sm text-dark-400">
{/* Show original price with strikethrough if discount */}
{devicePriceData.discount_percent &&
devicePriceData.discount_percent > 0 ? (
<span>
<span className="text-dark-500 line-through">
{formatPrice(devicePriceData.original_price_per_device_kopeks || 0)}
</span>
<span className="mx-1">{devicePriceData.price_per_device_label}</span>
</span>
) : (
devicePriceData.price_per_device_label
)}
/{t('subscription.perDevice').replace('/ ', '')} (
{t('subscription.days', { count: devicePriceData.days_left })})
</div>
{/* Discount badge */}
{devicePriceData.discount_percent && devicePriceData.discount_percent > 0 && (
<div className="mb-2">
<span className="inline-block rounded-full bg-green-500/20 px-2.5 py-0.5 text-sm font-medium text-green-400">
-{devicePriceData.discount_percent}%
</span>
</div>
)}
{/* Total price - show as free if 100% discount or 0 */}
{devicePriceData.total_price_kopeks === 0 ? (
<div className="text-2xl font-bold text-green-400">
{t('subscription.switchTariff.free')}
</div>
) : (
<div className="text-2xl font-bold text-accent-400">
{/* Show original total with strikethrough if discount */}
{devicePriceData.discount_percent &&
devicePriceData.discount_percent > 0 &&
devicePriceData.base_total_price_kopeks && (
<span className="mr-2 text-lg text-dark-500 line-through">
{formatPrice(devicePriceData.base_total_price_kopeks)}
</span>
)}
{devicePriceData.total_price_label}
</div>
)}
</div>
)}
{devicePriceData?.available &&
purchaseOptions &&
devicePriceData.total_price_kopeks &&
devicePriceData.total_price_kopeks > purchaseOptions.balance_kopeks && (
<InsufficientBalancePrompt
missingAmountKopeks={
devicePriceData.total_price_kopeks - purchaseOptions.balance_kopeks
}
compact
onBeforeTopUp={async () => {
await subscriptionApi.saveDevicesCart(devicesToAdd);
}}
/>
)}
<button
onClick={() => devicePurchaseMutation.mutate()}
disabled={
devicePurchaseMutation.isPending ||
!devicePriceData?.available ||
!!(
devicePriceData?.total_price_kopeks &&
purchaseOptions &&
devicePriceData.total_price_kopeks > purchaseOptions.balance_kopeks
)
}
className="btn-primary w-full py-3"
>
{devicePurchaseMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
</span>
) : (
t('subscription.additionalOptions.buy')
)}
</button>
{devicePurchaseMutation.isError && (
<div className="text-center text-sm text-error-400">
{getErrorMessage(devicePurchaseMutation.error)}
</div>
)}
</div>
)}
</div>
)}
{/* Reduce Devices */}
<div className="mt-4">
{!showDeviceReduction ? (
<button
onClick={() => setShowDeviceReduction(true)}
className="w-full rounded-xl border border-dark-700/50 bg-dark-800/50 p-4 text-left transition-colors hover:border-dark-600"
>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-dark-100">
{t('subscription.additionalOptions.reduceDevices')}
</div>
<div className="mt-1 text-sm text-dark-400">
{t('subscription.additionalOptions.reduceDevicesDescription')}
</div>
</div>
<svg
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
) : (
<div className="rounded-xl border border-dark-700/50 bg-dark-800/50 p-5">
<div className="mb-4 flex items-center justify-between">
<h3 className="font-medium text-dark-100">
{t('subscription.additionalOptions.reduceDevicesTitle')}
</h3>
<button
onClick={() => setShowDeviceReduction(false)}
className="text-sm text-dark-400 hover:text-dark-200"
>
</button>
</div>
{deviceReductionInfo?.available === false ? (
<div className="py-4 text-center text-sm text-dark-400">
{deviceReductionInfo.reason ||
t('subscription.additionalOptions.reduceUnavailable')}
</div>
) : deviceReductionInfo ? (
<div className="space-y-4">
{/* Device limit selector */}
<div className="flex items-center justify-center gap-6">
<button
onClick={() =>
setTargetDeviceLimit(
Math.max(
Math.max(
deviceReductionInfo.min_device_limit,
deviceReductionInfo.connected_devices_count,
),
targetDeviceLimit - 1,
),
)
}
disabled={
targetDeviceLimit <=
Math.max(
deviceReductionInfo.min_device_limit,
deviceReductionInfo.connected_devices_count,
)
}
className="btn-secondary flex h-12 w-12 items-center justify-center !p-0 text-2xl"
>
-
</button>
<div className="text-center">
<div className="text-4xl font-bold text-dark-100">{targetDeviceLimit}</div>
<div className="text-sm text-dark-500">
{t('subscription.additionalOptions.devicesUnit')}
</div>
</div>
<button
onClick={() =>
setTargetDeviceLimit(
Math.min(
deviceReductionInfo.current_device_limit - 1,
targetDeviceLimit + 1,
),
)
}
disabled={targetDeviceLimit >= deviceReductionInfo.current_device_limit - 1}
className="btn-secondary flex h-12 w-12 items-center justify-center !p-0 text-2xl"
>
+
</button>
</div>
{/* Info */}
<div className="space-y-1 text-center text-sm text-dark-400">
<div>
{t('subscription.additionalOptions.currentDeviceLimit', {
count: deviceReductionInfo.current_device_limit,
})}
</div>
<div>
{t('subscription.additionalOptions.minDeviceLimit', {
count: deviceReductionInfo.min_device_limit,
})}
</div>
<div>
{t('subscription.additionalOptions.connectedDevices', {
count: deviceReductionInfo.connected_devices_count,
})}
</div>
</div>
{/* Warning if connected devices block reduction */}
{deviceReductionInfo.connected_devices_count >
deviceReductionInfo.min_device_limit && (
<div className="rounded-lg bg-warning-500/10 p-3 text-center text-sm text-warning-400">
{t('subscription.additionalOptions.disconnectDevicesFirst', {
count: deviceReductionInfo.connected_devices_count,
})}
</div>
)}
{/* New limit preview */}
<div className="text-center">
<div className="text-sm text-dark-400">
{t('subscription.additionalOptions.newDeviceLimit', {
count: targetDeviceLimit,
})}
</div>
</div>
<button
onClick={() => deviceReductionMutation.mutate()}
disabled={
deviceReductionMutation.isPending ||
targetDeviceLimit >= deviceReductionInfo.current_device_limit ||
targetDeviceLimit < deviceReductionInfo.min_device_limit ||
targetDeviceLimit < deviceReductionInfo.connected_devices_count
}
className="btn-primary w-full py-3"
>
{deviceReductionMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
{t('subscription.additionalOptions.reducing')}
</span>
) : (
t('subscription.additionalOptions.reduce')
)}
</button>
{deviceReductionMutation.isError && (
<div className="text-center text-sm text-error-400">
{getErrorMessage(deviceReductionMutation.error)}
</div>
)}
</div>
) : (
<div className="flex items-center justify-center py-4">
<span className="h-5 w-5 animate-spin rounded-full border-2 border-accent-400/30 border-t-accent-400" />
</div>
)}
</div>
)}
</div>
{/* Buy Traffic */}
{subscription.traffic_limit_gb > 0 && (
<div className="mt-4">
{!showTrafficTopup ? (
<button
onClick={() => setShowTrafficTopup(true)}
className="w-full rounded-xl border border-dark-700/50 bg-dark-800/50 p-4 text-left transition-colors hover:border-dark-600"
>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-dark-100">
{t('subscription.additionalOptions.buyTraffic')}
</div>
<div className="mt-1 text-sm text-dark-400">
{t('subscription.additionalOptions.currentTrafficLimit', {
limit: subscription.traffic_limit_gb,
used: subscription.traffic_used_gb.toFixed(1),
})}
</div>
</div>
<svg
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
) : (
<div className="rounded-xl border border-dark-700/50 bg-dark-800/50 p-5">
<div className="mb-4 flex items-center justify-between">
<h3 className="font-medium text-dark-100">
{t('subscription.additionalOptions.buyTrafficTitle')}
</h3>
<button
onClick={() => {
setShowTrafficTopup(false);
setSelectedTrafficPackage(null);
}}
className="text-sm text-dark-400 hover:text-dark-200"
>
</button>
</div>
<div className="mb-4 rounded-lg bg-dark-700/30 p-2 text-xs text-dark-500">
{t('subscription.additionalOptions.trafficWarning')}
</div>
{!trafficPackages || trafficPackages.length === 0 ? (
<div className="py-4 text-center text-sm text-dark-400">
{t('subscription.additionalOptions.trafficUnavailable')}
</div>
) : (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
{trafficPackages.map((pkg) => (
<button
key={pkg.gb}
onClick={() => setSelectedTrafficPackage(pkg.gb)}
className={`rounded-xl border p-4 text-center transition-all ${
selectedTrafficPackage === pkg.gb
? 'border-accent-500 bg-accent-500/10'
: 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
}`}
>
<div className="text-lg font-semibold text-dark-100">
{pkg.is_unlimited
? '♾️ ' + t('subscription.additionalOptions.unlimited')
: `${pkg.gb} ${t('common.units.gb')}`}
</div>
{/* Discount badge */}
{pkg.discount_percent && pkg.discount_percent > 0 && (
<div className="mb-1">
<span className="inline-block rounded-full bg-green-500/20 px-2 py-0.5 text-xs font-medium text-green-400">
-{pkg.discount_percent}%
</span>
</div>
)}
{/* Price with original strikethrough if discount */}
<div className="font-medium text-accent-400">
{pkg.discount_percent &&
pkg.discount_percent > 0 &&
pkg.base_price_kopeks ? (
<>
<span className="mr-1 text-sm text-dark-500 line-through">
{formatPrice(pkg.base_price_kopeks)}
</span>
{formatPrice(pkg.price_kopeks)}
</>
) : (
formatPrice(pkg.price_kopeks)
)}
</div>
</button>
))}
</div>
{selectedTrafficPackage !== null &&
(() => {
const selectedPkg = trafficPackages.find(
(p) => p.gb === selectedTrafficPackage,
);
const hasEnoughBalance =
!selectedPkg ||
!purchaseOptions ||
selectedPkg.price_kopeks <= purchaseOptions.balance_kopeks;
const missingAmount =
selectedPkg && purchaseOptions
? selectedPkg.price_kopeks - purchaseOptions.balance_kopeks
: 0;
return (
<>
{!hasEnoughBalance && missingAmount > 0 && (
<InsufficientBalancePrompt
missingAmountKopeks={missingAmount}
compact
className="mb-3"
onBeforeTopUp={async () => {
await subscriptionApi.saveTrafficCart(selectedTrafficPackage);
}}
/>
)}
<button
onClick={() =>
trafficPurchaseMutation.mutate(selectedTrafficPackage)
}
disabled={trafficPurchaseMutation.isPending || !hasEnoughBalance}
className="btn-primary w-full py-3"
>
{trafficPurchaseMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
</span>
) : selectedPkg?.is_unlimited ? (
t('subscription.additionalOptions.buyUnlimited')
) : (
t('subscription.additionalOptions.buyTrafficGb', {
gb: selectedTrafficPackage,
})
)}
</button>
</>
);
})()}
{trafficPurchaseMutation.isError && (
<div className="text-center text-sm text-error-400">
{getErrorMessage(trafficPurchaseMutation.error)}
</div>
)}
</div>
)}
</div>
)}
</div>
)}
{/* Server Management - only in classic mode */}
{!isTariffsMode && (
<div className="mt-4">
{!showServerManagement ? (
<button
onClick={() => setShowServerManagement(true)}
className="w-full rounded-xl border border-dark-700/50 bg-dark-800/50 p-4 text-left transition-colors hover:border-dark-600"
>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-dark-100">
{t('subscription.additionalOptions.manageServers')}
</div>
<div className="mt-1 text-sm text-dark-400">
{t('subscription.servers', { count: subscription.servers?.length || 0 })}
</div>
</div>
<svg
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
) : (
<div className="rounded-xl border border-dark-700/50 bg-dark-800/50 p-5">
<div className="mb-4 flex items-center justify-between">
<h3 className="font-medium text-dark-100">
{t('subscription.additionalOptions.manageServersTitle')}
</h3>
<button
onClick={() => {
setShowServerManagement(false);
setSelectedServersToUpdate([]);
}}
className="text-sm text-dark-400 hover:text-dark-200"
>
</button>
</div>
{countriesLoading ? (
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : countriesData && countriesData.countries.length > 0 ? (
<div className="space-y-4">
<div className="rounded-lg bg-dark-700/30 p-2 text-xs text-dark-500">
{t('subscription.serverManagement.statusLegend')}
</div>
{countriesData.discount_percent > 0 && (
<div className="rounded-lg border border-success-500/30 bg-success-500/10 p-2 text-xs text-success-400">
🎁{' '}
{t('subscription.serverManagement.discountBanner', {
percent: countriesData.discount_percent,
})}
</div>
)}
<div className="max-h-64 space-y-2 overflow-y-auto">
{countriesData.countries
.filter((country) => country.is_available || country.is_connected)
.map((country) => {
const isCurrentlyConnected = country.is_connected;
const isSelected = selectedServersToUpdate.includes(country.uuid);
const willBeAdded = !isCurrentlyConnected && isSelected;
const willBeRemoved = isCurrentlyConnected && !isSelected;
return (
<button
key={country.uuid}
onClick={() => {
if (isSelected) {
setSelectedServersToUpdate((prev) =>
prev.filter((u) => u !== country.uuid),
);
} else {
setSelectedServersToUpdate((prev) => [...prev, country.uuid]);
}
}}
disabled={!country.is_available && !isCurrentlyConnected}
className={`flex w-full items-center justify-between rounded-xl border p-3 text-left transition-all ${
isSelected
? willBeAdded
? 'border-success-500 bg-success-500/10'
: 'border-accent-500 bg-accent-500/10'
: willBeRemoved
? 'border-error-500/50 bg-error-500/5'
: 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
} ${!country.is_available && !isCurrentlyConnected ? 'cursor-not-allowed opacity-50' : ''}`}
>
<div className="flex items-center gap-3">
<span className="text-lg">
{willBeAdded
? ''
: willBeRemoved
? ''
: isSelected
? '✅'
: '⚪'}
</span>
<div>
<div className="flex items-center gap-2 font-medium text-dark-100">
{country.name}
{country.has_discount && !isCurrentlyConnected && (
<span className="rounded bg-success-500/20 px-1.5 py-0.5 text-xs text-success-400">
-{country.discount_percent}%
</span>
)}
</div>
{willBeAdded && (
<div className="text-xs text-success-400">
+{formatPrice(country.price_kopeks)}{' '}
{t('subscription.serverManagement.forDays', {
days: countriesData.days_left,
})}
{country.has_discount && (
<span className="ml-1 text-dark-500 line-through">
{formatPrice(
Math.round(
(country.base_price_kopeks *
countriesData.days_left) /
30,
),
)}
</span>
)}
</div>
)}
{!willBeAdded && !isCurrentlyConnected && (
<div className="text-xs text-dark-500">
{formatPrice(country.price_per_month_kopeks)}
{t('subscription.serverManagement.perMonth')}
{country.has_discount && (
<span className="ml-1 text-dark-600 line-through">
{formatPrice(country.base_price_kopeks)}
</span>
)}
</div>
)}
{!country.is_available && !isCurrentlyConnected && (
<div className="text-xs text-dark-500">
{t('subscription.serverManagement.unavailable')}
</div>
)}
</div>
</div>
{country.country_code && (
<span className="text-xl">
{getFlagEmoji(country.country_code)}
</span>
)}
</button>
);
})}
</div>
{(() => {
const currentConnected = countriesData.countries
.filter((c) => c.is_connected)
.map((c) => c.uuid);
const added = selectedServersToUpdate.filter(
(u) => !currentConnected.includes(u),
);
const removed = currentConnected.filter(
(u) => !selectedServersToUpdate.includes(u),
);
const hasChanges = added.length > 0 || removed.length > 0;
// Calculate cost for added servers
const addedServers = countriesData.countries.filter((c) =>
added.includes(c.uuid),
);
const totalCost = addedServers.reduce((sum, s) => sum + s.price_kopeks, 0);
const hasEnoughBalance =
!purchaseOptions || totalCost <= purchaseOptions.balance_kopeks;
const missingAmount = purchaseOptions
? totalCost - purchaseOptions.balance_kopeks
: 0;
return hasChanges ? (
<div className="space-y-3 border-t border-dark-700/50 pt-3">
{added.length > 0 && (
<div className="text-sm">
<span className="text-success-400">
{t('subscription.serverManagement.toAdd')}
</span>{' '}
<span className="text-dark-300">
{addedServers.map((s) => s.name).join(', ')}
</span>
</div>
)}
{removed.length > 0 && (
<div className="text-sm">
<span className="text-error-400">
{t('subscription.serverManagement.toDisconnect')}
</span>{' '}
<span className="text-dark-300">
{countriesData.countries
.filter((c) => removed.includes(c.uuid))
.map((s) => s.name)
.join(', ')}
</span>
</div>
)}
{totalCost > 0 && (
<div className="text-center">
<div className="text-sm text-dark-400">
{t('subscription.serverManagement.paymentProrated')}
</div>
<div className="text-xl font-bold text-accent-400">
{formatPrice(totalCost)}
</div>
</div>
)}
{totalCost > 0 && !hasEnoughBalance && missingAmount > 0 && (
<InsufficientBalancePrompt
missingAmountKopeks={missingAmount}
compact
/>
)}
<button
onClick={() =>
updateCountriesMutation.mutate(selectedServersToUpdate)
}
disabled={
updateCountriesMutation.isPending ||
selectedServersToUpdate.length === 0 ||
(totalCost > 0 && !hasEnoughBalance)
}
className="btn-primary w-full py-3"
>
{updateCountriesMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
</span>
) : (
t('subscription.serverManagement.applyChanges')
)}
</button>
</div>
) : (
<div className="py-2 text-center text-sm text-dark-500">
{t('subscription.serverManagement.selectServersHint')}
</div>
);
})()}
{updateCountriesMutation.isError && (
<div className="text-center text-sm text-error-400">
{getErrorMessage(updateCountriesMutation.error)}
</div>
)}
</div>
) : (
<div className="py-4 text-center text-sm text-dark-400">
{t('subscription.serverManagement.noServersAvailable')}
</div>
)}
</div>
)}
</div>
)}
</div>
)}
{/* My Devices Section */}
{subscription && (
<div
className="relative overflow-hidden rounded-3xl"
style={{
background: g.cardBg,
border: `1px solid ${g.cardBorder}`,
boxShadow: g.shadow,
padding: '24px 28px',
}}
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-bold tracking-tight text-dark-50">
{t('subscription.myDevices')}
</h2>
{devicesData && devicesData.devices.length > 0 && (
<button
onClick={() => {
if (confirm(t('subscription.confirmDeleteAllDevices'))) {
deleteAllDevicesMutation.mutate();
}
}}
disabled={deleteAllDevicesMutation.isPending}
className="text-[11px] font-medium transition-colors"
style={{ color: '#FF3B5C' }}
>
{t('subscription.deleteAllDevices')}
</button>
)}
</div>
{devicesLoading ? (
<div className="flex items-center justify-center py-8">
<div
className="h-8 w-8 animate-spin rounded-full border-2 border-t-transparent"
style={{ borderColor: '#00E5A0', borderTopColor: 'transparent' }}
/>
</div>
) : devicesData && devicesData.devices.length > 0 ? (
<div className="space-y-2">
<div className="mb-2 font-mono text-[11px] text-dark-50/30">
{devicesData.total} /{' '}
{t('subscription.devices', { count: devicesData.device_limit })}
</div>
{devicesData.devices.map((device) => (
<div
key={device.hwid}
className="flex items-center justify-between rounded-[12px] p-3.5"
style={{
background: g.innerBg,
border: `1px solid ${g.innerBorder}`,
}}
>
<div className="flex items-center gap-3">
<div
className="flex h-9 w-9 items-center justify-center rounded-[10px]"
style={{ background: g.trackBg }}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke={g.textSecondary}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
</svg>
</div>
<div>
<div className="text-sm font-semibold text-dark-50">
{device.device_model || device.platform}
</div>
<div className="text-[11px] text-dark-50/30">{device.platform}</div>
</div>
</div>
<button
onClick={() => {
if (confirm(t('subscription.confirmDeleteDevice'))) {
deleteDeviceMutation.mutate(device.hwid);
}
}}
disabled={deleteDeviceMutation.isPending}
className="p-2 transition-colors"
style={{ color: g.textFaint }}
title={t('subscription.deleteDevice')}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
</button>
</div>
))}
</div>
) : (
<div className="py-8 text-center text-[12px] text-dark-50/25">
{t('subscription.noDevices')}
</div>
)}
</div>
)}
{/* Tariffs Section - Combined Purchase/Extend/Switch like MiniApp */}
{isTariffsMode && tariffs.length > 0 && (
<div
ref={tariffsCardRef}
className="relative overflow-hidden rounded-3xl"
style={{
background: g.cardBg,
border: `1px solid ${g.cardBorder}`,
boxShadow: g.shadow,
padding: '24px 28px',
}}
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-bold tracking-tight text-dark-50">
{subscription?.is_daily && !subscription?.is_trial
? t('subscription.switchTariff.title')
: subscription && !subscription.is_trial
? t('subscription.extend')
: t('subscription.getSubscription')}
</h2>
</div>
{/* Trial upgrade prompt */}
{subscription?.is_trial && (
<div
className="mb-6 rounded-[14px] p-4"
style={{
background: 'linear-gradient(135deg, rgba(255,184,0,0.08), rgba(62,219,176,0.06))',
border: '1px solid rgba(255,184,0,0.15)',
}}
>
<div className="flex items-start gap-3">
<div
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px]"
style={{ background: 'rgba(255,184,0,0.12)' }}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="#FFB800"
strokeWidth="1.5"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
/>
</svg>
</div>
<div>
<div className="text-sm font-semibold" style={{ color: '#FFB800' }}>
{t('subscription.trialUpgrade.title')}
</div>
<div className="mt-1 text-[12px] text-dark-50/40">
{t('subscription.trialUpgrade.description')}
</div>
</div>
</div>
</div>
)}
{/* Expired subscription notice - prompt to purchase new tariff */}
{isTariffsMode &&
purchaseOptions &&
'subscription_is_expired' in purchaseOptions &&
purchaseOptions.subscription_is_expired && (
<div
className="mb-6 rounded-[14px] p-4"
style={{
background: 'linear-gradient(135deg, rgba(255,59,92,0.08), rgba(255,184,0,0.06))',
border: '1px solid rgba(255,59,92,0.15)',
}}
>
<div className="flex items-start gap-3">
<div
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px]"
style={{ background: 'rgba(255,59,92,0.12)' }}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="#FF3B5C"
strokeWidth="1.5"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
</div>
<div>
<div className="text-sm font-semibold" style={{ color: '#FF3B5C' }}>
{t('subscription.expiredBanner.title')}
</div>
<div className="mt-1 text-[12px] text-dark-50/40">
{t('subscription.expiredBanner.selectTariff')}
</div>
</div>
</div>
</div>
)}
{/* Legacy subscription notice - if user has subscription without tariff */}
{subscription && !subscription.is_trial && !subscription.tariff_id && (
<div className="mb-6 rounded-xl border border-accent-500/30 bg-accent-500/10 p-4">
<div className="mb-2 font-medium text-accent-400">
{t('subscription.legacy.selectTariffTitle')}
</div>
<div className="text-sm text-dark-300">
{t('subscription.legacy.selectTariffDescription')}
</div>
<div className="mt-2 text-xs text-dark-500">
{t('subscription.legacy.currentSubContinues')}
</div>
</div>
)}
{/* Switch Tariff Preview Modal */}
{switchTariffId && (
<div ref={switchModalRef} className="mb-6 space-y-4 rounded-xl bg-dark-800/50 p-5">
<div className="flex items-center justify-between">
<h3 className="font-medium text-dark-100">
{t('subscription.switchTariff.title')}
</h3>
<button
onClick={() => setSwitchTariffId(null)}
className="text-sm text-dark-400 hover:text-dark-200"
>
</button>
</div>
{switchPreviewLoading ? (
<div className="flex items-center justify-center py-4">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : (
switchPreview &&
(() => {
// Find the target tariff to get daily price info
const targetTariff = tariffs.find((t) => t.id === switchTariffId);
const dailyPrice =
targetTariff?.daily_price_kopeks ?? targetTariff?.price_per_day_kopeks ?? 0;
const isDailyTariff = dailyPrice > 0;
return (
<>
<div className="space-y-2 text-sm">
<div className="flex justify-between text-dark-300">
<span>{t('subscription.switchTariff.currentTariff')}</span>
<span className="font-medium text-dark-100">
{switchPreview.current_tariff_name || '-'}
</span>
</div>
<div className="flex justify-between text-dark-300">
<span>{t('subscription.switchTariff.newTariff')}</span>
<span className="font-medium text-accent-400">
{switchPreview.new_tariff_name}
</span>
</div>
<div className="flex justify-between text-dark-300">
<span>{t('subscription.switchTariff.remainingDays')}</span>
<span>{switchPreview.remaining_days}</span>
</div>
</div>
{/* Daily tariff info */}
{isDailyTariff && (
<div className="rounded-lg border border-accent-500/30 bg-accent-500/10 p-3 text-center">
<div className="text-sm text-dark-300">
{t('subscription.switchTariff.dailyPayment')}
</div>
<div className="text-lg font-bold text-accent-400">
{formatPrice(dailyPrice)}
</div>
<div className="mt-1 text-xs text-dark-400">
{t('subscription.switchTariff.dailyChargeDescription')}
</div>
</div>
)}
<div className="flex items-center justify-between border-t border-dark-700/50 pt-3">
<div>
<span className="font-medium text-dark-100">
{t('subscription.switchTariff.upgradeCost')}
</span>
{/* Discount badge */}
{switchPreview.discount_percent && switchPreview.discount_percent > 0 && (
<span className="ml-2 inline-block rounded-full bg-green-500/20 px-2 py-0.5 text-xs font-medium text-green-400">
-{switchPreview.discount_percent}%
</span>
)}
</div>
<div className="text-right">
{/* Show original price with strikethrough if discount */}
{switchPreview.discount_percent &&
switchPreview.discount_percent > 0 &&
switchPreview.base_upgrade_cost_kopeks &&
switchPreview.base_upgrade_cost_kopeks > 0 && (
<span className="mr-2 text-sm text-dark-500 line-through">
{formatPrice(switchPreview.base_upgrade_cost_kopeks)}
</span>
)}
<span
className={`text-lg font-bold ${switchPreview.upgrade_cost_kopeks === 0 ? 'text-green-400' : 'text-accent-400'}`}
>
{switchPreview.upgrade_cost_kopeks > 0
? switchPreview.upgrade_cost_label
: t('subscription.switchTariff.free')}
</span>
</div>
</div>
{!switchPreview.has_enough_balance &&
switchPreview.upgrade_cost_kopeks > 0 && (
<InsufficientBalancePrompt
missingAmountKopeks={switchPreview.missing_amount_kopeks}
compact
/>
)}
<button
onClick={() => switchTariffMutation.mutate(switchTariffId)}
disabled={switchTariffMutation.isPending || !switchPreview.can_switch}
className="btn-primary w-full py-2.5"
>
{switchTariffMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
</span>
) : (
t('subscription.switchTariff.switch')
)}
</button>
{/* Show error (except subscription_expired which redirects to purchase) */}
{switchTariffMutation.isError &&
(() => {
const detail =
switchTariffMutation.error instanceof AxiosError
? switchTariffMutation.error.response?.data?.detail
: null;
// Skip displaying if it's subscription_expired (handled by redirect)
if (
typeof detail === 'object' &&
detail?.error_code === 'subscription_expired'
) {
return null;
}
return (
<div className="mt-3 text-center text-sm text-error-400">
{getErrorMessage(switchTariffMutation.error)}
</div>
);
})()}
</>
);
})()
)}
</div>
)}
{!showTariffPurchase ? (
<>
{/* Promo group discount banner */}
{tariffs.some((t) => t.promo_group_name) && (
<div className="mb-4 flex items-center gap-3 rounded-xl border border-success-500/30 bg-success-500/10 p-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-success-500/20 text-success-400">
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7"
/>
</svg>
</div>
<div>
<div className="text-sm font-medium text-success-400">
{t('subscription.promoGroup.yourGroup', {
name: tariffs.find((t) => t.promo_group_name)?.promo_group_name,
})}
</div>
<div className="text-xs text-dark-400">
{t('subscription.promoGroup.personalDiscountsApplied')}
</div>
</div>
</div>
)}
{/* Tariff List - current tariff first */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{[...tariffs]
// Hide Trial tariffs for users who already have trial subscription
.filter((tariff) => {
if (subscription?.is_trial && tariff.name.toLowerCase().includes('trial')) {
return false;
}
return true;
})
.sort((a, b) => {
const aIsCurrent = a.is_current || a.id === subscription?.tariff_id;
const bIsCurrent = b.is_current || b.id === subscription?.tariff_id;
if (aIsCurrent && !bIsCurrent) return -1;
if (!aIsCurrent && bIsCurrent) return 1;
return 0;
})
.map((tariff) => {
const isCurrentTariff =
tariff.is_current || tariff.id === subscription?.tariff_id;
// Check if subscription is expired from purchaseOptions
const isSubscriptionExpired =
isTariffsMode &&
purchaseOptions &&
'subscription_is_expired' in purchaseOptions &&
purchaseOptions.subscription_is_expired === true;
// canSwitch only if subscription is active (not expired, not trial)
const canSwitch =
subscription &&
subscription.tariff_id &&
!isCurrentTariff &&
!subscription.is_trial &&
!isSubscriptionExpired &&
subscription.is_active;
// Если есть подписка БЕЗ tariff_id (классическая) - разрешить выбрать тариф
const isLegacySubscription =
subscription && !subscription.is_trial && !subscription.tariff_id;
return (
<div
key={tariff.id}
className={`bento-card-hover p-5 text-left transition-all ${
isCurrentTariff ? 'bento-card-glow border-accent-500' : ''
}`}
>
<div className="mb-3 flex items-start justify-between">
<div>
<div className="text-lg font-semibold text-dark-100">{tariff.name}</div>
{tariff.description && (
<div className="mt-1 whitespace-pre-line text-sm text-dark-400">
{tariff.description}
</div>
)}
</div>
{isCurrentTariff && (
<span className="badge-success text-xs">
{t('subscription.currentTariff')}
</span>
)}
</div>
<div className="flex flex-wrap gap-4 text-sm">
{/* Traffic */}
<div className="flex items-center gap-1.5">
<svg
className="h-4 w-4 text-accent-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
/>
</svg>
<span className="font-medium text-dark-200">
{tariff.traffic_limit_label}
</span>
</div>
{/* Devices */}
<div className="flex items-center gap-1.5">
<svg
className="h-4 w-4 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"
/>
</svg>
<span className="text-dark-300">
{t('subscription.devices', { count: tariff.device_limit })}
</span>
</div>
{/* Traffic Reset */}
{tariff.traffic_reset_mode &&
tariff.traffic_reset_mode !== 'NO_RESET' && (
<div className="flex items-center gap-1.5">
<svg
className="h-4 w-4 text-dark-400"
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 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182M2.985 19.644l3.181-3.182"
/>
</svg>
<span className="text-dark-300">
{t(`subscription.trafficReset.${tariff.traffic_reset_mode}`)}
</span>
</div>
)}
</div>
{/* Price info - daily or period-based */}
<div className="mt-3 border-t border-dark-700/50 pt-3 text-sm text-dark-400">
{(() => {
// Daily tariff price (is_daily + daily_price_kopeks)
const dailyPrice =
tariff.daily_price_kopeks ?? tariff.price_per_day_kopeks ?? 0;
const originalDailyPrice = tariff.original_daily_price_kopeks || 0;
if (dailyPrice > 0) {
const promoDaily = applyPromoDiscount(
dailyPrice,
originalDailyPrice > dailyPrice ? originalDailyPrice : undefined,
);
return (
<span className="flex items-center gap-2">
<span className="font-medium text-accent-400">
{formatPrice(promoDaily.price)}
</span>
{promoDaily.original &&
promoDaily.original > promoDaily.price && (
<span className="text-xs text-dark-500 line-through">
{formatPrice(promoDaily.original)}
</span>
)}
<span>{t('subscription.tariff.perDay')}</span>
{/* Show discount badge */}
{promoDaily.percent && promoDaily.percent > 0 && (
<span
className={`rounded px-1.5 py-0.5 text-xs ${
promoDaily.isPromoGroup
? 'bg-success-500/20 text-success-400'
: 'bg-orange-500/20 text-orange-400'
}`}
>
-{promoDaily.percent}%
</span>
)}
</span>
);
}
// Period-based price
if (tariff.periods.length > 0) {
const firstPeriod = tariff.periods[0];
const promoPeriod = applyPromoDiscount(
firstPeriod?.price_kopeks || 0,
firstPeriod?.original_price_kopeks,
);
return (
<span className="flex flex-wrap items-center gap-2">
<span>{t('subscription.from')}</span>
<span className="font-medium text-accent-400">
{formatPrice(promoPeriod.price)}
</span>
{promoPeriod.original &&
promoPeriod.original > promoPeriod.price && (
<span className="text-xs text-dark-500 line-through">
{formatPrice(promoPeriod.original)}
</span>
)}
{promoPeriod.percent && promoPeriod.percent > 0 && (
<span
className={`rounded px-1.5 py-0.5 text-xs ${
promoPeriod.isPromoGroup
? 'bg-success-500/20 text-success-400'
: 'bg-orange-500/20 text-orange-400'
}`}
>
-{promoPeriod.percent}%
</span>
)}
</span>
);
}
// Fallback
return (
<span className="font-medium text-accent-400">
{t('subscription.tariff.flexiblePayment')}
</span>
);
})()}
</div>
{/* Action Buttons */}
<div className="mt-4 flex gap-2">
{isCurrentTariff ? (
/* Current tariff - extend button (hide for daily tariffs) */
subscription?.is_daily ? (
<div className="flex-1 py-2 text-center text-sm text-dark-500">
{t('subscription.currentTariff')}
</div>
) : (
<button
onClick={() => {
setSelectedTariff(tariff);
setSelectedTariffPeriod(tariff.periods[0] || null);
setShowTariffPurchase(true);
}}
className="btn-primary flex-1 py-2 text-sm"
>
{t('subscription.extend')}
</button>
)
) : isLegacySubscription ? (
/* Legacy subscription without tariff - allow selecting tariff for renewal */
<button
onClick={() => {
setSelectedTariff(tariff);
setSelectedTariffPeriod(tariff.periods[0] || null);
setShowTariffPurchase(true);
}}
className="btn-primary flex-1 py-2 text-sm"
>
{t('subscription.tariff.selectForRenewal')}
</button>
) : canSwitch ? (
/* Other tariffs with existing tariff - switch button */
<button
onClick={() => setSwitchTariffId(tariff.id)}
className="btn-secondary flex-1 py-2 text-sm"
>
{t('subscription.switchTariff.switch')}
</button>
) : (
/* No subscription or trial - purchase button */
<button
onClick={() => {
setSelectedTariff(tariff);
setSelectedTariffPeriod(tariff.periods[0] || null);
setShowTariffPurchase(true);
}}
className="btn-primary flex-1 py-2 text-sm"
>
{t('subscription.purchase')}
</button>
)}
</div>
</div>
);
})}
</div>
</>
) : (
selectedTariff && (
/* Tariff Purchase Form */
<div ref={tariffPurchaseRef} className="space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-dark-100">{selectedTariff.name}</h3>
<button
onClick={() => {
setShowTariffPurchase(false);
setSelectedTariff(null);
setSelectedTariffPeriod(null);
}}
className="text-dark-400 hover:text-dark-200"
>
{t('common.back')}
</button>
</div>
{/* Tariff Info */}
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="flex flex-wrap gap-4 text-sm">
<div>
<span className="text-dark-500">{t('subscription.traffic')}:</span>
<span className="ml-2 text-dark-200">
{selectedTariff.traffic_limit_label}
</span>
</div>
<div>
<span className="text-dark-500">{t('subscription.devices')}:</span>
<span className="ml-2 text-dark-200">
{selectedTariff.device_limit}
{selectedTariff.extra_devices_count > 0 && (
<span className="ml-1 text-xs text-accent-400">
(+{selectedTariff.extra_devices_count})
</span>
)}
</span>
</div>
</div>
</div>
{/* Daily Tariff Purchase */}
{selectedTariff.is_daily ||
(selectedTariff.daily_price_kopeks && selectedTariff.daily_price_kopeks > 0) ? (
<div className="rounded-xl border border-accent-500/30 bg-accent-500/10 p-5">
<div className="mb-4 text-center">
<div className="mb-2 text-sm text-dark-400">
{t('subscription.dailyPurchase.costPerDay')}
</div>
<div className="text-3xl font-bold text-accent-400">
{formatPrice(selectedTariff.daily_price_kopeks || 0)}
</div>
</div>
<div className="space-y-2 text-sm text-dark-400">
<div className="flex items-start gap-2">
<span className="text-accent-400"></span>
<span>{t('subscription.dailyPurchase.chargedDaily')}</span>
</div>
<div className="flex items-start gap-2">
<span className="text-accent-400"></span>
<span>{t('subscription.dailyPurchase.canPause')}</span>
</div>
<div className="flex items-start gap-2">
<span className="text-accent-400"></span>
<span>{t('subscription.dailyPurchase.pausedOnLowBalance')}</span>
</div>
</div>
{/* Purchase button for daily tariff */}
{(() => {
const dailyPrice = selectedTariff.daily_price_kopeks || 0;
const hasEnoughBalance =
purchaseOptions && dailyPrice <= purchaseOptions.balance_kopeks;
return (
<div className="mt-6">
{purchaseOptions && !hasEnoughBalance && (
<InsufficientBalancePrompt
missingAmountKopeks={dailyPrice - purchaseOptions.balance_kopeks}
compact
className="mb-4"
/>
)}
<button
onClick={() => tariffPurchaseMutation.mutate()}
disabled={tariffPurchaseMutation.isPending}
className="btn-primary w-full py-3"
>
{tariffPurchaseMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
{t('common.loading')}
</span>
) : (
t('subscription.dailyPurchase.activate', {
price: formatPrice(dailyPrice),
})
)}
</button>
{tariffPurchaseMutation.isError &&
!getInsufficientBalanceError(tariffPurchaseMutation.error) && (
<div className="mt-3 text-center text-sm text-error-400">
{getErrorMessage(tariffPurchaseMutation.error)}
</div>
)}
{tariffPurchaseMutation.isError &&
getInsufficientBalanceError(tariffPurchaseMutation.error) && (
<div className="mt-3">
<InsufficientBalancePrompt
missingAmountKopeks={
getInsufficientBalanceError(tariffPurchaseMutation.error)
?.missingAmount ||
dailyPrice - (purchaseOptions?.balance_kopeks || 0)
}
compact
/>
</div>
)}
</div>
);
})()}
</div>
) : (
<>
{/* Period Selection for non-daily tariffs */}
<div>
<div className="mb-3 text-sm text-dark-400">
{t('subscription.selectPeriod')}
</div>
{/* Fixed periods */}
{selectedTariff.periods.length > 0 && !useCustomDays && (
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
{selectedTariff.periods.map((period) => {
const promoPeriod = applyPromoDiscount(
period.price_kopeks,
period.original_price_kopeks,
);
const displayDiscount = promoPeriod.percent;
const displayOriginal = promoPeriod.original;
const displayPrice = promoPeriod.price;
const displayPerMonth =
displayPrice !== period.price_kopeks
? Math.round(displayPrice / Math.max(1, period.days / 30))
: period.price_per_month_kopeks;
return (
<button
key={period.days}
onClick={() => {
setSelectedTariffPeriod(period);
setUseCustomDays(false);
}}
className={`relative rounded-xl border p-4 text-left transition-all ${
selectedTariffPeriod?.days === period.days && !useCustomDays
? 'border-accent-500 bg-accent-500/10'
: 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
}`}
>
{displayDiscount && displayDiscount > 0 && (
<div
className={`absolute -right-2 -top-2 rounded-full px-2 py-0.5 text-xs font-medium text-white ${
promoPeriod.isPromoGroup ? 'bg-success-500' : 'bg-orange-500'
}`}
>
-{displayDiscount}%
</div>
)}
<div className="text-lg font-semibold text-dark-100">
{period.label}
</div>
<div className="flex items-center gap-2">
<span className="font-medium text-accent-400">
{formatPrice(displayPrice)}
</span>
{displayOriginal && displayOriginal > displayPrice && (
<span className="text-sm text-dark-500 line-through">
{formatPrice(displayOriginal)}
</span>
)}
</div>
<div className="mt-1 text-xs text-dark-500">
{formatPrice(displayPerMonth)}/{t('subscription.month')}
</div>
</button>
);
})}
</div>
)}
{/* Custom days option */}
{selectedTariff.custom_days_enabled &&
(selectedTariff.price_per_day_kopeks ?? 0) > 0 && (
<div className="rounded-xl border border-dark-700/50 bg-dark-800/50 p-4">
<div className="mb-3 flex items-center justify-between">
<span className="font-medium text-dark-200">
{t('subscription.customDays.title')}
</span>
<button
type="button"
onClick={() => setUseCustomDays(!useCustomDays)}
className={`relative h-6 w-10 rounded-full transition-colors ${
useCustomDays ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
useCustomDays ? 'left-5' : 'left-1'
}`}
/>
</button>
</div>
{useCustomDays && (
<div className="space-y-3">
<div className="flex items-center gap-4">
<input
type="range"
min={selectedTariff.min_days ?? 1}
max={selectedTariff.max_days ?? 365}
value={customDays}
onChange={(e) => setCustomDays(parseInt(e.target.value))}
className="flex-1 accent-accent-500"
/>
<input
type="number"
value={customDays}
min={selectedTariff.min_days ?? 1}
max={selectedTariff.max_days ?? 365}
onChange={(e) =>
setCustomDays(
Math.max(
selectedTariff.min_days ?? 1,
Math.min(
selectedTariff.max_days ?? 365,
parseInt(e.target.value) ||
(selectedTariff.min_days ?? 1),
),
),
)
}
className="w-20 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-center text-dark-100"
/>
</div>
{(() => {
const basePrice =
customDays * (selectedTariff.price_per_day_kopeks ?? 0);
const existingOriginal =
selectedTariff.original_price_per_day_kopeks &&
selectedTariff.original_price_per_day_kopeks >
(selectedTariff.price_per_day_kopeks ?? 0)
? customDays * selectedTariff.original_price_per_day_kopeks
: undefined;
const promoCustom = applyPromoDiscount(
basePrice,
existingOriginal,
);
return (
<div className="flex justify-between text-sm">
<span className="text-dark-400">
{t('subscription.days', { count: customDays })} ×{' '}
{formatPrice(selectedTariff.price_per_day_kopeks ?? 0)}/
{t('subscription.customDays.perDay')}
</span>
<div className="flex items-center gap-2">
<span className="font-medium text-accent-400">
{formatPrice(promoCustom.price)}
</span>
{promoCustom.original &&
promoCustom.original > promoCustom.price && (
<>
<span className="text-xs text-dark-500 line-through">
{formatPrice(promoCustom.original)}
</span>
<span
className={`rounded px-1.5 py-0.5 text-xs ${
promoCustom.isPromoGroup
? 'bg-success-500/20 text-success-400'
: 'bg-orange-500/20 text-orange-400'
}`}
>
-{promoCustom.percent}%
</span>
</>
)}
</div>
</div>
);
})()}
</div>
)}
</div>
)}
</div>
{/* Custom traffic option */}
{selectedTariff.custom_traffic_enabled &&
(selectedTariff.traffic_price_per_gb_kopeks ?? 0) > 0 && (
<div>
<div className="mb-3 text-sm text-dark-400">
{t('subscription.customTraffic.label')}
</div>
<div className="rounded-xl border border-dark-700/50 bg-dark-800/50 p-4">
<div className="mb-3 flex items-center justify-between">
<span className="font-medium text-dark-200">
{t('subscription.customTraffic.selectVolume')}
</span>
<button
type="button"
onClick={() => setUseCustomTraffic(!useCustomTraffic)}
className={`relative h-6 w-10 rounded-full transition-colors ${
useCustomTraffic ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
useCustomTraffic ? 'left-5' : 'left-1'
}`}
/>
</button>
</div>
{!useCustomTraffic && (
<div className="text-sm text-dark-400">
{t('subscription.customTraffic.default', {
label: selectedTariff.traffic_limit_label,
})}
</div>
)}
{useCustomTraffic && (
<div className="space-y-3">
<div className="flex items-center gap-4">
<input
type="range"
min={selectedTariff.min_traffic_gb ?? 1}
max={selectedTariff.max_traffic_gb ?? 1000}
value={customTrafficGb}
onChange={(e) => setCustomTrafficGb(parseInt(e.target.value))}
className="flex-1 accent-accent-500"
/>
<div className="flex items-center gap-2">
<input
type="number"
value={customTrafficGb}
min={selectedTariff.min_traffic_gb ?? 1}
max={selectedTariff.max_traffic_gb ?? 1000}
onChange={(e) =>
setCustomTrafficGb(
Math.max(
selectedTariff.min_traffic_gb ?? 1,
Math.min(
selectedTariff.max_traffic_gb ?? 1000,
parseInt(e.target.value) ||
(selectedTariff.min_traffic_gb ?? 1),
),
),
)
}
className="w-20 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-center text-dark-100"
/>
<span className="text-dark-400">{t('common.units.gb')}</span>
</div>
</div>
<div className="flex justify-between text-sm">
<span className="text-dark-400">
{customTrafficGb} {t('common.units.gb')} ×{' '}
{formatPrice(selectedTariff.traffic_price_per_gb_kopeks ?? 0)}/
{t('common.units.gb')}
</span>
<span className="font-medium text-accent-400">
+
{formatPrice(
customTrafficGb *
(selectedTariff.traffic_price_per_gb_kopeks ?? 0),
)}
</span>
</div>
</div>
)}
</div>
</div>
)}
{/* Summary & Purchase */}
{(selectedTariffPeriod || useCustomDays) && (
<div className="rounded-xl bg-dark-800/50 p-5">
{(() => {
// Calculate prices with promo discount
const basePeriodPrice = useCustomDays
? customDays * (selectedTariff.price_per_day_kopeks ?? 0)
: selectedTariffPeriod?.price_kopeks || 0;
const existingPeriodOriginal = useCustomDays
? selectedTariff.original_price_per_day_kopeks &&
selectedTariff.original_price_per_day_kopeks >
(selectedTariff.price_per_day_kopeks ?? 0)
? customDays * selectedTariff.original_price_per_day_kopeks
: undefined
: selectedTariffPeriod?.original_price_kopeks &&
selectedTariffPeriod.original_price_kopeks >
selectedTariffPeriod.price_kopeks
? selectedTariffPeriod.original_price_kopeks
: undefined;
const promoPeriod = applyPromoDiscount(
basePeriodPrice,
existingPeriodOriginal,
);
const trafficPrice =
useCustomTraffic && selectedTariff.custom_traffic_enabled
? customTrafficGb * (selectedTariff.traffic_price_per_gb_kopeks ?? 0)
: 0;
const totalPrice = promoPeriod.price + trafficPrice;
const originalTotal = promoPeriod.original
? promoPeriod.original + trafficPrice
: null;
return (
<>
{/* Price breakdown */}
<div className="mb-4 space-y-2">
{useCustomDays ? (
<div className="flex justify-between text-sm text-dark-300">
<span>
{t('subscription.stepPeriod')}:{' '}
{t('subscription.days', { count: customDays })}
</span>
<div className="flex items-center gap-2">
<span>{formatPrice(promoPeriod.price)}</span>
{promoPeriod.original &&
promoPeriod.original > promoPeriod.price && (
<span className="text-xs text-dark-500 line-through">
{formatPrice(promoPeriod.original)}
</span>
)}
</div>
</div>
) : (
selectedTariffPeriod && (
<>
{/* Если есть доп. устройства - показываем разбивку */}
{(selectedTariffPeriod.extra_devices_count ?? 0) > 0 &&
selectedTariffPeriod.base_tariff_price_kopeks ? (
<>
<div className="flex justify-between text-sm text-dark-300">
<span>
{t('subscription.baseTariff')}:{' '}
{selectedTariffPeriod.label}
</span>
<span>
{formatPrice(
selectedTariffPeriod.base_tariff_price_kopeks,
)}
</span>
</div>
<div className="flex justify-between text-sm text-dark-300">
<span>
{t('subscription.extraDevices')} (
{selectedTariffPeriod.extra_devices_count})
</span>
<span>
+
{formatPrice(
selectedTariffPeriod.extra_devices_cost_kopeks ?? 0,
)}
</span>
</div>
</>
) : (
<div className="flex justify-between text-sm text-dark-300">
<span>
{t('subscription.summary.period', {
label: selectedTariffPeriod.label,
})}
</span>
<div className="flex items-center gap-2">
<span>{formatPrice(promoPeriod.price)}</span>
{promoPeriod.original &&
promoPeriod.original > promoPeriod.price && (
<span className="text-xs text-dark-500 line-through">
{formatPrice(promoPeriod.original)}
</span>
)}
</div>
</div>
)}
</>
)
)}
{useCustomTraffic && selectedTariff.custom_traffic_enabled && (
<div className="flex justify-between text-sm text-dark-300">
<span>
{t('subscription.summary.traffic', { gb: customTrafficGb })}
</span>
<span>+{formatPrice(trafficPrice)}</span>
</div>
)}
</div>
{/* Promo discount info */}
{promoPeriod.percent && (
<div className="mb-4 flex items-center justify-center gap-2 rounded-lg border border-orange-500/30 bg-orange-500/10 p-2">
<span className="text-sm font-medium text-orange-400">
{t('promo.discountApplied')} -{promoPeriod.percent}%
</span>
</div>
)}
<div className="mb-4 flex items-center justify-between border-t border-dark-700/50 pt-2">
<span className="font-medium text-dark-100">
{t('subscription.total')}
</span>
<div className="text-right">
<span className="text-2xl font-bold text-accent-400">
{formatPrice(totalPrice)}
</span>
{originalTotal && (
<div className="text-sm text-dark-500 line-through">
{formatPrice(originalTotal)}
</div>
)}
</div>
</div>
<button
onClick={() => tariffPurchaseMutation.mutate()}
disabled={tariffPurchaseMutation.isPending}
className="btn-primary w-full py-3"
>
{tariffPurchaseMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
{t('common.loading')}
</span>
) : (
t('subscription.purchase')
)}
</button>
</>
);
})()}
{tariffPurchaseMutation.isError &&
!getInsufficientBalanceError(tariffPurchaseMutation.error) && (
<div className="mt-3 text-center text-sm text-error-400">
{getErrorMessage(tariffPurchaseMutation.error)}
</div>
)}
{tariffPurchaseMutation.isError &&
getInsufficientBalanceError(tariffPurchaseMutation.error) && (
<div className="mt-3">
<InsufficientBalancePrompt
missingAmountKopeks={
getInsufficientBalanceError(tariffPurchaseMutation.error)
?.missingAmount || 0
}
compact
/>
</div>
)}
</div>
)}
</>
)}
</div>
)
)}
</div>
)}
{/* Purchase/Extend Section - Classic Mode */}
{classicOptions && classicOptions.periods.length > 0 && (
<div
ref={tariffsCardRef}
className="relative overflow-hidden rounded-3xl"
style={{
background: g.cardBg,
border: `1px solid ${g.cardBorder}`,
boxShadow: g.shadow,
padding: '24px 28px',
}}
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-bold tracking-tight text-dark-50">
{subscription && !subscription.is_trial
? t('subscription.extend')
: t('subscription.getSubscription')}
</h2>
{!showPurchaseForm && (
<button onClick={() => setShowPurchaseForm(true)} className="btn-primary">
{subscription && !subscription.is_trial
? t('subscription.extend')
: t('subscription.getSubscription')}
</button>
)}
</div>
{showPurchaseForm && (
<div className="space-y-6">
{/* Step Indicator */}
<div className="mb-6 flex items-center justify-between">
<div className="text-sm text-dark-400">
{t('subscription.step', { current: currentStepIndex + 1, total: steps.length })}
</div>
<div className="flex gap-2">
{steps.map((step, idx) => (
<div
key={step}
className={`h-1 w-8 rounded-full transition-colors ${
idx <= currentStepIndex ? 'bg-accent-500' : 'bg-dark-700'
}`}
/>
))}
</div>
</div>
<div className="mb-4 text-lg font-medium text-dark-100">
{getStepLabel(currentStep)}
</div>
{/* Step: Period Selection */}
{currentStep === 'period' && classicOptions && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{classicOptions.periods.map((period) => {
const promoPeriod = applyPromoDiscount(
period.price_kopeks,
period.original_price_kopeks,
);
return (
<button
key={period.id}
onClick={() => {
setSelectedPeriod(period);
if (period.traffic.current !== undefined) {
setSelectedTraffic(period.traffic.current);
}
const availableServers = getAvailableServers(period);
// If only 1 server available, auto-select it (step will be skipped)
if (availableServers.length === 1) {
setSelectedServers([availableServers[0].uuid]);
} else if (period.servers.selected) {
const availUuids = new Set(availableServers.map((s) => s.uuid));
setSelectedServers(
period.servers.selected.filter((uuid) => availUuids.has(uuid)),
);
}
if (period.devices.current) {
setSelectedDevices(period.devices.current);
}
}}
className={`bento-card-hover relative p-4 text-left transition-all ${
selectedPeriod?.id === period.id
? 'bento-card-glow border-accent-500'
: ''
}`}
>
{promoPeriod.percent && promoPeriod.percent > 0 && (
<div
className={`absolute right-2 top-2 z-10 rounded-full px-2 py-0.5 text-xs font-medium text-white shadow-sm ${
promoPeriod.isPromoGroup ? 'bg-success-500' : 'bg-orange-500'
}`}
>
-{promoPeriod.percent}%
</div>
)}
<div className="text-lg font-semibold text-dark-100">{period.label}</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="font-medium text-accent-400">
{formatPrice(promoPeriod.price)}
</span>
{promoPeriod.original && promoPeriod.original > promoPeriod.price && (
<span className="text-sm text-dark-500 line-through">
{formatPrice(promoPeriod.original)}
</span>
)}
</div>
</button>
);
})}
</div>
)}
{/* Step: Traffic Selection */}
{currentStep === 'traffic' && selectedPeriod?.traffic.options && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{selectedPeriod.traffic.options.map((option) => {
const promoTraffic = applyPromoDiscount(
option.price_kopeks,
option.original_price_kopeks,
);
return (
<button
key={option.value}
onClick={() => setSelectedTraffic(option.value)}
disabled={!option.is_available}
className={`bento-card-hover relative p-4 text-center transition-all ${
selectedTraffic === option.value
? 'bento-card-glow border-accent-500'
: ''
} ${!option.is_available ? 'cursor-not-allowed opacity-50' : ''}`}
>
{promoTraffic.percent && promoTraffic.percent > 0 && (
<div
className={`absolute right-2 top-2 z-10 rounded-full px-2 py-0.5 text-xs font-medium text-white shadow-sm ${
promoTraffic.isPromoGroup ? 'bg-success-500' : 'bg-orange-500'
}`}
>
-{promoTraffic.percent}%
</div>
)}
<div className="text-lg font-semibold text-dark-100">{option.label}</div>
<div className="mt-1 flex flex-wrap items-center justify-center gap-x-2 gap-y-1">
<span className="text-accent-400">{formatPrice(promoTraffic.price)}</span>
{promoTraffic.original && promoTraffic.original > promoTraffic.price && (
<span className="text-xs text-dark-500 line-through">
{formatPrice(promoTraffic.original)}
</span>
)}
</div>
</button>
);
})}
</div>
)}
{/* Step: Server Selection */}
{currentStep === 'servers' && selectedPeriod?.servers.options && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{selectedPeriod.servers.options
// Hide unavailable (disabled) servers and trial servers for existing trial users
.filter((server) => {
if (!server.is_available) return false;
if (subscription?.is_trial && server.name.toLowerCase().includes('trial')) {
return false;
}
return true;
})
.map((server) => {
const promoServer = applyPromoDiscount(
server.price_kopeks,
server.original_price_kopeks,
);
return (
<button
key={server.uuid}
onClick={() => toggleServer(server.uuid)}
disabled={!server.is_available}
className={`relative rounded-xl border p-4 text-left transition-all ${
selectedServers.includes(server.uuid)
? 'border-accent-500 bg-accent-500/10'
: server.is_available
? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
: 'cursor-not-allowed border-dark-800/30 bg-dark-900/30 opacity-50'
}`}
>
{promoServer.percent && promoServer.percent > 0 ? (
<div
className={`absolute right-2 top-2 z-10 rounded-full px-2 py-0.5 text-xs font-medium text-white shadow-sm ${
promoServer.isPromoGroup ? 'bg-success-500' : 'bg-orange-500'
}`}
>
-{promoServer.percent}%
</div>
) : null}
<div className="flex items-center gap-3">
<div
className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded border-2 ${
selectedServers.includes(server.uuid)
? 'border-accent-500 bg-accent-500'
: 'border-dark-600'
}`}
>
{selectedServers.includes(server.uuid) && <CheckIcon />}
</div>
<div>
<div className="font-medium text-dark-100">{server.name}</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm text-accent-400">
{formatPrice(promoServer.price)}
{t('subscription.perMonth')}
</span>
{promoServer.original &&
promoServer.original > promoServer.price ? (
<span className="text-xs text-dark-500 line-through">
{formatPrice(promoServer.original)}
</span>
) : null}
</div>
</div>
</div>
</button>
);
})}
</div>
)}
{/* Step: Device Selection */}
{currentStep === 'devices' && selectedPeriod && (
<div className="flex flex-col items-center py-8">
<div className="flex items-center gap-6">
<button
onClick={() =>
setSelectedDevices(
Math.max(selectedPeriod.devices.min, selectedDevices - 1),
)
}
disabled={selectedDevices <= selectedPeriod.devices.min}
className="btn-secondary flex h-14 w-14 items-center justify-center !p-0 text-2xl"
>
-
</button>
<div className="text-center">
<div className="text-5xl font-bold text-dark-100">{selectedDevices}</div>
<div className="mt-2 text-dark-500">{t('subscription.devices')}</div>
</div>
<button
onClick={() =>
setSelectedDevices(
Math.min(selectedPeriod.devices.max, selectedDevices + 1),
)
}
disabled={selectedDevices >= selectedPeriod.devices.max}
className="btn-secondary flex h-14 w-14 items-center justify-center !p-0 text-2xl"
>
+
</button>
</div>
<div className="mt-4 space-y-1 text-center text-sm text-dark-500">
<div className="text-accent-400">
{t('subscription.devicesFree', { count: selectedPeriod.devices.min })}
</div>
{selectedPeriod.devices.max > selectedPeriod.devices.min && (
<div>
{formatPrice(selectedPeriod.devices.price_per_device_kopeks)}{' '}
{t('subscription.perExtraDevice')}
</div>
)}
</div>
</div>
)}
{/* Step: Confirm */}
{currentStep === 'confirm' && (
<div>
{previewLoading ? (
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : preview ? (
<div className="space-y-4 rounded-xl bg-dark-800/50 p-5">
{/* Active promo discount banner */}
{activeDiscount?.is_active && activeDiscount.discount_percent && (
<div className="flex items-center justify-center gap-2 rounded-lg border border-orange-500/30 bg-orange-500/10 p-3">
<svg
className="h-4 w-4 text-orange-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
/>
</svg>
<span className="text-sm font-medium text-orange-400">
{t('promo.discountApplied')} -{activeDiscount.discount_percent}%
</span>
</div>
)}
{preview.breakdown.map((item, idx) => (
<div key={idx} className="flex justify-between text-dark-300">
<span>{item.label}</span>
<span>{item.value}</span>
</div>
))}
{(() => {
const promoTotal = applyPromoDiscount(
preview.total_price_kopeks,
preview.original_price_kopeks,
);
return (
<div className="flex items-center justify-between border-t border-dark-700/50 pt-4">
<span className="text-lg font-semibold text-dark-100">
{t('subscription.total')}
</span>
<div className="text-right">
<div className="text-2xl font-bold text-accent-400">
{formatPrice(promoTotal.price)}
</div>
{promoTotal.original && promoTotal.original > promoTotal.price && (
<div className="text-sm text-dark-500 line-through">
{formatPrice(promoTotal.original)}
</div>
)}
</div>
</div>
);
})()}
{preview.discount_label && (
<div className="text-center text-sm text-success-400">
{preview.discount_label}
</div>
)}
{!preview.can_purchase &&
(preview.missing_amount_kopeks > 0 ? (
<InsufficientBalancePrompt
missingAmountKopeks={preview.missing_amount_kopeks}
compact
/>
) : preview.status_message ? (
<div className="rounded-lg bg-error-500/10 px-4 py-3 text-center text-sm text-error-400">
{preview.status_message}
</div>
) : null)}
</div>
) : null}
</div>
)}
{/* Navigation Buttons */}
<div className="flex gap-3 border-t border-dark-800/50 pt-4">
{!isFirstStep && (
<button onClick={goToPrevStep} className="btn-secondary flex-1">
{t('common.back')}
</button>
)}
{isFirstStep && (
<button onClick={resetPurchase} className="btn-secondary">
{t('common.cancel')}
</button>
)}
{!isLastStep ? (
<button
onClick={goToNextStep}
disabled={!selectedPeriod}
className="btn-primary flex-1"
>
{t('common.next')}
</button>
) : (
<button
onClick={() => purchaseMutation.mutate()}
disabled={
purchaseMutation.isPending || previewLoading || !preview?.can_purchase
}
className="btn-primary flex-1"
>
{purchaseMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
{t('common.loading')}
</span>
) : (
t('subscription.purchase')
)}
</button>
)}
</div>
{purchaseMutation.isError && (
<div className="text-center text-sm text-error-400">
{getErrorMessage(purchaseMutation.error)}
</div>
)}
</div>
)}
</div>
)}
</div>
);
}