import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { subscriptionApi } from '../../../api/subscription'; import { getErrorMessage, getInsufficientBalanceError } from '../../../utils/subscriptionHelpers'; import { useCurrency } from '../../../hooks/useCurrency'; import { usePromoDiscount } from '../../../hooks/usePromoDiscount'; import InsufficientBalancePrompt from '../../InsufficientBalancePrompt'; import type { Tariff, TariffPeriod } from '../../../types'; // ────────────────────────────────────────────────────────────────── // TariffPurchaseForm // // The full per-tariff purchase form: period picker (or daily-tariff // activate), custom-days toggle + slider, custom-traffic toggle + // slider, summary, and the confirm CTA. Self-owns: // - the purchaseTariff mutation // - the auto-scroll-into-view ref + effect on mount // - selectedTariffPeriod / customDays / customTrafficGb / // useCustomDays / useCustomTraffic (form-internal state, reset // by re-mount when the parent passes a new `tariff` via key=) // // The parent (SubscriptionPurchase) supplies the chosen tariff, // the current balance (for inline insufficient-balance prompts), // the subscription id (for the renew-this-subscription flow), and // onBack to clear its own selection state. // ────────────────────────────────────────────────────────────────── export interface TariffPurchaseFormProps { tariff: Tariff; subscriptionId: number | undefined; balanceKopeks: number | undefined; onBack: () => void; } export function TariffPurchaseForm({ tariff, subscriptionId, balanceKopeks, onBack, }: TariffPurchaseFormProps) { const { t } = useTranslation(); const navigate = useNavigate(); const queryClient = useQueryClient(); const { formatAmount, currencySymbol } = useCurrency(); const { applyPromoDiscount } = usePromoDiscount(); const ref = useRef(null); const formatPrice = (kopeks: number) => kopeks === 0 ? t('subscription.free', 'Бесплатно') : `${formatAmount(kopeks / 100)} ${currencySymbol}`; // Form-internal state — seeded from the tariff prop. Resets via // `key={tariff.id}` on the parent's render. const [selectedTariffPeriod, setSelectedTariffPeriod] = useState( tariff.periods[0] || null, ); const [customDays, setCustomDays] = useState(30); const [customTrafficGb, setCustomTrafficGb] = useState(50); const [useCustomDays, setUseCustomDays] = useState(false); const [useCustomTraffic, setUseCustomTraffic] = useState(false); const purchaseMutation = useMutation({ mutationFn: () => { const isDailyTariff = tariff.is_daily || (tariff.daily_price_kopeks && tariff.daily_price_kopeks > 0); const days = isDailyTariff ? 1 : useCustomDays ? customDays : selectedTariffPeriod?.days || 30; const trafficGb = useCustomTraffic && tariff.custom_traffic_enabled ? customTrafficGb : undefined; // Forward the subscription_id when the user landed here via the // "Renew this subscription" flow (?subscriptionId=N). The backend // uses it to resolve the exact target row by ID, avoiding the // race with concurrent panel webhooks that would otherwise hit // the partial UNIQUE on uq_subscriptions_user_tariff_active. return subscriptionApi.purchaseTariff( tariff.id, days, trafficGb, subscriptionId ?? undefined, ); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['subscription'] }); queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] }); navigate('/subscriptions', { replace: true }); }, }); // Smooth scroll the form into view when first mounted. useEffect(() => { if (ref.current) { const timer = setTimeout(() => { ref.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 100); return () => clearTimeout(timer); } }, []); return (

{tariff.name}

{/* Tariff Info */}
{t('subscription.traffic')}: {tariff.traffic_limit_label}
{t('subscription.devices')}: {tariff.device_limit === 0 ? '∞' : tariff.device_limit} {tariff.extra_devices_count > 0 && ( (+{tariff.extra_devices_count}) )}
{/* Daily Tariff Purchase */} {tariff.is_daily || (tariff.daily_price_kopeks && tariff.daily_price_kopeks > 0) ? (
{t('subscription.dailyPurchase.costPerDay')}
{formatPrice(tariff.daily_price_kopeks || 0)}
{t('subscription.dailyPurchase.chargedDaily')}
{t('subscription.dailyPurchase.canPause')}
{t('subscription.dailyPurchase.pausedOnLowBalance')}
{(() => { const dailyPrice = tariff.daily_price_kopeks || 0; const hasEnoughBalance = balanceKopeks !== undefined && dailyPrice <= balanceKopeks; return (
{balanceKopeks !== undefined && !hasEnoughBalance && ( )} {purchaseMutation.isError && !getInsufficientBalanceError(purchaseMutation.error) && (
{getErrorMessage(purchaseMutation.error)}
)} {purchaseMutation.isError && getInsufficientBalanceError(purchaseMutation.error) && (
)}
); })()}
) : ( <> {/* Period Selection for non-daily tariffs */}
{t('subscription.selectPeriod')}
{tariff.periods.length > 0 && !useCustomDays && (
{tariff.periods.map((period) => { const promoPeriod = applyPromoDiscount( period.price_kopeks, period.original_price_kopeks, ); const displayDiscount = promoPeriod.percent; const displayOriginal = promoPeriod.original; const displayPrice = promoPeriod.price; const displayPerMonth = displayPrice !== period.price_kopeks ? Math.round(displayPrice / Math.max(1, period.days / 30)) : period.price_per_month_kopeks; return ( ); })}
)} {/* No periods available fallback */} {tariff.periods.length === 0 && !useCustomDays && !(tariff.custom_days_enabled && (tariff.price_per_day_kopeks ?? 0) > 0) && (
{t('subscription.noPeriodsAvailable')}
{t('subscription.noPeriodsAvailableHint')}
)} {/* Custom days option */} {tariff.custom_days_enabled && (tariff.price_per_day_kopeks ?? 0) > 0 && (
{t('subscription.customDays.title')}
{useCustomDays && (
setCustomDays(parseInt(e.target.value))} className="flex-1 accent-accent-500" /> setCustomDays( Math.max( tariff.min_days ?? 1, Math.min( tariff.max_days ?? 365, parseInt(e.target.value) || (tariff.min_days ?? 1), ), ), ) } className="w-20 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-center text-dark-100" />
{(() => { const basePrice = customDays * (tariff.price_per_day_kopeks ?? 0); const existingOriginal = tariff.original_price_per_day_kopeks && tariff.original_price_per_day_kopeks > (tariff.price_per_day_kopeks ?? 0) ? customDays * tariff.original_price_per_day_kopeks : undefined; const promoCustom = applyPromoDiscount(basePrice, existingOriginal); return (
{t('subscription.days', { count: customDays })} ×{' '} {formatPrice(tariff.price_per_day_kopeks ?? 0)}/ {t('subscription.customDays.perDay')}
{formatPrice(promoCustom.price)} {promoCustom.original && promoCustom.original > promoCustom.price && ( <> {formatPrice(promoCustom.original)} -{promoCustom.percent}% )}
); })()}
)}
)}
{/* Custom traffic option */} {tariff.custom_traffic_enabled && (tariff.traffic_price_per_gb_kopeks ?? 0) > 0 && (
{t('subscription.customTraffic.label')}
{t('subscription.customTraffic.selectVolume')}
{!useCustomTraffic && (
{t('subscription.customTraffic.default', { label: tariff.traffic_limit_label, })}
)} {useCustomTraffic && (
setCustomTrafficGb(parseInt(e.target.value))} className="flex-1 accent-accent-500" />
setCustomTrafficGb( Math.max( tariff.min_traffic_gb ?? 1, Math.min( tariff.max_traffic_gb ?? 1000, parseInt(e.target.value) || (tariff.min_traffic_gb ?? 1), ), ), ) } className="w-20 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-center text-dark-100" /> {t('common.units.gb')}
{customTrafficGb} {t('common.units.gb')} ×{' '} {formatPrice(tariff.traffic_price_per_gb_kopeks ?? 0)}/ {t('common.units.gb')} +{formatPrice(customTrafficGb * (tariff.traffic_price_per_gb_kopeks ?? 0))}
)}
)} {/* Summary & Purchase */} {(selectedTariffPeriod || useCustomDays) && (
{(() => { const basePeriodPrice = useCustomDays ? customDays * (tariff.price_per_day_kopeks ?? 0) : selectedTariffPeriod?.price_kopeks || 0; const existingPeriodOriginal = useCustomDays ? tariff.original_price_per_day_kopeks && tariff.original_price_per_day_kopeks > (tariff.price_per_day_kopeks ?? 0) ? customDays * tariff.original_price_per_day_kopeks : undefined : selectedTariffPeriod?.original_price_kopeks && selectedTariffPeriod.original_price_kopeks > selectedTariffPeriod.price_kopeks ? selectedTariffPeriod.original_price_kopeks : undefined; const promoPeriod = applyPromoDiscount(basePeriodPrice, existingPeriodOriginal); const trafficPrice = useCustomTraffic && tariff.custom_traffic_enabled ? customTrafficGb * (tariff.traffic_price_per_gb_kopeks ?? 0) : 0; const totalPrice = promoPeriod.price + trafficPrice; const originalTotal = promoPeriod.original ? promoPeriod.original + trafficPrice : null; return ( <>
{useCustomDays ? (
{t('subscription.stepPeriod')}:{' '} {t('subscription.days', { count: customDays })}
{formatPrice(promoPeriod.price)} {promoPeriod.original && promoPeriod.original > promoPeriod.price && ( {formatPrice(promoPeriod.original)} )}
) : ( selectedTariffPeriod && ( <> {(selectedTariffPeriod.extra_devices_count ?? 0) > 0 && selectedTariffPeriod.base_tariff_price_kopeks ? ( <>
{t('subscription.baseTariff')}: {selectedTariffPeriod.label} {formatPrice(selectedTariffPeriod.base_tariff_price_kopeks)}
{t('subscription.extraDevices')} ( {selectedTariffPeriod.extra_devices_count}) + {formatPrice( selectedTariffPeriod.extra_devices_cost_kopeks ?? 0, )}
) : (
{t('subscription.summary.period', { label: selectedTariffPeriod.label, })}
{formatPrice(promoPeriod.price)} {promoPeriod.original && promoPeriod.original > promoPeriod.price && ( {formatPrice(promoPeriod.original)} )}
)} ) )} {useCustomTraffic && tariff.custom_traffic_enabled && (
{t('subscription.summary.traffic', { gb: customTrafficGb })} +{formatPrice(trafficPrice)}
)}
{promoPeriod.percent && (
{t('promo.discountApplied')} -{promoPeriod.percent}%
)}
{t('subscription.total')}
{formatPrice(totalPrice)} {originalTotal && (
{formatPrice(originalTotal)}
)}
); })()} {purchaseMutation.isError && !getInsufficientBalanceError(purchaseMutation.error) && (
{getErrorMessage(purchaseMutation.error)}
)} {purchaseMutation.isError && getInsufficientBalanceError(purchaseMutation.error) && (
)}
)} )}
); }