From 126f9ab9b9dae357876951d6ae6077587c544be4 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 27 Feb 2026 06:47:30 +0300 Subject: [PATCH] refactor: extract purchase/renewal flow to separate page Move ~1900 lines of purchase/renewal logic from Subscription.tsx into a new SubscriptionPurchase.tsx page at /subscription/purchase. - Create SubscriptionPurchase.tsx with tariffs grid, classic mode wizard, switch preview modal, and promo handling - Create PurchaseCTAButton.tsx with animated gradient border, state-aware colors and context-aware text - Create subscriptionHelpers.ts with shared utilities - Add CopyIcon to shared icons module - Register /subscription/purchase route with lazy loading - Update SubscriptionCardExpired and PromoOffersSection navigators - Add CTA translation keys to ru.json and en.json - Remove all scrollToExtend references --- src/App.tsx | 11 + src/components/PromoOffersSection.tsx | 2 +- .../dashboard/SubscriptionCardExpired.tsx | 3 +- src/components/icons/index.tsx | 16 + .../subscription/PurchaseCTAButton.tsx | 93 + src/locales/en.json | 5 + src/locales/ru.json | 5 + src/pages/Subscription.tsx | 1956 +---------------- src/pages/SubscriptionPurchase.tsx | 1900 ++++++++++++++++ src/utils/subscriptionHelpers.ts | 42 + 10 files changed, 2091 insertions(+), 1942 deletions(-) create mode 100644 src/components/subscription/PurchaseCTAButton.tsx create mode 100644 src/pages/SubscriptionPurchase.tsx create mode 100644 src/utils/subscriptionHelpers.ts diff --git a/src/App.tsx b/src/App.tsx index aee6f56..04adf9e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,7 @@ import Dashboard from './pages/Dashboard'; // User pages - lazy load const Subscription = lazy(() => import('./pages/Subscription')); +const SubscriptionPurchase = lazy(() => import('./pages/SubscriptionPurchase')); const Balance = lazy(() => import('./pages/Balance')); const Referral = lazy(() => import('./pages/Referral')); const Support = lazy(() => import('./pages/Support')); @@ -203,6 +204,16 @@ function App() { } /> + + + + + + } + /> { - navigate('/subscription', { state: { scrollToExtend: true } }); + navigate('/subscription/purchase'); }; const handleDeactivateClick = () => { diff --git a/src/components/dashboard/SubscriptionCardExpired.tsx b/src/components/dashboard/SubscriptionCardExpired.tsx index 1a18c5a..57f5329 100644 --- a/src/components/dashboard/SubscriptionCardExpired.tsx +++ b/src/components/dashboard/SubscriptionCardExpired.tsx @@ -139,8 +139,7 @@ export default function SubscriptionCardExpired({ subscription }: SubscriptionCa {/* Action buttons */}
( ); +export const CopyIcon = ({ className }: IconProps) => ( + + + +); + export const XIcon = ({ className }: IconProps) => ( + +
+ {/* Left: icon + text */} +
+ {/* Sparkle icon */} +
+ +
+
+
{buttonText}
+
{hintText}
+
+
+ + {/* Right: chevron */} + +
+ + + ); +} diff --git a/src/locales/en.json b/src/locales/en.json index 105f662..e7d818b 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -318,6 +318,11 @@ "price": "Price", "noSubscription": "You don't have an active subscription", "getSubscription": "Get Subscription", + "cta": { + "expiredHint": "Choose a plan and pay", + "trialHint": "More traffic and devices", + "activeHint": "Renewal and plan change" + }, "connectionInfo": "Connection Info", "copyLink": "Copy Link", "copied": "Copied!", diff --git a/src/locales/ru.json b/src/locales/ru.json index d6d06d0..b4cae25 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -336,6 +336,11 @@ "price": "Цена", "noSubscription": "У вас нет активной подписки", "getSubscription": "Оформить подписку", + "cta": { + "expiredHint": "Выберите тариф и оплатите", + "trialHint": "Больше трафика и устройств", + "activeHint": "Продление и смена тарифа" + }, "connectionInfo": "Данные для подключения", "copyLink": "Копировать ссылку", "copied": "Скопировано!", diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index a8de6b8..5a9f9f1 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -1,95 +1,30 @@ -import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; +import { useState, useEffect, 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 { useNavigate } from 'react-router'; import { subscriptionApi } from '../api/subscription'; -import { promoApi } from '../api/promo'; import TrafficProgressBar from '../components/dashboard/TrafficProgressBar'; +import { HoverBorderGradient } from '../components/ui/hover-border-gradient'; import { getTrafficZone } from '../utils/trafficZone'; import { formatTraffic } from '../utils/formatTraffic'; import { getGlassColors } from '../utils/glassTheme'; import { useTheme } from '../hooks/useTheme'; -import { HoverBorderGradient } from '../components/ui/hover-border-gradient'; -import type { - PurchaseSelection, - PeriodOption, - Tariff, - TariffPeriod, - ClassicPurchaseOptions, -} from '../types'; import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt'; import { useCurrency } from '../hooks/useCurrency'; import { useCloseOnSuccessNotification } from '../store/successNotification'; -import i18n from '../i18n'; - -// Helper to extract error message from axios/api errors -const getErrorMessage = (error: unknown): string => { - if (error instanceof AxiosError) { - const detail = error.response?.data?.detail; - if (typeof detail === 'string') return detail; - if (typeof detail === 'object' && detail?.message) return detail.message; - } - if (error instanceof Error) return error.message; - return i18n.t('common.error'); -}; - -// Helper to extract insufficient balance error details -const getInsufficientBalanceError = ( - error: unknown, -): { required: number; balance: number; missingAmount?: number } | null => { - if (error instanceof AxiosError) { - const detail = error.response?.data?.detail; - // Support both 'insufficient_balance' and 'insufficient_funds' codes - if ( - typeof detail === 'object' && - (detail?.code === 'insufficient_balance' || detail?.code === 'insufficient_funds') - ) { - return { - required: detail.required || detail.total_price || 0, - balance: detail.balance || 0, - missingAmount: detail.missing_amount || detail.missingAmount || 0, - }; - } - } - return null; -}; - -// Icons -const CopyIcon = () => ( - - - -); - -const CheckIcon = () => ( - - - -); - -// Convert country code to flag emoji -const getFlagEmoji = (countryCode: string): string => { - if (!countryCode || countryCode.length !== 2) return ''; - const codePoints = countryCode - .toUpperCase() - .split('') - .map((char) => 127397 + char.charCodeAt(0)); - return String.fromCodePoint(...codePoints); -}; - -type PurchaseStep = 'period' | 'traffic' | 'servers' | 'devices' | 'confirm'; +import PurchaseCTAButton from '../components/subscription/PurchaseCTAButton'; +import { CopyIcon, CheckIcon } from '../components/icons'; +import { + getErrorMessage, + getInsufficientBalanceError, + getFlagEmoji, +} from '../utils/subscriptionHelpers'; export default function Subscription() { const { t } = useTranslation(); const queryClient = useQueryClient(); - const location = useLocation(); - const navigate = useNavigate(); const { formatAmount, currencySymbol } = useCurrency(); + const navigate = useNavigate(); const { isDark } = useTheme(); const g = getGlassColors(isDark); const [copied, setCopied] = useState(false); @@ -97,67 +32,6 @@ export default function Subscription() { // Helper to format price from kopeks const formatPrice = (kopeks: number) => `${formatAmount(kopeks / 100)} ${currencySymbol}`; - // Helper to apply promo discount to a price, stacking with existing promo group discount - const applyPromoDiscount = ( - priceKopeks: number, - existingOriginalPrice?: number | null, - ): { - price: number; - original: number | null; - percent: number | null; - isPromoGroup: boolean; - } => { - const hasExisting = (existingOriginalPrice ?? 0) > priceKopeks; - const hasPromo = !!activeDiscount?.is_active && !!activeDiscount.discount_percent; - - if (!hasExisting && !hasPromo) { - return { price: priceKopeks, original: null, percent: null, isPromoGroup: false }; - } - - let finalPrice = priceKopeks; - if (hasPromo) { - finalPrice = Math.round(priceKopeks * (1 - activeDiscount!.discount_percent! / 100)); - } - - if (hasExisting) { - // Promo group discount exists — calculate combined percent from deepest original - const combinedPercent = hasPromo - ? Math.round((1 - finalPrice / existingOriginalPrice!) * 100) - : Math.round((1 - priceKopeks / existingOriginalPrice!) * 100); - return { - price: finalPrice, - original: existingOriginalPrice!, - percent: combinedPercent, - isPromoGroup: true, - }; - } - - // Only promo offer discount (no promo group) - return { - price: finalPrice, - original: priceKopeks, - percent: activeDiscount!.discount_percent!, - isPromoGroup: false, - }; - }; - - // Purchase state (classic mode) - const [currentStep, setCurrentStep] = useState('period'); - const [selectedPeriod, setSelectedPeriod] = useState(null); - const [selectedTraffic, setSelectedTraffic] = useState(null); - const [selectedServers, setSelectedServers] = useState([]); - const [selectedDevices, setSelectedDevices] = useState(1); - const [showPurchaseForm, setShowPurchaseForm] = useState(false); - - // Tariffs mode state - const [selectedTariff, setSelectedTariff] = useState(null); - const [selectedTariffPeriod, setSelectedTariffPeriod] = useState(null); - const [showTariffPurchase, setShowTariffPurchase] = useState(false); - // Custom days/traffic state - const [customDays, setCustomDays] = useState(30); - const [customTrafficGb, setCustomTrafficGb] = useState(50); - const [useCustomDays, setUseCustomDays] = useState(false); - const [useCustomTraffic, setUseCustomTraffic] = useState(false); // Device/traffic topup state const [showDeviceTopup, setShowDeviceTopup] = useState(false); const [devicesToAdd, setDevicesToAdd] = useState(1); @@ -206,109 +80,13 @@ export default function Subscription() { return () => clearInterval(id); }, [subscription?.end_date]); - const { data: purchaseOptions, isLoading: optionsLoading } = useQuery({ + // Purchase options (needed for balance_kopeks in device/traffic/server management) + const { data: purchaseOptions } = useQuery({ queryKey: ['purchase-options'], queryFn: subscriptionApi.getPurchaseOptions, }); - // Fetch active promo discount - const { data: activeDiscount } = useQuery({ - queryKey: ['active-discount'], - queryFn: promoApi.getActiveDiscount, - staleTime: 30000, - }); - - // Check if in tariffs mode (moved up to be available for useEffect) const isTariffsMode = purchaseOptions?.sales_mode === 'tariffs'; - const classicOptions = !isTariffsMode ? (purchaseOptions as ClassicPurchaseOptions) : null; - const tariffs = - isTariffsMode && purchaseOptions && 'tariffs' in purchaseOptions ? purchaseOptions.tariffs : []; - - // Get truly available servers for a given period (same filter as rendering) - const getAvailableServers = useCallback( - (period: PeriodOption | null) => { - if (!period?.servers.options) return []; - return period.servers.options.filter((server) => { - if (!server.is_available) return false; - if (subscription?.is_trial && server.name.toLowerCase().includes('trial')) return false; - return true; - }); - }, - [subscription?.is_trial], - ); - - // Determine which steps are needed - const steps = useMemo(() => { - const result: PurchaseStep[] = ['period']; - if (selectedPeriod?.traffic.selectable && (selectedPeriod.traffic.options?.length ?? 0) > 0) { - result.push('traffic'); - } - const availableServers = getAvailableServers(selectedPeriod); - // Skip server selection step if only 1 server available (auto-select it) - if (availableServers.length > 1) { - result.push('servers'); - } - if (selectedPeriod && selectedPeriod.devices.max > selectedPeriod.devices.min) { - result.push('devices'); - } - result.push('confirm'); - return result; - }, [selectedPeriod, getAvailableServers]); - - const currentStepIndex = steps.indexOf(currentStep); - const isFirstStep = currentStepIndex === 0; - const isLastStep = currentStepIndex === steps.length - 1; - - // Initialize selection from options (classic mode only) - useEffect(() => { - if (classicOptions && !selectedPeriod) { - const defaultPeriod = - classicOptions.periods.find((p) => p.id === classicOptions.selection.period_id) || - classicOptions.periods[0]; - setSelectedPeriod(defaultPeriod); - setSelectedTraffic(classicOptions.selection.traffic_value); - const availableServers = getAvailableServers(defaultPeriod); - const availableServerUuids = new Set(availableServers.map((s) => s.uuid)); - // If only 1 server available, auto-select it (step will be skipped) - if (availableServers.length === 1) { - setSelectedServers([availableServers[0].uuid]); - } else { - setSelectedServers( - classicOptions.selection.servers.filter((uuid) => availableServerUuids.has(uuid)), - ); - } - setSelectedDevices(classicOptions.selection.devices); - } - }, [classicOptions, selectedPeriod, getAvailableServers]); - - // Build selection object - const currentSelection: PurchaseSelection = useMemo( - () => ({ - period_id: selectedPeriod?.id, - period_days: selectedPeriod?.period_days, - traffic_value: selectedTraffic ?? undefined, - servers: selectedServers, - devices: selectedDevices, - }), - [selectedPeriod, selectedTraffic, selectedServers, selectedDevices], - ); - - // Preview query - const { data: preview, isLoading: previewLoading } = useQuery({ - queryKey: ['purchase-preview', currentSelection], - queryFn: () => subscriptionApi.previewPurchase(currentSelection), - enabled: !!selectedPeriod && showPurchaseForm && currentStep === 'confirm', - }); - - const purchaseMutation = useMutation({ - mutationFn: () => subscriptionApi.submitPurchase(currentSelection), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['subscription'] }); - queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); - setShowPurchaseForm(false); - setCurrentStep('period'); - }, - }); const autopayMutation = useMutation({ mutationFn: (enabled: boolean) => subscriptionApi.updateAutopay(enabled), @@ -348,96 +126,15 @@ export default function Subscription() { }, }); - // Refs for auto-scroll - const switchModalRef = useRef(null); - const tariffPurchaseRef = useRef(null); - const tariffsCardRef = useRef(null); - - // Tariff switch preview - const [switchTariffId, setSwitchTariffId] = useState(null); - - // Auto-close all modals/forms when success notification appears (e.g., subscription purchased via WebSocket) + // Auto-close all modals/forms when success notification appears 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], @@ -589,40 +286,6 @@ export default function Subscription() { 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); @@ -631,51 +294,7 @@ export default function Subscription() { } }; - 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) { + if (isLoading) { return (
@@ -2409,1549 +2028,8 @@ export default function Subscription() {
)} - {/* Tariffs Section - Combined Purchase/Extend/Switch like MiniApp */} - {isTariffsMode && tariffs.length > 0 && ( -
-
-

- {subscription?.is_daily && !subscription?.is_trial - ? t('subscription.switchTariff.title') - : subscription && !subscription.is_trial - ? t('subscription.extend') - : t('subscription.getSubscription')} -

-
- - {/* Trial upgrade prompt */} - {subscription?.is_trial && ( -
-
-
- -
-
-
- {t('subscription.trialUpgrade.title')} -
-
- {t('subscription.trialUpgrade.description')} -
-
-
-
- )} - - {/* Expired subscription notice - prompt to purchase new tariff */} - {isTariffsMode && - purchaseOptions && - 'subscription_is_expired' in purchaseOptions && - purchaseOptions.subscription_is_expired && ( -
-
-
- -
-
-
- {t('subscription.expiredBanner.title')} -
-
- {t('subscription.expiredBanner.selectTariff')} -
-
-
-
- )} - - {/* Legacy subscription notice - if user has subscription without tariff */} - {subscription && !subscription.is_trial && !subscription.tariff_id && ( -
-
- {t('subscription.legacy.selectTariffTitle')} -
-
- {t('subscription.legacy.selectTariffDescription')} -
-
- {t('subscription.legacy.currentSubContinues')} -
-
- )} - - {/* Switch Tariff Preview Modal */} - {switchTariffId && ( -
-
-

- {t('subscription.switchTariff.title')} -

- -
- - {switchPreviewLoading ? ( -
-
-
- ) : ( - 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 ( - <> -
-
- {t('subscription.switchTariff.currentTariff')} - - {switchPreview.current_tariff_name || '-'} - -
-
- {t('subscription.switchTariff.newTariff')} - - {switchPreview.new_tariff_name} - -
-
- {t('subscription.switchTariff.remainingDays')} - {switchPreview.remaining_days} -
-
- - {/* Daily tariff info */} - {isDailyTariff && ( -
-
- {t('subscription.switchTariff.dailyPayment')} -
-
- {formatPrice(dailyPrice)} -
-
- {t('subscription.switchTariff.dailyChargeDescription')} -
-
- )} - -
-
- - {t('subscription.switchTariff.upgradeCost')} - - {/* Discount badge */} - {switchPreview.discount_percent && switchPreview.discount_percent > 0 && ( - - -{switchPreview.discount_percent}% - - )} -
-
- {/* 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 && ( - - {formatPrice(switchPreview.base_upgrade_cost_kopeks)} - - )} - - {switchPreview.upgrade_cost_kopeks > 0 - ? switchPreview.upgrade_cost_label - : t('subscription.switchTariff.free')} - -
-
- - {!switchPreview.has_enough_balance && - switchPreview.upgrade_cost_kopeks > 0 && ( - - )} - - - - {/* 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 ( -
- {getErrorMessage(switchTariffMutation.error)} -
- ); - })()} - - ); - })() - )} -
- )} - - {!showTariffPurchase ? ( - <> - {/* Promo group discount banner */} - {tariffs.some((t) => t.promo_group_name) && ( -
-
- - - -
-
-
- {t('subscription.promoGroup.yourGroup', { - name: tariffs.find((t) => t.promo_group_name)?.promo_group_name, - })} -
-
- {t('subscription.promoGroup.personalDiscountsApplied')} -
-
-
- )} - {/* Tariff List - current tariff first */} -
- {[...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 ( -
-
-
-
{tariff.name}
- {tariff.description && ( -
- {tariff.description} -
- )} -
- {isCurrentTariff && ( - - {t('subscription.currentTariff')} - - )} -
-
- {/* Traffic */} -
- - - - - {tariff.traffic_limit_label} - -
- {/* Devices */} -
- - - - - {t('subscription.devices', { count: tariff.device_limit })} - -
- {/* Traffic Reset */} - {tariff.traffic_reset_mode && - tariff.traffic_reset_mode !== 'NO_RESET' && ( -
- - - - - {t(`subscription.trafficReset.${tariff.traffic_reset_mode}`)} - -
- )} -
- {/* Price info - daily or period-based */} -
- {(() => { - // 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 ( - - - {formatPrice(promoDaily.price)} - - {promoDaily.original && - promoDaily.original > promoDaily.price && ( - - {formatPrice(promoDaily.original)} - - )} - {t('subscription.tariff.perDay')} - {/* Show discount badge */} - {promoDaily.percent && promoDaily.percent > 0 && ( - - -{promoDaily.percent}% - - )} - - ); - } - // 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 ( - - {t('subscription.from')} - - {formatPrice(promoPeriod.price)} - - {promoPeriod.original && - promoPeriod.original > promoPeriod.price && ( - - {formatPrice(promoPeriod.original)} - - )} - {promoPeriod.percent && promoPeriod.percent > 0 && ( - - -{promoPeriod.percent}% - - )} - - ); - } - // Fallback - return ( - - {t('subscription.tariff.flexiblePayment')} - - ); - })()} -
- - {/* Action Buttons */} -
- {isCurrentTariff ? ( - /* Current tariff - extend button (hide for daily tariffs) */ - subscription?.is_daily ? ( -
- {t('subscription.currentTariff')} -
- ) : ( - - ) - ) : isLegacySubscription ? ( - /* Legacy subscription without tariff - allow selecting tariff for renewal */ - - ) : canSwitch ? ( - /* Other tariffs with existing tariff - switch button */ - - ) : ( - /* No subscription or trial - purchase button */ - - )} -
-
- ); - })} -
- - ) : ( - selectedTariff && ( - /* Tariff Purchase Form */ -
-
-

{selectedTariff.name}

- -
- - {/* Tariff Info */} -
-
-
- {t('subscription.traffic')}: - - {selectedTariff.traffic_limit_label} - -
-
- {t('subscription.devices')}: - - {selectedTariff.device_limit} - {selectedTariff.extra_devices_count > 0 && ( - - (+{selectedTariff.extra_devices_count}) - - )} - -
-
-
- - {/* Daily Tariff Purchase */} - {selectedTariff.is_daily || - (selectedTariff.daily_price_kopeks && selectedTariff.daily_price_kopeks > 0) ? ( -
-
-
- {t('subscription.dailyPurchase.costPerDay')} -
-
- {formatPrice(selectedTariff.daily_price_kopeks || 0)} -
-
-
-
- - {t('subscription.dailyPurchase.chargedDaily')} -
-
- - {t('subscription.dailyPurchase.canPause')} -
-
- - {t('subscription.dailyPurchase.pausedOnLowBalance')} -
-
- - {/* Purchase button for daily tariff */} - {(() => { - const dailyPrice = selectedTariff.daily_price_kopeks || 0; - const hasEnoughBalance = - purchaseOptions && dailyPrice <= purchaseOptions.balance_kopeks; - - return ( -
- {purchaseOptions && !hasEnoughBalance && ( - - )} - - - - {tariffPurchaseMutation.isError && - !getInsufficientBalanceError(tariffPurchaseMutation.error) && ( -
- {getErrorMessage(tariffPurchaseMutation.error)} -
- )} - {tariffPurchaseMutation.isError && - getInsufficientBalanceError(tariffPurchaseMutation.error) && ( -
- -
- )} -
- ); - })()} -
- ) : ( - <> - {/* Period Selection for non-daily tariffs */} -
-
- {t('subscription.selectPeriod')} -
- - {/* Fixed periods */} - {selectedTariff.periods.length > 0 && !useCustomDays && ( -
- {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 ( - - ); - })} -
- )} - - {/* Custom days option */} - {selectedTariff.custom_days_enabled && - (selectedTariff.price_per_day_kopeks ?? 0) > 0 && ( -
-
- - {t('subscription.customDays.title')} - - -
- {useCustomDays && ( -
-
- setCustomDays(parseInt(e.target.value))} - className="flex-1 accent-accent-500" - /> - - 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" - /> -
- {(() => { - 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 ( -
- - {t('subscription.days', { count: customDays })} ×{' '} - {formatPrice(selectedTariff.price_per_day_kopeks ?? 0)}/ - {t('subscription.customDays.perDay')} - -
- - {formatPrice(promoCustom.price)} - - {promoCustom.original && - promoCustom.original > promoCustom.price && ( - <> - - {formatPrice(promoCustom.original)} - - - -{promoCustom.percent}% - - - )} -
-
- ); - })()} -
- )} -
- )} -
- - {/* Custom traffic option */} - {selectedTariff.custom_traffic_enabled && - (selectedTariff.traffic_price_per_gb_kopeks ?? 0) > 0 && ( -
-
- {t('subscription.customTraffic.label')} -
-
-
- - {t('subscription.customTraffic.selectVolume')} - - -
- {!useCustomTraffic && ( -
- {t('subscription.customTraffic.default', { - label: selectedTariff.traffic_limit_label, - })} -
- )} - {useCustomTraffic && ( -
-
- setCustomTrafficGb(parseInt(e.target.value))} - className="flex-1 accent-accent-500" - /> -
- - 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" - /> - {t('common.units.gb')} -
-
-
- - {customTrafficGb} {t('common.units.gb')} ×{' '} - {formatPrice(selectedTariff.traffic_price_per_gb_kopeks ?? 0)}/ - {t('common.units.gb')} - - - + - {formatPrice( - customTrafficGb * - (selectedTariff.traffic_price_per_gb_kopeks ?? 0), - )} - -
-
- )} -
-
- )} - - {/* Summary & Purchase */} - {(selectedTariffPeriod || useCustomDays) && ( -
- {(() => { - // 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 */} -
- {useCustomDays ? ( -
- - {t('subscription.stepPeriod')}:{' '} - {t('subscription.days', { count: customDays })} - -
- {formatPrice(promoPeriod.price)} - {promoPeriod.original && - promoPeriod.original > promoPeriod.price && ( - - {formatPrice(promoPeriod.original)} - - )} -
-
- ) : ( - selectedTariffPeriod && ( - <> - {/* Если есть доп. устройства - показываем разбивку */} - {(selectedTariffPeriod.extra_devices_count ?? 0) > 0 && - selectedTariffPeriod.base_tariff_price_kopeks ? ( - <> -
- - {t('subscription.baseTariff')}:{' '} - {selectedTariffPeriod.label} - - - {formatPrice( - selectedTariffPeriod.base_tariff_price_kopeks, - )} - -
-
- - {t('subscription.extraDevices')} ( - {selectedTariffPeriod.extra_devices_count}) - - - + - {formatPrice( - selectedTariffPeriod.extra_devices_cost_kopeks ?? 0, - )} - -
- - ) : ( -
- - {t('subscription.summary.period', { - label: selectedTariffPeriod.label, - })} - -
- {formatPrice(promoPeriod.price)} - {promoPeriod.original && - promoPeriod.original > promoPeriod.price && ( - - {formatPrice(promoPeriod.original)} - - )} -
-
- )} - - ) - )} - {useCustomTraffic && selectedTariff.custom_traffic_enabled && ( -
- - {t('subscription.summary.traffic', { gb: customTrafficGb })} - - +{formatPrice(trafficPrice)} -
- )} -
- - {/* Promo discount info */} - {promoPeriod.percent && ( -
- - {t('promo.discountApplied')} -{promoPeriod.percent}% - -
- )} - -
- - {t('subscription.total')} - -
- - {formatPrice(totalPrice)} - - {originalTotal && ( -
- {formatPrice(originalTotal)} -
- )} -
-
- - - - ); - })()} - - {tariffPurchaseMutation.isError && - !getInsufficientBalanceError(tariffPurchaseMutation.error) && ( -
- {getErrorMessage(tariffPurchaseMutation.error)} -
- )} - {tariffPurchaseMutation.isError && - getInsufficientBalanceError(tariffPurchaseMutation.error) && ( -
- -
- )} -
- )} - - )} -
- ) - )} -
- )} - - {/* Purchase/Extend Section - Classic Mode */} - {classicOptions && classicOptions.periods.length > 0 && ( -
-
-

- {subscription && !subscription.is_trial - ? t('subscription.extend') - : t('subscription.getSubscription')} -

- {!showPurchaseForm && ( - - )} -
- - {showPurchaseForm && ( -
- {/* Step Indicator */} -
-
- {t('subscription.step', { current: currentStepIndex + 1, total: steps.length })} -
-
- {steps.map((step, idx) => ( -
- ))} -
-
- -
- {getStepLabel(currentStep)} -
- - {/* Step: Period Selection */} - {currentStep === 'period' && classicOptions && ( -
- {classicOptions.periods.map((period) => { - const promoPeriod = applyPromoDiscount( - period.price_kopeks, - period.original_price_kopeks, - ); - - return ( - - ); - })} -
- )} - - {/* Step: Traffic Selection */} - {currentStep === 'traffic' && selectedPeriod?.traffic.options && ( -
- {selectedPeriod.traffic.options.map((option) => { - const promoTraffic = applyPromoDiscount( - option.price_kopeks, - option.original_price_kopeks, - ); - - return ( - - ); - })} -
- )} - - {/* Step: Server Selection */} - {currentStep === 'servers' && selectedPeriod?.servers.options && ( -
- {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 ( - - ); - })} -
- )} - - {/* Step: Device Selection */} - {currentStep === 'devices' && selectedPeriod && ( -
-
- -
-
{selectedDevices}
-
{t('subscription.devices')}
-
- -
-
-
- {t('subscription.devicesFree', { count: selectedPeriod.devices.min })} -
- {selectedPeriod.devices.max > selectedPeriod.devices.min && ( -
- {formatPrice(selectedPeriod.devices.price_per_device_kopeks)}{' '} - {t('subscription.perExtraDevice')} -
- )} -
-
- )} - - {/* Step: Confirm */} - {currentStep === 'confirm' && ( -
- {previewLoading ? ( -
-
-
- ) : preview ? ( -
- {/* Active promo discount banner */} - {activeDiscount?.is_active && activeDiscount.discount_percent && ( -
- - - - - {t('promo.discountApplied')} -{activeDiscount.discount_percent}% - -
- )} - - {preview.breakdown.map((item, idx) => ( -
- {item.label} - {item.value} -
- ))} - - {(() => { - const promoTotal = applyPromoDiscount( - preview.total_price_kopeks, - preview.original_price_kopeks, - ); - - return ( -
- - {t('subscription.total')} - -
-
- {formatPrice(promoTotal.price)} -
- {promoTotal.original && promoTotal.original > promoTotal.price && ( -
- {formatPrice(promoTotal.original)} -
- )} -
-
- ); - })()} - - {preview.discount_label && ( -
- {preview.discount_label} -
- )} - - {!preview.can_purchase && - (preview.missing_amount_kopeks > 0 ? ( - - ) : preview.status_message ? ( -
- {preview.status_message} -
- ) : null)} -
- ) : null} -
- )} - - {/* Navigation Buttons */} -
- {!isFirstStep && ( - - )} - - {isFirstStep && ( - - )} - - {!isLastStep ? ( - - ) : ( - - )} -
- - {purchaseMutation.isError && ( -
- {getErrorMessage(purchaseMutation.error)} -
- )} -
- )} -
- )} + {/* Purchase / Renewal CTA */} +
); } diff --git a/src/pages/SubscriptionPurchase.tsx b/src/pages/SubscriptionPurchase.tsx new file mode 100644 index 0000000..3aa99ce --- /dev/null +++ b/src/pages/SubscriptionPurchase.tsx @@ -0,0 +1,1900 @@ +import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router'; +import { AxiosError } from 'axios'; +import { subscriptionApi } from '../api/subscription'; +import { promoApi } from '../api/promo'; +import { getGlassColors } from '../utils/glassTheme'; +import { useTheme } from '../hooks/useTheme'; +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 { CheckIcon } from '../components/icons'; +import { + getErrorMessage, + getInsufficientBalanceError, + type PurchaseStep, +} from '../utils/subscriptionHelpers'; + +export default function SubscriptionPurchase() { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const { formatAmount, currencySymbol } = useCurrency(); + const { isDark } = useTheme(); + const g = getGlassColors(isDark); + + const formatPrice = (kopeks: number) => `${formatAmount(kopeks / 100)} ${currencySymbol}`; + + // Subscription query (shares cache with /subscription page) + const { data: subscriptionResponse, isLoading } = useQuery({ + queryKey: ['subscription'], + queryFn: subscriptionApi.getSubscription, + retry: false, + staleTime: 0, + refetchOnMount: 'always', + }); + const subscription = subscriptionResponse?.subscription ?? null; + + // Purchase options + const { data: purchaseOptions, isLoading: optionsLoading } = useQuery({ + queryKey: ['purchase-options'], + queryFn: subscriptionApi.getPurchaseOptions, + }); + + // Active promo discount + const { data: activeDiscount } = useQuery({ + queryKey: ['active-discount'], + queryFn: promoApi.getActiveDiscount, + staleTime: 30000, + }); + + // Sales mode detection + const isTariffsMode = purchaseOptions?.sales_mode === 'tariffs'; + const classicOptions = !isTariffsMode ? (purchaseOptions as ClassicPurchaseOptions) : null; + const tariffs = + isTariffsMode && purchaseOptions && 'tariffs' in purchaseOptions ? purchaseOptions.tariffs : []; + + // Helper to apply promo 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) { + const combinedPercent = hasPromo + ? Math.round((1 - finalPrice / existingOriginalPrice!) * 100) + : Math.round((1 - priceKopeks / existingOriginalPrice!) * 100); + return { + price: finalPrice, + original: existingOriginalPrice!, + percent: combinedPercent, + isPromoGroup: true, + }; + } + + return { + price: finalPrice, + original: priceKopeks, + percent: activeDiscount!.discount_percent!, + isPromoGroup: false, + }; + }; + + // Classic mode state + const [currentStep, setCurrentStep] = useState('period'); + const [selectedPeriod, setSelectedPeriod] = useState(null); + const [selectedTraffic, setSelectedTraffic] = useState(null); + const [selectedServers, setSelectedServers] = useState([]); + const [selectedDevices, setSelectedDevices] = useState(1); + const [showPurchaseForm, setShowPurchaseForm] = useState(false); + + // Tariffs mode state + const [selectedTariff, setSelectedTariff] = useState(null); + const [selectedTariffPeriod, setSelectedTariffPeriod] = useState(null); + const [showTariffPurchase, setShowTariffPurchase] = useState(false); + const [customDays, setCustomDays] = useState(30); + const [customTrafficGb, setCustomTrafficGb] = useState(50); + const [useCustomDays, setUseCustomDays] = useState(false); + const [useCustomTraffic, setUseCustomTraffic] = useState(false); + + // Refs for auto-scroll + const switchModalRef = useRef(null); + const tariffPurchaseRef = useRef(null); + + // Tariff switch + const [switchTariffId, setSwitchTariffId] = useState(null); + + // Auto-close all modals on success notification + const handleCloseAllModals = () => { + setShowPurchaseForm(false); + setShowTariffPurchase(false); + setSwitchTariffId(null); + setSelectedTariff(null); + setSelectedTariffPeriod(null); + }; + useCloseOnSuccessNotification(handleCloseAllModals); + + // Get available servers + 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], + ); + + // Steps for classic mode + const steps = useMemo(() => { + const result: PurchaseStep[] = ['period']; + if (selectedPeriod?.traffic.selectable && (selectedPeriod.traffic.options?.length ?? 0) > 0) { + result.push('traffic'); + } + const availableServers = getAvailableServers(selectedPeriod); + 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 classic mode selection + 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 (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 classic mode selection + 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 (classic) + const { data: preview, isLoading: previewLoading } = useQuery({ + queryKey: ['purchase-preview', currentSelection], + queryFn: () => subscriptionApi.previewPurchase(currentSelection), + enabled: !!selectedPeriod && showPurchaseForm && currentStep === 'confirm', + }); + + // Classic purchase mutation + const purchaseMutation = useMutation({ + mutationFn: () => subscriptionApi.submitPurchase(currentSelection), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['subscription'] }); + queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); + navigate('/subscription', { replace: true }); + }, + }); + + // Switch preview query + 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); + navigate('/subscription', { replace: true }); + }, + onError: (error: unknown) => { + if (error instanceof AxiosError) { + const detail = error.response?.data?.detail; + if ( + typeof detail === 'object' && + detail?.error_code === 'subscription_expired' && + detail?.use_purchase_flow === true + ) { + const targetTariff = tariffs.find((tariff) => tariff.id === switchTariffId); + if (targetTariff) { + setSwitchTariffId(null); + setSelectedTariff(targetTariff); + setSelectedTariffPeriod(targetTariff.periods[0] || null); + setShowTariffPurchase(true); + queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); + } + } + } + }, + }); + + // Tariff purchase mutation + const tariffPurchaseMutation = useMutation({ + mutationFn: () => { + if (!selectedTariff) { + throw new Error('Tariff not selected'); + } + 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'] }); + navigate('/subscription', { replace: true }); + }, + }); + + // Auto-scroll effects + useEffect(() => { + if (switchTariffId && switchModalRef.current) { + const timer = setTimeout(() => { + switchModalRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 100); + return () => clearTimeout(timer); + } + }, [switchTariffId]); + + useEffect(() => { + if (showTariffPurchase && tariffPurchaseRef.current) { + const timer = setTimeout(() => { + tariffPurchaseRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 100); + return () => clearTimeout(timer); + } + }, [showTariffPurchase]); + + // Classic mode helpers + const toggleServer = (uuid: string) => { + if (selectedServers.includes(uuid)) { + if (selectedServers.length > 1) { + setSelectedServers(selectedServers.filter((s) => s !== uuid)); + } + } else { + setSelectedServers([...selectedServers, uuid]); + } + }; + + const goToNextStep = () => { + const nextIndex = currentStepIndex + 1; + if (nextIndex < steps.length) { + setCurrentStep(steps[nextIndex]); + } + }; + + const goToPrevStep = () => { + const prevIndex = currentStepIndex - 1; + if (prevIndex >= 0) { + setCurrentStep(steps[prevIndex]); + } + }; + + const resetPurchase = () => { + setShowPurchaseForm(false); + setCurrentStep('period'); + }; + + const getStepLabel = (step: PurchaseStep) => { + switch (step) { + case 'period': + return t('subscription.stepPeriod'); + case 'traffic': + return t('subscription.stepTraffic'); + case 'servers': + return t('subscription.stepServers'); + case 'devices': + return t('subscription.stepDevices'); + case 'confirm': + return t('subscription.stepConfirm'); + } + }; + + if (isLoading || optionsLoading) { + return ( +
+
+
+ ); + } + + return ( +
+ {/* Header with back link */} +
+ +

+ {subscription?.is_daily && !subscription?.is_trial + ? t('subscription.switchTariff.title') + : subscription && !subscription.is_trial + ? t('subscription.extend') + : t('subscription.getSubscription')} +

+
+ + {/* Tariffs Section */} + {isTariffsMode && tariffs.length > 0 && ( +
+ {/* Trial upgrade prompt */} + {subscription?.is_trial && ( +
+
+
+ +
+
+
+ {t('subscription.trialUpgrade.title')} +
+
+ {t('subscription.trialUpgrade.description')} +
+
+
+
+ )} + + {/* Expired subscription notice */} + {isTariffsMode && + purchaseOptions && + 'subscription_is_expired' in purchaseOptions && + purchaseOptions.subscription_is_expired && ( +
+
+
+ +
+
+
+ {t('subscription.expiredBanner.title')} +
+
+ {t('subscription.expiredBanner.selectTariff')} +
+
+
+
+ )} + + {/* Legacy subscription notice */} + {subscription && !subscription.is_trial && !subscription.tariff_id && ( +
+
+ {t('subscription.legacy.selectTariffTitle')} +
+
+ {t('subscription.legacy.selectTariffDescription')} +
+
+ {t('subscription.legacy.currentSubContinues')} +
+
+ )} + + {/* Switch Tariff Preview Modal */} + {switchTariffId && ( +
+
+

+ {t('subscription.switchTariff.title')} +

+ +
+ + {switchPreviewLoading ? ( +
+
+
+ ) : ( + switchPreview && + (() => { + const targetTariff = tariffs.find((tariff) => tariff.id === switchTariffId); + const dailyPrice = + targetTariff?.daily_price_kopeks ?? targetTariff?.price_per_day_kopeks ?? 0; + const isDailyTariff = dailyPrice > 0; + + return ( + <> +
+
+ {t('subscription.switchTariff.currentTariff')} + + {switchPreview.current_tariff_name || '-'} + +
+
+ {t('subscription.switchTariff.newTariff')} + + {switchPreview.new_tariff_name} + +
+
+ {t('subscription.switchTariff.remainingDays')} + {switchPreview.remaining_days} +
+
+ + {isDailyTariff && ( +
+
+ {t('subscription.switchTariff.dailyPayment')} +
+
+ {formatPrice(dailyPrice)} +
+
+ {t('subscription.switchTariff.dailyChargeDescription')} +
+
+ )} + +
+
+ + {t('subscription.switchTariff.upgradeCost')} + + {switchPreview.discount_percent && switchPreview.discount_percent > 0 && ( + + -{switchPreview.discount_percent}% + + )} +
+
+ {switchPreview.discount_percent && + switchPreview.discount_percent > 0 && + switchPreview.base_upgrade_cost_kopeks && + switchPreview.base_upgrade_cost_kopeks > 0 && ( + + {formatPrice(switchPreview.base_upgrade_cost_kopeks)} + + )} + + {switchPreview.upgrade_cost_kopeks > 0 + ? switchPreview.upgrade_cost_label + : t('subscription.switchTariff.free')} + +
+
+ + {!switchPreview.has_enough_balance && + switchPreview.upgrade_cost_kopeks > 0 && ( + + )} + + + + {switchTariffMutation.isError && + (() => { + const detail = + switchTariffMutation.error instanceof AxiosError + ? switchTariffMutation.error.response?.data?.detail + : null; + if ( + typeof detail === 'object' && + detail?.error_code === 'subscription_expired' + ) { + return null; + } + return ( +
+ {getErrorMessage(switchTariffMutation.error)} +
+ ); + })()} + + ); + })() + )} +
+ )} + + {!showTariffPurchase ? ( + <> + {/* Promo group discount banner */} + {tariffs.some((tariff) => tariff.promo_group_name) && ( +
+
+ + + +
+
+
+ {t('subscription.promoGroup.yourGroup', { + name: tariffs.find((tariff) => tariff.promo_group_name)?.promo_group_name, + })} +
+
+ {t('subscription.promoGroup.personalDiscountsApplied')} +
+
+
+ )} + + {/* Tariff Grid */} +
+ {[...tariffs] + .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; + const isSubscriptionExpired = + isTariffsMode && + purchaseOptions && + 'subscription_is_expired' in purchaseOptions && + purchaseOptions.subscription_is_expired === true; + const canSwitch = + subscription && + subscription.tariff_id && + !isCurrentTariff && + !subscription.is_trial && + !isSubscriptionExpired && + subscription.is_active; + const isLegacySubscription = + subscription && !subscription.is_trial && !subscription.tariff_id; + + return ( +
+
+
+
{tariff.name}
+ {tariff.description && ( +
+ {tariff.description} +
+ )} +
+ {isCurrentTariff && ( + + {t('subscription.currentTariff')} + + )} +
+
+
+ + + + + {tariff.traffic_limit_label} + +
+
+ + + + + {t('subscription.devices', { count: tariff.device_limit })} + +
+ {tariff.traffic_reset_mode && + tariff.traffic_reset_mode !== 'NO_RESET' && ( +
+ + + + + {t(`subscription.trafficReset.${tariff.traffic_reset_mode}`)} + +
+ )} +
+ {/* Price info */} +
+ {(() => { + 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 ( + + + {formatPrice(promoDaily.price)} + + {promoDaily.original && + promoDaily.original > promoDaily.price && ( + + {formatPrice(promoDaily.original)} + + )} + {t('subscription.tariff.perDay')} + {promoDaily.percent && promoDaily.percent > 0 && ( + + -{promoDaily.percent}% + + )} + + ); + } + if (tariff.periods.length > 0) { + const firstPeriod = tariff.periods[0]; + const promoPeriod = applyPromoDiscount( + firstPeriod?.price_kopeks || 0, + firstPeriod?.original_price_kopeks, + ); + return ( + + {t('subscription.from')} + + {formatPrice(promoPeriod.price)} + + {promoPeriod.original && + promoPeriod.original > promoPeriod.price && ( + + {formatPrice(promoPeriod.original)} + + )} + {promoPeriod.percent && promoPeriod.percent > 0 && ( + + -{promoPeriod.percent}% + + )} + + ); + } + return ( + + {t('subscription.tariff.flexiblePayment')} + + ); + })()} +
+ + {/* Action Buttons */} +
+ {isCurrentTariff ? ( + subscription?.is_daily ? ( +
+ {t('subscription.currentTariff')} +
+ ) : ( + + ) + ) : isLegacySubscription ? ( + + ) : canSwitch ? ( + + ) : ( + + )} +
+
+ ); + })} +
+ + ) : ( + selectedTariff && ( + /* Tariff Purchase Form */ +
+
+

{selectedTariff.name}

+ +
+ + {/* Tariff Info */} +
+
+
+ {t('subscription.traffic')}: + + {selectedTariff.traffic_limit_label} + +
+
+ {t('subscription.devices')}: + + {selectedTariff.device_limit} + {selectedTariff.extra_devices_count > 0 && ( + + (+{selectedTariff.extra_devices_count}) + + )} + +
+
+
+ + {/* Daily Tariff Purchase */} + {selectedTariff.is_daily || + (selectedTariff.daily_price_kopeks && selectedTariff.daily_price_kopeks > 0) ? ( +
+
+
+ {t('subscription.dailyPurchase.costPerDay')} +
+
+ {formatPrice(selectedTariff.daily_price_kopeks || 0)} +
+
+
+
+ + {t('subscription.dailyPurchase.chargedDaily')} +
+
+ + {t('subscription.dailyPurchase.canPause')} +
+
+ + {t('subscription.dailyPurchase.pausedOnLowBalance')} +
+
+ + {(() => { + const dailyPrice = selectedTariff.daily_price_kopeks || 0; + const hasEnoughBalance = + purchaseOptions && dailyPrice <= purchaseOptions.balance_kopeks; + + return ( +
+ {purchaseOptions && !hasEnoughBalance && ( + + )} + + + + {tariffPurchaseMutation.isError && + !getInsufficientBalanceError(tariffPurchaseMutation.error) && ( +
+ {getErrorMessage(tariffPurchaseMutation.error)} +
+ )} + {tariffPurchaseMutation.isError && + getInsufficientBalanceError(tariffPurchaseMutation.error) && ( +
+ +
+ )} +
+ ); + })()} +
+ ) : ( + <> + {/* Period Selection for non-daily tariffs */} +
+
+ {t('subscription.selectPeriod')} +
+ + {selectedTariff.periods.length > 0 && !useCustomDays && ( +
+ {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 ( + + ); + })} +
+ )} + + {/* Custom days option */} + {selectedTariff.custom_days_enabled && + (selectedTariff.price_per_day_kopeks ?? 0) > 0 && ( +
+
+ + {t('subscription.customDays.title')} + + +
+ {useCustomDays && ( +
+
+ setCustomDays(parseInt(e.target.value))} + className="flex-1 accent-accent-500" + /> + + 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" + /> +
+ {(() => { + 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 ( +
+ + {t('subscription.days', { count: customDays })} ×{' '} + {formatPrice(selectedTariff.price_per_day_kopeks ?? 0)}/ + {t('subscription.customDays.perDay')} + +
+ + {formatPrice(promoCustom.price)} + + {promoCustom.original && + promoCustom.original > promoCustom.price && ( + <> + + {formatPrice(promoCustom.original)} + + + -{promoCustom.percent}% + + + )} +
+
+ ); + })()} +
+ )} +
+ )} +
+ + {/* Custom traffic option */} + {selectedTariff.custom_traffic_enabled && + (selectedTariff.traffic_price_per_gb_kopeks ?? 0) > 0 && ( +
+
+ {t('subscription.customTraffic.label')} +
+
+
+ + {t('subscription.customTraffic.selectVolume')} + + +
+ {!useCustomTraffic && ( +
+ {t('subscription.customTraffic.default', { + label: selectedTariff.traffic_limit_label, + })} +
+ )} + {useCustomTraffic && ( +
+
+ setCustomTrafficGb(parseInt(e.target.value))} + className="flex-1 accent-accent-500" + /> +
+ + 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" + /> + {t('common.units.gb')} +
+
+
+ + {customTrafficGb} {t('common.units.gb')} ×{' '} + {formatPrice(selectedTariff.traffic_price_per_gb_kopeks ?? 0)}/ + {t('common.units.gb')} + + + + + {formatPrice( + customTrafficGb * + (selectedTariff.traffic_price_per_gb_kopeks ?? 0), + )} + +
+
+ )} +
+
+ )} + + {/* Summary & Purchase */} + {(selectedTariffPeriod || useCustomDays) && ( +
+ {(() => { + 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 ( + <> +
+ {useCustomDays ? ( +
+ + {t('subscription.stepPeriod')}:{' '} + {t('subscription.days', { count: customDays })} + +
+ {formatPrice(promoPeriod.price)} + {promoPeriod.original && + promoPeriod.original > promoPeriod.price && ( + + {formatPrice(promoPeriod.original)} + + )} +
+
+ ) : ( + selectedTariffPeriod && ( + <> + {(selectedTariffPeriod.extra_devices_count ?? 0) > 0 && + selectedTariffPeriod.base_tariff_price_kopeks ? ( + <> +
+ + {t('subscription.baseTariff')}:{' '} + {selectedTariffPeriod.label} + + + {formatPrice( + selectedTariffPeriod.base_tariff_price_kopeks, + )} + +
+
+ + {t('subscription.extraDevices')} ( + {selectedTariffPeriod.extra_devices_count}) + + + + + {formatPrice( + selectedTariffPeriod.extra_devices_cost_kopeks ?? 0, + )} + +
+ + ) : ( +
+ + {t('subscription.summary.period', { + label: selectedTariffPeriod.label, + })} + +
+ {formatPrice(promoPeriod.price)} + {promoPeriod.original && + promoPeriod.original > promoPeriod.price && ( + + {formatPrice(promoPeriod.original)} + + )} +
+
+ )} + + ) + )} + {useCustomTraffic && selectedTariff.custom_traffic_enabled && ( +
+ + {t('subscription.summary.traffic', { gb: customTrafficGb })} + + +{formatPrice(trafficPrice)} +
+ )} +
+ + {promoPeriod.percent && ( +
+ + {t('promo.discountApplied')} -{promoPeriod.percent}% + +
+ )} + +
+ + {t('subscription.total')} + +
+ + {formatPrice(totalPrice)} + + {originalTotal && ( +
+ {formatPrice(originalTotal)} +
+ )} +
+
+ + + + ); + })()} + + {tariffPurchaseMutation.isError && + !getInsufficientBalanceError(tariffPurchaseMutation.error) && ( +
+ {getErrorMessage(tariffPurchaseMutation.error)} +
+ )} + {tariffPurchaseMutation.isError && + getInsufficientBalanceError(tariffPurchaseMutation.error) && ( +
+ +
+ )} +
+ )} + + )} +
+ ) + )} +
+ )} + + {/* Purchase/Extend Section - Classic Mode */} + {classicOptions && classicOptions.periods.length > 0 && ( +
+
+

+ {subscription && !subscription.is_trial + ? t('subscription.extend') + : t('subscription.getSubscription')} +

+ {!showPurchaseForm && ( + + )} +
+ + {showPurchaseForm && ( +
+ {/* Step Indicator */} +
+
+ {t('subscription.step', { current: currentStepIndex + 1, total: steps.length })} +
+
+ {steps.map((step, idx) => ( +
+ ))} +
+
+ +
+ {getStepLabel(currentStep)} +
+ + {/* Step: Period Selection */} + {currentStep === 'period' && classicOptions && ( +
+ {classicOptions.periods.map((period) => { + const promoPeriod = applyPromoDiscount( + period.price_kopeks, + period.original_price_kopeks, + ); + + return ( + + ); + })} +
+ )} + + {/* Step: Traffic Selection */} + {currentStep === 'traffic' && selectedPeriod?.traffic.options && ( +
+ {selectedPeriod.traffic.options.map((option) => { + const promoTraffic = applyPromoDiscount( + option.price_kopeks, + option.original_price_kopeks, + ); + + return ( + + ); + })} +
+ )} + + {/* Step: Server Selection */} + {currentStep === 'servers' && selectedPeriod?.servers.options && ( +
+ {selectedPeriod.servers.options + .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 ( + + ); + })} +
+ )} + + {/* Step: Device Selection */} + {currentStep === 'devices' && selectedPeriod && ( +
+
+ +
+
{selectedDevices}
+
{t('subscription.devices')}
+
+ +
+
+
+ {t('subscription.devicesFree', { count: selectedPeriod.devices.min })} +
+ {selectedPeriod.devices.max > selectedPeriod.devices.min && ( +
+ {formatPrice(selectedPeriod.devices.price_per_device_kopeks)}{' '} + {t('subscription.perExtraDevice')} +
+ )} +
+
+ )} + + {/* Step: Confirm */} + {currentStep === 'confirm' && ( +
+ {previewLoading ? ( +
+
+
+ ) : preview ? ( +
+ {activeDiscount?.is_active && activeDiscount.discount_percent && ( +
+ + + + + {t('promo.discountApplied')} -{activeDiscount.discount_percent}% + +
+ )} + + {preview.breakdown.map((item, idx) => ( +
+ {item.label} + {item.value} +
+ ))} + + {(() => { + const promoTotal = applyPromoDiscount( + preview.total_price_kopeks, + preview.original_price_kopeks, + ); + + return ( +
+ + {t('subscription.total')} + +
+
+ {formatPrice(promoTotal.price)} +
+ {promoTotal.original && promoTotal.original > promoTotal.price && ( +
+ {formatPrice(promoTotal.original)} +
+ )} +
+
+ ); + })()} + + {preview.discount_label && ( +
+ {preview.discount_label} +
+ )} + + {!preview.can_purchase && + (preview.missing_amount_kopeks > 0 ? ( + + ) : preview.status_message ? ( +
+ {preview.status_message} +
+ ) : null)} +
+ ) : null} +
+ )} + + {/* Navigation Buttons */} +
+ {!isFirstStep && ( + + )} + + {isFirstStep && ( + + )} + + {!isLastStep ? ( + + ) : ( + + )} +
+ + {purchaseMutation.isError && ( +
+ {getErrorMessage(purchaseMutation.error)} +
+ )} +
+ )} +
+ )} +
+ ); +} diff --git a/src/utils/subscriptionHelpers.ts b/src/utils/subscriptionHelpers.ts new file mode 100644 index 0000000..8be7f94 --- /dev/null +++ b/src/utils/subscriptionHelpers.ts @@ -0,0 +1,42 @@ +import { AxiosError } from 'axios'; +import i18n from '../i18n'; + +export type PurchaseStep = 'period' | 'traffic' | 'servers' | 'devices' | 'confirm'; + +export 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'); +}; + +export const getInsufficientBalanceError = ( + error: unknown, +): { required: number; balance: number; missingAmount?: number } | null => { + if (error instanceof AxiosError) { + const detail = error.response?.data?.detail; + 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; +}; + +export 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); +};