From 349a9ca4210512d4e15e9ca977114d34bd0ad149 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 26 May 2026 23:44:31 +0300 Subject: [PATCH] refactor(purchase): extract SwitchTariffSheet from god page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the tariff-switch preview modal (137 lines of inline JSX + its previewTariffSwitch query + switchTariff mutation + the auto-scroll-into-view useEffect + the ref) into a self-contained sheet at src/components/subscription/sheets/SwitchTariffSheet.tsx. Parent keeps the trigger (switchTariffId selection state lives on the TariffPickerGrid card click) but no longer holds the query, the mutation, the ref, or the side-effect. The subscription_expired backend signal — which used to call three setters at parent level (setSelectedTariff, setSelectedTariffPeriod, setShowTariffPurchase) — now flows through a single onExpiredFallback(tariff) callback. SubscriptionPurchase.tsx: 2010 → 1841 lines (-169). --- .../subscription/sheets/SwitchTariffSheet.tsx | 239 ++++++++++++++++++ src/pages/SubscriptionPurchase.tsx | 204 ++------------- 2 files changed, 255 insertions(+), 188 deletions(-) create mode 100644 src/components/subscription/sheets/SwitchTariffSheet.tsx diff --git a/src/components/subscription/sheets/SwitchTariffSheet.tsx b/src/components/subscription/sheets/SwitchTariffSheet.tsx new file mode 100644 index 0000000..f84e039 --- /dev/null +++ b/src/components/subscription/sheets/SwitchTariffSheet.tsx @@ -0,0 +1,239 @@ +import { useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import { subscriptionApi } from '../../../api/subscription'; +import { getErrorMessage } from '../../../utils/subscriptionHelpers'; +import { useCurrency } from '../../../hooks/useCurrency'; +import InsufficientBalancePrompt from '../../InsufficientBalancePrompt'; +import type { Tariff } from '../../../types'; + +// ────────────────────────────────────────────────────────────────── +// SwitchTariffSheet +// +// Inline preview + confirm of a tariff switch on an existing +// subscription. Self-owns: +// - the previewTariffSwitch query +// - the switchTariff mutation +// - the auto-scroll-into-view effect +// +// The parent keeps the `switchTariffId` selection state because the +// trigger lives on a tariff card inside TariffPickerGrid. When the +// backend reports `subscription_expired` (the user's subscription +// lapsed while the modal was open), the sheet hands off to the +// parent via `onExpiredFallback(tariff)`, which opens the regular +// TariffPurchaseForm instead of attempting another switch. +// ────────────────────────────────────────────────────────────────── + +export interface SwitchTariffSheetProps { + open: boolean; + tariffId: number | null; + subscriptionId: number | undefined; + tariffs: Tariff[]; + onClose: () => void; + onExpiredFallback: (tariff: Tariff) => void; +} + +export function SwitchTariffSheet({ + open, + tariffId, + subscriptionId, + tariffs, + onClose, + onExpiredFallback, +}: SwitchTariffSheetProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { formatAmount, currencySymbol } = useCurrency(); + const ref = useRef(null); + + const formatPrice = (kopeks: number) => + kopeks === 0 + ? t('subscription.free', 'Бесплатно') + : `${formatAmount(kopeks / 100)} ${currencySymbol}`; + + const { data: switchPreview, isLoading: switchPreviewLoading } = useQuery({ + queryKey: ['tariff-switch-preview', tariffId], + queryFn: () => subscriptionApi.previewTariffSwitch(tariffId!, subscriptionId), + enabled: !!tariffId, + }); + + const switchMutation = useMutation({ + mutationFn: (id: number) => subscriptionApi.switchTariff(id, subscriptionId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] }); + queryClient.invalidateQueries({ queryKey: ['purchase-options', subscriptionId] }); + onClose(); + navigate('/subscriptions', { replace: true }); + }, + onError: (error: unknown) => { + // Backend signal: the subscription lapsed mid-flight. Hand the + // selected tariff back to the parent so it can open the regular + // purchase form instead. + if (error instanceof AxiosError) { + const detail = error.response?.data?.detail; + if ( + typeof detail === 'object' && + detail?.error_code === 'subscription_expired' && + detail?.use_purchase_flow === true + ) { + const targetTariff = tariffs.find((tariff) => tariff.id === tariffId); + if (targetTariff) { + onClose(); + onExpiredFallback(targetTariff); + queryClient.invalidateQueries({ queryKey: ['purchase-options', subscriptionId] }); + } + } + } + }, + }); + + // Smoothly scroll the panel into view when opened. + useEffect(() => { + if (open && ref.current) { + const timer = setTimeout(() => { + ref.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 100); + return () => clearTimeout(timer); + } + }, [open]); + + if (!open || !tariffId) return null; + + return ( +
+
+

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

+ +
+ + {switchPreviewLoading ? ( +
+
+
+ ) : ( + switchPreview && + (() => { + const targetTariff = tariffs.find((tariff) => tariff.id === tariffId); + const dailyPrice = + targetTariff?.daily_price_kopeks ?? targetTariff?.price_per_day_kopeks ?? 0; + const isDailyTariff = dailyPrice > 0; + + return ( + <> +
+
+ {t('subscription.switchTariff.currentTariff')} + + {switchPreview.current_tariff_name || '-'} + +
+
+ {t('subscription.switchTariff.newTariff')} + + {switchPreview.new_tariff_name} + +
+
+ {t('subscription.switchTariff.remainingDays')} + {switchPreview.remaining_days} +
+
+ + {isDailyTariff && ( +
+
+ {t('subscription.switchTariff.dailyPayment')} +
+
{formatPrice(dailyPrice)}
+
+ {t('subscription.switchTariff.dailyChargeDescription')} +
+
+ )} + +
+
+ + {t('subscription.switchTariff.upgradeCost')} + + {switchPreview.discount_percent && switchPreview.discount_percent > 0 && ( + + -{switchPreview.discount_percent}% + + )} +
+
+ {switchPreview.discount_percent && + switchPreview.discount_percent > 0 && + switchPreview.base_upgrade_cost_kopeks && + switchPreview.base_upgrade_cost_kopeks > 0 && ( + + {formatPrice(switchPreview.base_upgrade_cost_kopeks)} + + )} + + {switchPreview.upgrade_cost_kopeks > 0 + ? switchPreview.upgrade_cost_label + : t('subscription.switchTariff.free')} + +
+
+ + {!switchPreview.has_enough_balance && switchPreview.upgrade_cost_kopeks > 0 && ( + + )} + + + + {switchMutation.isError && + (() => { + // Suppress the toast when the subscription_expired + // fallback already triggered: the parent is now + // showing the regular purchase form, surfacing the + // raw axios message here would be misleading. + const detail = + switchMutation.error instanceof AxiosError + ? switchMutation.error.response?.data?.detail + : null; + if (typeof detail === 'object' && detail?.error_code === 'subscription_expired') { + return null; + } + return ( +
+ {getErrorMessage(switchMutation.error)} +
+ ); + })()} + + ); + })() + )} +
+ ); +} diff --git a/src/pages/SubscriptionPurchase.tsx b/src/pages/SubscriptionPurchase.tsx index 32c73e8..170ed7e 100644 --- a/src/pages/SubscriptionPurchase.tsx +++ b/src/pages/SubscriptionPurchase.tsx @@ -2,7 +2,6 @@ import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { useNavigate, useSearchParams } from 'react-router'; -import { AxiosError } from 'axios'; import { subscriptionApi } from '../api/subscription'; import { usePromoDiscount } from '../hooks/usePromoDiscount'; import { WebBackButton } from '../components/WebBackButton'; @@ -20,6 +19,7 @@ import { useCurrency } from '../hooks/useCurrency'; import { useCloseOnSuccessNotification } from '../store/successNotification'; import { CheckIcon } from '../components/icons'; import Twemoji from 'react-twemoji'; +import { SwitchTariffSheet } from '../components/subscription/sheets/SwitchTariffSheet'; import { getErrorMessage, getInsufficientBalanceError, @@ -102,8 +102,7 @@ export default function SubscriptionPurchase() { const [useCustomDays, setUseCustomDays] = useState(false); const [useCustomTraffic, setUseCustomTraffic] = useState(false); - // Refs for auto-scroll - const switchModalRef = useRef(null); + // Refs for auto-scroll (switch-modal ref moved into ) const tariffPurchaseRef = useRef(null); // Tariff switch @@ -206,44 +205,7 @@ export default function SubscriptionPurchase() { }, }); - // Switch preview query - const { data: switchPreview, isLoading: switchPreviewLoading } = useQuery({ - queryKey: ['tariff-switch-preview', switchTariffId], - queryFn: () => subscriptionApi.previewTariffSwitch(switchTariffId!, subscriptionId), - enabled: !!switchTariffId, - }); - - // Tariff switch mutation - const switchTariffMutation = useMutation({ - mutationFn: (tariffId: number) => subscriptionApi.switchTariff(tariffId, subscriptionId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] }); - queryClient.invalidateQueries({ queryKey: ['purchase-options', subscriptionId] }); - setSwitchTariffId(null); - - navigate('/subscriptions', { replace: true }); - }, - onError: (error: unknown) => { - if (error instanceof AxiosError) { - const detail = error.response?.data?.detail; - if ( - typeof detail === 'object' && - detail?.error_code === 'subscription_expired' && - detail?.use_purchase_flow === true - ) { - const targetTariff = tariffs.find((tariff) => tariff.id === switchTariffId); - if (targetTariff) { - setSwitchTariffId(null); - - setSelectedTariff(targetTariff); - setSelectedTariffPeriod(targetTariff.periods[0] || null); - setShowTariffPurchase(true); - queryClient.invalidateQueries({ queryKey: ['purchase-options', subscriptionId] }); - } - } - } - }, - }); + // (switch preview query + switchTariffMutation moved into ) // Tariff purchase mutation const tariffPurchaseMutation = useMutation({ @@ -281,16 +243,7 @@ export default function SubscriptionPurchase() { }, }); - // Auto-scroll effects - useEffect(() => { - if (switchTariffId && switchModalRef.current) { - const timer = setTimeout(() => { - switchModalRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }, 100); - return () => clearTimeout(timer); - } - }, [switchTariffId]); - + // Auto-scroll effects (switch-tariff scroll moved into the sheet) useEffect(() => { if (showTariffPurchase && tariffPurchaseRef.current) { const timer = setTimeout(() => { @@ -523,143 +476,18 @@ export default function SubscriptionPurchase() { )} {/* Switch Tariff Preview Modal */} - {switchTariffId && ( -
-
-

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

- -
- - {switchPreviewLoading ? ( -
-
-
- ) : ( - switchPreview && - (() => { - const targetTariff = tariffs.find((tariff) => tariff.id === switchTariffId); - const dailyPrice = - targetTariff?.daily_price_kopeks ?? targetTariff?.price_per_day_kopeks ?? 0; - const isDailyTariff = dailyPrice > 0; - - return ( - <> -
-
- {t('subscription.switchTariff.currentTariff')} - - {switchPreview.current_tariff_name || '-'} - -
-
- {t('subscription.switchTariff.newTariff')} - - {switchPreview.new_tariff_name} - -
-
- {t('subscription.switchTariff.remainingDays')} - {switchPreview.remaining_days} -
-
- - {isDailyTariff && ( -
-
- {t('subscription.switchTariff.dailyPayment')} -
-
- {formatPrice(dailyPrice)} -
-
- {t('subscription.switchTariff.dailyChargeDescription')} -
-
- )} - -
-
- - {t('subscription.switchTariff.upgradeCost')} - - {switchPreview.discount_percent && switchPreview.discount_percent > 0 && ( - - -{switchPreview.discount_percent}% - - )} -
-
- {switchPreview.discount_percent && - switchPreview.discount_percent > 0 && - switchPreview.base_upgrade_cost_kopeks && - switchPreview.base_upgrade_cost_kopeks > 0 && ( - - {formatPrice(switchPreview.base_upgrade_cost_kopeks)} - - )} - - {switchPreview.upgrade_cost_kopeks > 0 - ? switchPreview.upgrade_cost_label - : t('subscription.switchTariff.free')} - -
-
- - {!switchPreview.has_enough_balance && - switchPreview.upgrade_cost_kopeks > 0 && ( - - )} - - - - {switchTariffMutation.isError && - (() => { - const detail = - switchTariffMutation.error instanceof AxiosError - ? switchTariffMutation.error.response?.data?.detail - : null; - if ( - typeof detail === 'object' && - detail?.error_code === 'subscription_expired' - ) { - return null; - } - return ( -
- {getErrorMessage(switchTariffMutation.error)} -
- ); - })()} - - ); - })() - )} -
- )} + setSwitchTariffId(null)} + onExpiredFallback={(tariff) => { + setSelectedTariff(tariff); + setSelectedTariffPeriod(tariff.periods[0] || null); + setShowTariffPurchase(true); + }} + /> {!showTariffPurchase ? ( <>