mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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:
602
src/components/subscription/purchase/ClassicPurchaseWizard.tsx
Normal file
602
src/components/subscription/purchase/ClassicPurchaseWizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user