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. // ────────────────────────────────────────────────────────────────── // The backend rejects a switch that must instead go through the purchase flow: // the subscription lapsed (`subscription_expired`), it is a trial that has no // paid value to prorate and would otherwise be handed a full target period // (`trial_cannot_switch`, bug #629889), or it sits on a free 0₽ tariff whose // spammed/gifted remainder must reset rather than be prorated and carried // (`free_tariff_cannot_switch`, TARIFF_SWITCH_RESET_FREE_DAYS). All arrive as // detail.code + use_purchase_flow=true; some payloads use the legacy // `error_code` key, so we accept either. function shouldUsePurchaseFlow(error: unknown): boolean { if (!(error instanceof AxiosError)) return false; const detail = error.response?.data?.detail as | { code?: string; error_code?: string; use_purchase_flow?: boolean } | undefined; if (!detail || typeof detail !== 'object') return false; const code = detail.code ?? detail.error_code; return ( (code === 'subscription_expired' || code === 'trial_cannot_switch' || code === 'free_tariff_cannot_switch') && detail.use_purchase_flow === true ); } 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: this subscription can't be switched (it lapsed, or it's // a trial). Hand the selected tariff back to the parent so it opens the // regular purchase form instead. if (shouldUsePurchaseFlow(error)) { 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 purchase-flow fallback already // triggered (expired / trial): the parent is now showing the // regular purchase form, so the raw axios message would mislead. if (shouldUsePurchaseFlow(switchMutation.error)) { return null; } return (
{getErrorMessage(switchMutation.error)}
); })()} ); })() )}
); }