diff --git a/src/components/subscription/purchase/ClassicPurchaseWizard.tsx b/src/components/subscription/purchase/ClassicPurchaseWizard.tsx new file mode 100644 index 0000000..9b46bea --- /dev/null +++ b/src/components/subscription/purchase/ClassicPurchaseWizard.tsx @@ -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('period'); + const [selectedPeriod, setSelectedPeriod] = useState(null); + const [selectedTraffic, setSelectedTraffic] = useState(null); + const [selectedServers, setSelectedServers] = useState([]); + const [selectedDevices, setSelectedDevices] = useState(1); + const [showPurchaseForm, setShowPurchaseForm] = useState(false); + + // 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(() => { + 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 ( +
+
+

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

+ {!showPurchaseForm && ( + + )} +
+ + {showPurchaseForm && ( +
+ {/* Step Indicator */} +
+
+ {t('subscription.step', { current: currentStepIndex + 1, total: steps.length })} +
+
+ {steps.map((step, idx) => ( +
+ ))} +
+
+ +
{getStepLabel(currentStep)}
+ + {/* Step: Period Selection */} + {currentStep === 'period' && ( +
+ {classicOptions.periods.map((period) => { + const promoPeriod = applyPromoDiscount( + period.price_kopeks, + period.original_price_kopeks, + ); + + return ( + + ); + })} +
+ )} + + {/* Step: Traffic Selection */} + {currentStep === 'traffic' && selectedPeriod?.traffic.options && ( +
+ {selectedPeriod.traffic.options.map((option) => { + const promoTraffic = applyPromoDiscount( + option.price_kopeks, + option.original_price_kopeks, + ); + + return ( + + ); + })} +
+ )} + + {/* Step: Server Selection */} + {currentStep === 'servers' && selectedPeriod?.servers.options && ( +
+ {selectedPeriod.servers.options + .filter((server) => { + if (!server.is_available) return false; + if (subscription?.is_trial && server.name.toLowerCase().includes('trial')) { + return false; + } + return true; + }) + .map((server) => { + const promoServer = applyPromoDiscount( + server.price_kopeks, + server.original_price_kopeks, + ); + + return ( + + ); + })} +
+ )} + + {/* Step: Device Selection */} + {currentStep === 'devices' && selectedPeriod && ( +
+
+ +
+
{selectedDevices}
+
{t('subscription.devices')}
+
+ +
+
+
+ {t('subscription.devicesFree', { count: selectedPeriod.devices.min })} +
+ {selectedPeriod.devices.max > selectedPeriod.devices.min && ( +
+ {formatPrice(selectedPeriod.devices.price_per_device_kopeks)}{' '} + {t('subscription.perExtraDevice')} +
+ )} +
+
+ )} + + {/* Step: Confirm */} + {currentStep === 'confirm' && ( +
+ {previewLoading ? ( +
+
+
+ ) : preview ? ( +
+ {activeDiscount?.is_active && activeDiscount.discount_percent && ( +
+ + + + + {t('promo.discountApplied')} -{activeDiscount.discount_percent}% + +
+ )} + + {preview.breakdown.map((item, idx) => ( +
+ {item.label} + {item.value} +
+ ))} + + {(() => { + const promoTotal = applyPromoDiscount( + preview.total_price_kopeks, + preview.original_price_kopeks, + ); + + return ( +
+ + {t('subscription.total')} + +
+
+ {formatPrice(promoTotal.price)} +
+ {promoTotal.original && promoTotal.original > promoTotal.price && ( +
+ {formatPrice(promoTotal.original)} +
+ )} +
+
+ ); + })()} + + {preview.discount_label && ( +
+ {preview.discount_label} +
+ )} + + {!preview.can_purchase && + (preview.missing_amount_kopeks > 0 ? ( + + ) : preview.status_message ? ( +
+ {preview.status_message} +
+ ) : null)} +
+ ) : null} +
+ )} + + {/* Navigation Buttons */} +
+ {!isFirstStep && ( + + )} + + {isFirstStep && ( + + )} + + {!isLastStep ? ( + + ) : ( + + )} +
+ + {purchaseMutation.isError && ( +
+ {getErrorMessage(purchaseMutation.error)} +
+ )} +
+ )} +
+ ); +} diff --git a/src/pages/SubscriptionPurchase.tsx b/src/pages/SubscriptionPurchase.tsx index ba17404..28be245 100644 --- a/src/pages/SubscriptionPurchase.tsx +++ b/src/pages/SubscriptionPurchase.tsx @@ -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('period'); - const [selectedPeriod, setSelectedPeriod] = useState(null); - const [selectedTraffic, setSelectedTraffic] = useState(null); - const [selectedServers, setSelectedServers] = useState([]); - const [selectedDevices, setSelectedDevices] = useState(1); - const [showPurchaseForm, setShowPurchaseForm] = useState(false); + // (classic-mode state moved into ) // Tariffs mode state const [selectedTariff, setSelectedTariff] = useState(null); @@ -100,7 +79,7 @@ export default function SubscriptionPurchase() { // Auto-close all modals on success notification const handleCloseAllModals = () => { - setShowPurchaseForm(false); + // setShowPurchaseForm moved into '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(() => { - 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 ) // (tariffPurchaseMutation moved into ) // (auto-scroll effects: switch-modal into , // tariff-purchase into ) - // 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 ) if (isLoading || optionsLoading) { return ( @@ -469,415 +319,11 @@ export default function SubscriptionPurchase() { {/* Purchase/Extend Section - Classic Mode */} {classicOptions && classicOptions.periods.length > 0 && ( -
-
-

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

- {!showPurchaseForm && ( - - )} -
- - {showPurchaseForm && ( -
- {/* Step Indicator */} -
-
- {t('subscription.step', { current: currentStepIndex + 1, total: steps.length })} -
-
- {steps.map((step, idx) => ( -
- ))} -
-
- -
- {getStepLabel(currentStep)} -
- - {/* Step: Period Selection */} - {currentStep === 'period' && classicOptions && ( -
- {classicOptions.periods.map((period) => { - const promoPeriod = applyPromoDiscount( - period.price_kopeks, - period.original_price_kopeks, - ); - - return ( - - ); - })} -
- )} - - {/* Step: Traffic Selection */} - {currentStep === 'traffic' && selectedPeriod?.traffic.options && ( -
- {selectedPeriod.traffic.options.map((option) => { - const promoTraffic = applyPromoDiscount( - option.price_kopeks, - option.original_price_kopeks, - ); - - return ( - - ); - })} -
- )} - - {/* Step: Server Selection */} - {currentStep === 'servers' && selectedPeriod?.servers.options && ( -
- {selectedPeriod.servers.options - .filter((server) => { - if (!server.is_available) return false; - if (subscription?.is_trial && server.name.toLowerCase().includes('trial')) { - return false; - } - return true; - }) - .map((server) => { - const promoServer = applyPromoDiscount( - server.price_kopeks, - server.original_price_kopeks, - ); - - return ( - - ); - })} -
- )} - - {/* Step: Device Selection */} - {currentStep === 'devices' && selectedPeriod && ( -
-
- -
-
{selectedDevices}
-
{t('subscription.devices')}
-
- -
-
-
- {t('subscription.devicesFree', { count: selectedPeriod.devices.min })} -
- {selectedPeriod.devices.max > selectedPeriod.devices.min && ( -
- {formatPrice(selectedPeriod.devices.price_per_device_kopeks)}{' '} - {t('subscription.perExtraDevice')} -
- )} -
-
- )} - - {/* Step: Confirm */} - {currentStep === 'confirm' && ( -
- {previewLoading ? ( -
-
-
- ) : preview ? ( -
- {activeDiscount?.is_active && activeDiscount.discount_percent && ( -
- - - - - {t('promo.discountApplied')} -{activeDiscount.discount_percent}% - -
- )} - - {preview.breakdown.map((item, idx) => ( -
- {item.label} - {item.value} -
- ))} - - {(() => { - const promoTotal = applyPromoDiscount( - preview.total_price_kopeks, - preview.original_price_kopeks, - ); - - return ( -
- - {t('subscription.total')} - -
-
- {formatPrice(promoTotal.price)} -
- {promoTotal.original && promoTotal.original > promoTotal.price && ( -
- {formatPrice(promoTotal.original)} -
- )} -
-
- ); - })()} - - {preview.discount_label && ( -
- {preview.discount_label} -
- )} - - {!preview.can_purchase && - (preview.missing_amount_kopeks > 0 ? ( - - ) : preview.status_message ? ( -
- {preview.status_message} -
- ) : null)} -
- ) : null} -
- )} - - {/* Navigation Buttons */} -
- {!isFirstStep && ( - - )} - - {isFirstStep && ( - - )} - - {!isLastStep ? ( - - ) : ( - - )} -
- - {purchaseMutation.isError && ( -
- {getErrorMessage(purchaseMutation.error)} -
- )} -
- )} -
+ )} {/* No options available fallback */}