mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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).
603 lines
25 KiB
TypeScript
603 lines
25 KiB
TypeScript
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>
|
|
);
|
|
}
|