refactor(purchase): extract ClassicPurchaseWizard from god page

The largest and last structural extraction in the SubscriptionPurchase
decomp. Moves the classic-mode purchase flow (period → traffic →
servers → devices → confirm) into a self-contained step wizard at
src/components/subscription/purchase/ClassicPurchaseWizard.tsx.

The wizard self-owns:
- the preview query (gated on confirm step + form open)
- the submitPurchase mutation
- all six pieces of wizard state (currentStep, selectedPeriod,
  selectedTraffic, selectedServers, selectedDevices, showPurchaseForm)
- the steps memo + currentSelection memo + currentStepIndex/isFirst/isLast
- the init useEffect (seeds from classicOptions.selection)
- toggleServer / goToNextStep / goToPrevStep / resetPurchase / getStepLabel
- its own useCloseOnSuccessNotification handler (closes form + resets
  step on global success notification) — replaces the parent's
  handleCloseAllModals dependency for the classic flow

Parent shrinks to a pure orchestrator: route params, three top-level
queries (subscription, purchase-options, subscriptions-list), mode
detection, two pieces of tariffs-mode state, the success-notification
handler for the remaining tariff modals, and JSX that delegates to
the four extracted sub-components.

SubscriptionPurchase.tsx: 908 → 370 lines (-538).
Cumulative across the 5-step decomp: 2054 → 370 (-1684, -82%).
Drops 11 parent imports (useEffect, useMemo, useCallback, useMutation,
useQueryClient, useNavigate, PurchaseSelection, PeriodOption,
InsufficientBalancePrompt, CheckIcon, Twemoji, useCurrency,
usePromoDiscount, getErrorMessage, PurchaseStep).
This commit is contained in:
c0mrade
2026-05-27 00:02:31 +03:00
parent cf5299973a
commit 72c325f8cc
2 changed files with 617 additions and 569 deletions

View File

@@ -0,0 +1,602 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { subscriptionApi } from '../../../api/subscription';
import { useTheme } from '../../../hooks/useTheme';
import { useCurrency } from '../../../hooks/useCurrency';
import { usePromoDiscount } from '../../../hooks/usePromoDiscount';
import { useCloseOnSuccessNotification } from '../../../store/successNotification';
import { getGlassColors } from '../../../utils/glassTheme';
import { getErrorMessage, type PurchaseStep } from '../../../utils/subscriptionHelpers';
import { CheckIcon } from '../../icons';
import InsufficientBalancePrompt from '../../InsufficientBalancePrompt';
import Twemoji from 'react-twemoji';
import type {
ClassicPurchaseOptions,
PeriodOption,
PurchaseSelection,
Subscription,
} from '../../../types';
// ──────────────────────────────────────────────────────────────────
// ClassicPurchaseWizard
//
// The classic-mode (non-tariff) purchase flow: a step wizard with
// period → traffic (optional) → servers (when >1) → devices
// (optional) → confirm. Self-owns:
// - the preview query (gated to confirm step)
// - the submitPurchase mutation
// - all six pieces of wizard state (currentStep, selectedPeriod,
// selectedTraffic, selectedServers, selectedDevices, showForm)
// - the steps memo + currentSelection memo
// - the init effect that seeds defaults from classicOptions.selection
// - useCloseOnSuccessNotification (resets form + step on global
// success notification)
//
// Parent only supplies the loaded options and the subscription
// context. Replaces ~411 lines of inline JSX in SubscriptionPurchase.
// ──────────────────────────────────────────────────────────────────
export interface ClassicPurchaseWizardProps {
classicOptions: ClassicPurchaseOptions;
subscription: Subscription | null;
subscriptionId: number | undefined;
}
export function ClassicPurchaseWizard({
classicOptions,
subscription,
subscriptionId,
}: ClassicPurchaseWizardProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { isDark } = useTheme();
const g = getGlassColors(isDark);
const { formatAmount, currencySymbol } = useCurrency();
const { activeDiscount, applyPromoDiscount } = usePromoDiscount();
const formatPrice = (kopeks: number) =>
kopeks === 0
? t('subscription.free', 'Бесплатно')
: `${formatAmount(kopeks / 100)} ${currencySymbol}`;
// Wizard state
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);
// Global success-notification handler — resets back to the closed,
// period-step state. Mirrors the parent's old handleCloseAllModals
// contract for this flow.
useCloseOnSuccessNotification(() => {
setShowPurchaseForm(false);
setCurrentStep('period');
});
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],
);
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);
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;
// Seed defaults from classicOptions.selection on first mount.
useEffect(() => {
if (!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]);
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],
);
const { data: preview, isLoading: previewLoading } = useQuery({
queryKey: ['purchase-preview', currentSelection],
queryFn: () => subscriptionApi.previewPurchase(currentSelection, subscriptionId),
enabled: !!selectedPeriod && showPurchaseForm && currentStep === 'confirm',
});
const purchaseMutation = useMutation({
mutationFn: () => subscriptionApi.submitPurchase(currentSelection, subscriptionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
queryClient.invalidateQueries({ queryKey: ['purchase-options', subscriptionId] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
navigate('/subscriptions', { replace: true });
},
});
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');
}
};
return (
<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">
{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' && (
<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 (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-warning-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-warning-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
.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-warning-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">
<Twemoji options={{ className: 'twemoji', folder: 'svg', ext: '.svg' }}>
{server.name}
</Twemoji>
</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">
{activeDiscount?.is_active && activeDiscount.discount_percent && (
<div className="flex items-center justify-center gap-2 rounded-lg border border-warning-500/30 bg-warning-500/10 p-3">
<svg
className="h-4 w-4 text-warning-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-warning-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>
);
}

View File

@@ -1,40 +1,27 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useNavigate, useSearchParams } from 'react-router';
import { useSearchParams } from 'react-router';
import { subscriptionApi } from '../api/subscription';
import { usePromoDiscount } from '../hooks/usePromoDiscount';
import { WebBackButton } from '../components/WebBackButton';
import { getGlassColors } from '../utils/glassTheme';
import { useTheme } from '../hooks/useTheme';
import type { PurchaseSelection, PeriodOption, Tariff, ClassicPurchaseOptions } from '../types';
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
import { useCurrency } from '../hooks/useCurrency';
import type { Tariff, ClassicPurchaseOptions } from '../types';
import { useCloseOnSuccessNotification } from '../store/successNotification';
import { CheckIcon } from '../components/icons';
import Twemoji from 'react-twemoji';
import { SwitchTariffSheet } from '../components/subscription/sheets/SwitchTariffSheet';
import { TariffPurchaseForm } from '../components/subscription/purchase/TariffPurchaseForm';
import { TariffPickerGrid } from '../components/subscription/purchase/TariffPickerGrid';
import { getErrorMessage, type PurchaseStep } from '../utils/subscriptionHelpers';
import { ClassicPurchaseWizard } from '../components/subscription/purchase/ClassicPurchaseWizard';
export default function SubscriptionPurchase() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const subscriptionId = searchParams.get('subscriptionId')
? parseInt(searchParams.get('subscriptionId')!, 10)
: undefined;
const { formatAmount, currencySymbol } = useCurrency();
const { isDark } = useTheme();
const g = getGlassColors(isDark);
const formatPrice = (kopeks: number) =>
kopeks === 0
? t('subscription.free', 'Бесплатно')
: `${formatAmount(kopeks / 100)} ${currencySymbol}`;
// Subscription query (shares cache with /subscription page)
const { data: subscriptionResponse, isLoading } = useQuery({
queryKey: ['subscription', subscriptionId],
@@ -58,9 +45,6 @@ export default function SubscriptionPurchase() {
refetchOnMount: 'always',
});
// Active promo discount + applyPromoDiscount helper (hook owns both)
const { activeDiscount, applyPromoDiscount } = usePromoDiscount();
// Sales mode detection
const isTariffsMode = purchaseOptions?.sales_mode === 'tariffs';
const classicOptions = !isTariffsMode ? (purchaseOptions as ClassicPurchaseOptions) : null;
@@ -75,15 +59,10 @@ export default function SubscriptionPurchase() {
});
const isMultiTariff = multiSubData?.multi_tariff_enabled ?? false;
// (applyPromoDiscount moved into usePromoDiscount above)
// (active promo discount + applyPromoDiscount live in usePromoDiscount;
// consumed directly by the sub-components, not threaded as props)
// Classic mode state
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);
// (classic-mode state moved into <ClassicPurchaseWizard>)
// Tariffs mode state
const [selectedTariff, setSelectedTariff] = useState<Tariff | null>(null);
@@ -100,7 +79,7 @@ export default function SubscriptionPurchase() {
// Auto-close all modals on success notification
const handleCloseAllModals = () => {
setShowPurchaseForm(false);
// setShowPurchaseForm moved into <ClassicPurchaseWizard>'s own useCloseOnSuccessNotification
setShowTariffPurchase(false);
setSwitchTariffId(null);
@@ -109,142 +88,13 @@ export default function SubscriptionPurchase() {
};
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<PurchaseStep[]>(() => {
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, subscriptionId),
enabled: !!selectedPeriod && showPurchaseForm && currentStep === 'confirm',
});
// Classic purchase mutation
const purchaseMutation = useMutation({
mutationFn: () => subscriptionApi.submitPurchase(currentSelection, subscriptionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
queryClient.invalidateQueries({ queryKey: ['purchase-options', subscriptionId] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
navigate('/subscriptions', { replace: true });
},
});
// (switch preview query + switchTariffMutation moved into <SwitchTariffSheet>)
// (tariffPurchaseMutation moved into <TariffPurchaseForm>)
// (auto-scroll effects: switch-modal into <SwitchTariffSheet>,
// tariff-purchase into <TariffPurchaseForm>)
// 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');
}
};
// (classic-mode helpers moved into <ClassicPurchaseWizard>)
if (isLoading || optionsLoading) {
return (
@@ -469,415 +319,11 @@ export default function SubscriptionPurchase() {
{/* Purchase/Extend Section - Classic Mode */}
{classicOptions && classicOptions.periods.length > 0 && (
<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">
{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 (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-warning-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-warning-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
.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-warning-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">
<Twemoji
options={{ className: 'twemoji', folder: 'svg', ext: '.svg' }}
>
{server.name}
</Twemoji>
</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">
{activeDiscount?.is_active && activeDiscount.discount_percent && (
<div className="flex items-center justify-center gap-2 rounded-lg border border-warning-500/30 bg-warning-500/10 p-3">
<svg
className="h-4 w-4 text-warning-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-warning-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>
<ClassicPurchaseWizard
classicOptions={classicOptions}
subscription={subscription}
subscriptionId={subscriptionId}
/>
)}
{/* No options available fallback */}