mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
refactor(purchase): extract TariffPurchaseForm from god page
The single biggest extraction in the SubscriptionPurchase decomp.
Moves the per-tariff purchase form into a self-contained component
at src/components/subscription/purchase/TariffPurchaseForm.tsx.
The form self-owns:
- the purchaseTariff mutation (mode-aware: daily-tariff activate vs
period-based + custom-days / custom-traffic toggles + summary)
- the auto-scroll-into-view ref + effect on mount
- selectedTariffPeriod / customDays / customTrafficGb / useCustomDays /
useCustomTraffic — five pieces of state that were parent-level but
only ever read inside this form. Form remounts with fresh state
when the parent passes a new tariff (key={selectedTariff.id}).
Parent only owns selectedTariff + showTariffPurchase (the two pieces
the orchestrator needs to decide what to render) and threads them
into <TariffPurchaseForm>. Tariff card click handlers drop their
period-seeding line (the form seeds itself from tariff.periods[0]
on mount). The subscription_expired fallback callback simplifies
for the same reason.
SubscriptionPurchase.tsx: 1838 → 1222 lines (-616). Drops useRef,
TariffPeriod, and getInsufficientBalanceError from parent imports.
This commit is contained in:
641
src/components/subscription/purchase/TariffPurchaseForm.tsx
Normal file
641
src/components/subscription/purchase/TariffPurchaseForm.tsx
Normal file
@@ -0,0 +1,641 @@
|
||||
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<HTMLDivElement>(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<TariffPeriod | null>(
|
||||
tariff.periods[0] || null,
|
||||
);
|
||||
const [customDays, setCustomDays] = useState<number>(30);
|
||||
const [customTrafficGb, setCustomTrafficGb] = useState<number>(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 (
|
||||
<div ref={ref} className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-dark-100">{tariff.name}</h3>
|
||||
<button onClick={onBack} className="text-dark-400 hover:text-dark-200">
|
||||
← {t('common.back')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tariff Info */}
|
||||
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-dark-500">{t('subscription.traffic')}:</span>
|
||||
<span className="ml-2 text-dark-200">{tariff.traffic_limit_label}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-dark-500">{t('subscription.devices')}:</span>
|
||||
<span className="ml-2 text-dark-200">
|
||||
{tariff.device_limit === 0 ? '∞' : tariff.device_limit}
|
||||
{tariff.extra_devices_count > 0 && (
|
||||
<span className="ml-1 text-xs text-accent-400">
|
||||
(+{tariff.extra_devices_count})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Daily Tariff Purchase */}
|
||||
{tariff.is_daily || (tariff.daily_price_kopeks && tariff.daily_price_kopeks > 0) ? (
|
||||
<div className="rounded-xl border border-accent-500/30 bg-accent-500/10 p-5">
|
||||
<div className="mb-4 text-center">
|
||||
<div className="mb-2 text-sm text-dark-400">
|
||||
{t('subscription.dailyPurchase.costPerDay')}
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-accent-400">
|
||||
{formatPrice(tariff.daily_price_kopeks || 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-dark-400">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-accent-400">•</span>
|
||||
<span>{t('subscription.dailyPurchase.chargedDaily')}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-accent-400">•</span>
|
||||
<span>{t('subscription.dailyPurchase.canPause')}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-accent-400">•</span>
|
||||
<span>{t('subscription.dailyPurchase.pausedOnLowBalance')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const dailyPrice = tariff.daily_price_kopeks || 0;
|
||||
const hasEnoughBalance = balanceKopeks !== undefined && dailyPrice <= balanceKopeks;
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
{balanceKopeks !== undefined && !hasEnoughBalance && (
|
||||
<InsufficientBalancePrompt
|
||||
missingAmountKopeks={dailyPrice - balanceKopeks}
|
||||
compact
|
||||
className="mb-4"
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => purchaseMutation.mutate()}
|
||||
disabled={purchaseMutation.isPending}
|
||||
className="btn-primary w-full py-3"
|
||||
>
|
||||
{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.dailyPurchase.activate', {
|
||||
price: formatPrice(dailyPrice),
|
||||
})
|
||||
)}
|
||||
</button>
|
||||
|
||||
{purchaseMutation.isError &&
|
||||
!getInsufficientBalanceError(purchaseMutation.error) && (
|
||||
<div className="mt-3 text-center text-sm text-error-400">
|
||||
{getErrorMessage(purchaseMutation.error)}
|
||||
</div>
|
||||
)}
|
||||
{purchaseMutation.isError &&
|
||||
getInsufficientBalanceError(purchaseMutation.error) && (
|
||||
<div className="mt-3">
|
||||
<InsufficientBalancePrompt
|
||||
missingAmountKopeks={
|
||||
getInsufficientBalanceError(purchaseMutation.error)?.missingAmount ||
|
||||
dailyPrice - (balanceKopeks || 0)
|
||||
}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Period Selection for non-daily tariffs */}
|
||||
<div>
|
||||
<div className="mb-3 text-sm text-dark-400">{t('subscription.selectPeriod')}</div>
|
||||
|
||||
{tariff.periods.length > 0 && !useCustomDays && (
|
||||
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{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 (
|
||||
<button
|
||||
key={period.days}
|
||||
onClick={() => {
|
||||
setSelectedTariffPeriod(period);
|
||||
setUseCustomDays(false);
|
||||
}}
|
||||
className={`relative rounded-xl border p-4 text-left transition-all ${
|
||||
selectedTariffPeriod?.days === period.days && !useCustomDays
|
||||
? 'border-accent-500 bg-accent-500/10'
|
||||
: 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
|
||||
}`}
|
||||
>
|
||||
{displayDiscount && displayDiscount > 0 && (
|
||||
<div
|
||||
className={`absolute -right-2 -top-2 rounded-full px-2 py-0.5 text-xs font-medium text-white ${
|
||||
promoPeriod.isPromoGroup ? 'bg-success-500' : 'bg-warning-500'
|
||||
}`}
|
||||
>
|
||||
-{displayDiscount}%
|
||||
</div>
|
||||
)}
|
||||
<div className="text-lg font-semibold text-dark-100">{period.label}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-accent-400">
|
||||
{formatPrice(displayPrice)}
|
||||
</span>
|
||||
{displayOriginal && displayOriginal > displayPrice && (
|
||||
<span className="text-sm text-dark-500 line-through">
|
||||
{formatPrice(displayOriginal)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-dark-500">
|
||||
{formatPrice(displayPerMonth)}/{t('subscription.month')}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No periods available fallback */}
|
||||
{tariff.periods.length === 0 &&
|
||||
!useCustomDays &&
|
||||
!(tariff.custom_days_enabled && (tariff.price_per_day_kopeks ?? 0) > 0) && (
|
||||
<div className="rounded-xl border border-warning-500/30 bg-warning-500/10 p-4 text-center">
|
||||
<div className="mb-2 text-sm font-medium text-warning-400">
|
||||
{t('subscription.noPeriodsAvailable')}
|
||||
</div>
|
||||
<div className="text-xs text-dark-400">
|
||||
{t('subscription.noPeriodsAvailableHint')}
|
||||
</div>
|
||||
<button onClick={onBack} className="btn-secondary mt-3 px-4 py-2 text-sm">
|
||||
{t('subscription.chooseDifferentTariff')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom days option */}
|
||||
{tariff.custom_days_enabled && (tariff.price_per_day_kopeks ?? 0) > 0 && (
|
||||
<div className="rounded-xl border border-dark-700/50 bg-dark-800/50 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="font-medium text-dark-200">
|
||||
{t('subscription.customDays.title')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUseCustomDays(!useCustomDays)}
|
||||
role="switch"
|
||||
aria-checked={useCustomDays}
|
||||
aria-label={t('subscription.customDays.title')}
|
||||
className={`relative h-6 w-10 rounded-full transition-colors ${
|
||||
useCustomDays ? 'bg-accent-500' : 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
|
||||
useCustomDays ? 'left-5' : 'left-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{useCustomDays && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min={tariff.min_days ?? 1}
|
||||
max={tariff.max_days ?? 365}
|
||||
value={customDays}
|
||||
onChange={(e) => setCustomDays(parseInt(e.target.value))}
|
||||
className="flex-1 accent-accent-500"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={customDays}
|
||||
min={tariff.min_days ?? 1}
|
||||
max={tariff.max_days ?? 365}
|
||||
onChange={(e) =>
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
{(() => {
|
||||
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 (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-dark-400">
|
||||
{t('subscription.days', { count: customDays })} ×{' '}
|
||||
{formatPrice(tariff.price_per_day_kopeks ?? 0)}/
|
||||
{t('subscription.customDays.perDay')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-accent-400">
|
||||
{formatPrice(promoCustom.price)}
|
||||
</span>
|
||||
{promoCustom.original && promoCustom.original > promoCustom.price && (
|
||||
<>
|
||||
<span className="text-xs text-dark-500 line-through">
|
||||
{formatPrice(promoCustom.original)}
|
||||
</span>
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-xs ${
|
||||
promoCustom.isPromoGroup
|
||||
? 'bg-success-500/20 text-success-400'
|
||||
: 'bg-warning-500/20 text-warning-400'
|
||||
}`}
|
||||
>
|
||||
-{promoCustom.percent}%
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Custom traffic option */}
|
||||
{tariff.custom_traffic_enabled && (tariff.traffic_price_per_gb_kopeks ?? 0) > 0 && (
|
||||
<div>
|
||||
<div className="mb-3 text-sm text-dark-400">
|
||||
{t('subscription.customTraffic.label')}
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700/50 bg-dark-800/50 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="font-medium text-dark-200">
|
||||
{t('subscription.customTraffic.selectVolume')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUseCustomTraffic(!useCustomTraffic)}
|
||||
role="switch"
|
||||
aria-checked={useCustomTraffic}
|
||||
aria-label={t('subscription.customTraffic.selectVolume')}
|
||||
className={`relative h-6 w-10 rounded-full transition-colors ${
|
||||
useCustomTraffic ? 'bg-accent-500' : 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
|
||||
useCustomTraffic ? 'left-5' : 'left-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{!useCustomTraffic && (
|
||||
<div className="text-sm text-dark-400">
|
||||
{t('subscription.customTraffic.default', {
|
||||
label: tariff.traffic_limit_label,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{useCustomTraffic && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min={tariff.min_traffic_gb ?? 1}
|
||||
max={tariff.max_traffic_gb ?? 1000}
|
||||
value={customTrafficGb}
|
||||
onChange={(e) => setCustomTrafficGb(parseInt(e.target.value))}
|
||||
className="flex-1 accent-accent-500"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={customTrafficGb}
|
||||
min={tariff.min_traffic_gb ?? 1}
|
||||
max={tariff.max_traffic_gb ?? 1000}
|
||||
onChange={(e) =>
|
||||
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"
|
||||
/>
|
||||
<span className="text-dark-400">{t('common.units.gb')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-dark-400">
|
||||
{customTrafficGb} {t('common.units.gb')} ×{' '}
|
||||
{formatPrice(tariff.traffic_price_per_gb_kopeks ?? 0)}/
|
||||
{t('common.units.gb')}
|
||||
</span>
|
||||
<span className="font-medium text-accent-400">
|
||||
+{formatPrice(customTrafficGb * (tariff.traffic_price_per_gb_kopeks ?? 0))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary & Purchase */}
|
||||
{(selectedTariffPeriod || useCustomDays) && (
|
||||
<div className="rounded-xl bg-dark-800/50 p-5">
|
||||
{(() => {
|
||||
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 (
|
||||
<>
|
||||
<div className="mb-4 space-y-2">
|
||||
{useCustomDays ? (
|
||||
<div className="flex justify-between text-sm text-dark-300">
|
||||
<span>
|
||||
{t('subscription.stepPeriod')}:{' '}
|
||||
{t('subscription.days', { count: customDays })}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{formatPrice(promoPeriod.price)}</span>
|
||||
{promoPeriod.original && promoPeriod.original > promoPeriod.price && (
|
||||
<span className="text-xs text-dark-500 line-through">
|
||||
{formatPrice(promoPeriod.original)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
selectedTariffPeriod && (
|
||||
<>
|
||||
{(selectedTariffPeriod.extra_devices_count ?? 0) > 0 &&
|
||||
selectedTariffPeriod.base_tariff_price_kopeks ? (
|
||||
<>
|
||||
<div className="flex justify-between text-sm text-dark-300">
|
||||
<span>
|
||||
{t('subscription.baseTariff')}: {selectedTariffPeriod.label}
|
||||
</span>
|
||||
<span>
|
||||
{formatPrice(selectedTariffPeriod.base_tariff_price_kopeks)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-dark-300">
|
||||
<span>
|
||||
{t('subscription.extraDevices')} (
|
||||
{selectedTariffPeriod.extra_devices_count})
|
||||
</span>
|
||||
<span>
|
||||
+
|
||||
{formatPrice(
|
||||
selectedTariffPeriod.extra_devices_cost_kopeks ?? 0,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex justify-between text-sm text-dark-300">
|
||||
<span>
|
||||
{t('subscription.summary.period', {
|
||||
label: selectedTariffPeriod.label,
|
||||
})}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{formatPrice(promoPeriod.price)}</span>
|
||||
{promoPeriod.original &&
|
||||
promoPeriod.original > promoPeriod.price && (
|
||||
<span className="text-xs text-dark-500 line-through">
|
||||
{formatPrice(promoPeriod.original)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
{useCustomTraffic && tariff.custom_traffic_enabled && (
|
||||
<div className="flex justify-between text-sm text-dark-300">
|
||||
<span>{t('subscription.summary.traffic', { gb: customTrafficGb })}</span>
|
||||
<span>+{formatPrice(trafficPrice)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{promoPeriod.percent && (
|
||||
<div className="mb-4 flex items-center justify-center gap-2 rounded-lg border border-warning-500/30 bg-warning-500/10 p-2">
|
||||
<span className="text-sm font-medium text-warning-400">
|
||||
{t('promo.discountApplied')} -{promoPeriod.percent}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4 flex items-center justify-between border-t border-dark-700/50 pt-2">
|
||||
<span className="font-medium text-dark-100">{t('subscription.total')}</span>
|
||||
<div className="text-right">
|
||||
<span className="text-2xl font-bold text-accent-400">
|
||||
{formatPrice(totalPrice)}
|
||||
</span>
|
||||
{originalTotal && (
|
||||
<div className="text-sm text-dark-500 line-through">
|
||||
{formatPrice(originalTotal)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => purchaseMutation.mutate()}
|
||||
disabled={purchaseMutation.isPending}
|
||||
className="btn-primary w-full py-3"
|
||||
>
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{purchaseMutation.isError && !getInsufficientBalanceError(purchaseMutation.error) && (
|
||||
<div className="mt-3 text-center text-sm text-error-400">
|
||||
{getErrorMessage(purchaseMutation.error)}
|
||||
</div>
|
||||
)}
|
||||
{purchaseMutation.isError && getInsufficientBalanceError(purchaseMutation.error) && (
|
||||
<div className="mt-3">
|
||||
<InsufficientBalancePrompt
|
||||
missingAmountKopeks={
|
||||
getInsufficientBalanceError(purchaseMutation.error)?.missingAmount || 0
|
||||
}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
@@ -7,24 +7,15 @@ 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,
|
||||
TariffPeriod,
|
||||
ClassicPurchaseOptions,
|
||||
} from '../types';
|
||||
import type { PurchaseSelection, PeriodOption, Tariff, ClassicPurchaseOptions } from '../types';
|
||||
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
|
||||
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,
|
||||
type PurchaseStep,
|
||||
} from '../utils/subscriptionHelpers';
|
||||
import { TariffPurchaseForm } from '../components/subscription/purchase/TariffPurchaseForm';
|
||||
import { getErrorMessage, type PurchaseStep } from '../utils/subscriptionHelpers';
|
||||
|
||||
export default function SubscriptionPurchase() {
|
||||
const { t } = useTranslation();
|
||||
@@ -95,15 +86,13 @@ export default function SubscriptionPurchase() {
|
||||
|
||||
// Tariffs mode state
|
||||
const [selectedTariff, setSelectedTariff] = useState<Tariff | null>(null);
|
||||
const [selectedTariffPeriod, setSelectedTariffPeriod] = useState<TariffPeriod | null>(null);
|
||||
const [showTariffPurchase, setShowTariffPurchase] = useState(false);
|
||||
const [customDays, setCustomDays] = useState<number>(30);
|
||||
const [customTrafficGb, setCustomTrafficGb] = useState<number>(50);
|
||||
const [useCustomDays, setUseCustomDays] = useState(false);
|
||||
const [useCustomTraffic, setUseCustomTraffic] = useState(false);
|
||||
// (selectedTariffPeriod / customDays / customTrafficGb / useCustomDays /
|
||||
// useCustomTraffic moved into <TariffPurchaseForm>; form remounts with
|
||||
// fresh state via key=tariff.id when the parent picks a new tariff)
|
||||
|
||||
// Refs for auto-scroll (switch-modal ref moved into <SwitchTariffSheet>)
|
||||
const tariffPurchaseRef = useRef<HTMLDivElement>(null);
|
||||
// (tariffPurchaseRef moved into <TariffPurchaseForm>; switch-modal ref
|
||||
// moved into <SwitchTariffSheet>)
|
||||
|
||||
// Tariff switch
|
||||
const [switchTariffId, setSwitchTariffId] = useState<number | null>(null);
|
||||
@@ -115,7 +104,7 @@ export default function SubscriptionPurchase() {
|
||||
setSwitchTariffId(null);
|
||||
|
||||
setSelectedTariff(null);
|
||||
setSelectedTariffPeriod(null);
|
||||
// (selectedTariffPeriod lives inside <TariffPurchaseForm> now; unmount clears it)
|
||||
};
|
||||
useCloseOnSuccessNotification(handleCloseAllModals);
|
||||
|
||||
@@ -207,51 +196,9 @@ export default function SubscriptionPurchase() {
|
||||
|
||||
// (switch preview query + switchTariffMutation moved into <SwitchTariffSheet>)
|
||||
|
||||
// Tariff purchase mutation
|
||||
const tariffPurchaseMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!selectedTariff) {
|
||||
throw new Error('Tariff not selected');
|
||||
}
|
||||
const isDailyTariff =
|
||||
selectedTariff.is_daily ||
|
||||
(selectedTariff.daily_price_kopeks && selectedTariff.daily_price_kopeks > 0);
|
||||
const days = isDailyTariff
|
||||
? 1
|
||||
: useCustomDays
|
||||
? customDays
|
||||
: selectedTariffPeriod?.days || 30;
|
||||
const trafficGb =
|
||||
useCustomTraffic && selectedTariff.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(
|
||||
selectedTariff.id,
|
||||
days,
|
||||
trafficGb,
|
||||
subscriptionId ?? undefined,
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||||
navigate('/subscriptions', { replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-scroll effects (switch-tariff scroll moved into the sheet)
|
||||
useEffect(() => {
|
||||
if (showTariffPurchase && tariffPurchaseRef.current) {
|
||||
const timer = setTimeout(() => {
|
||||
tariffPurchaseRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [showTariffPurchase]);
|
||||
// (tariffPurchaseMutation moved into <TariffPurchaseForm>)
|
||||
// (auto-scroll effects: switch-modal into <SwitchTariffSheet>,
|
||||
// tariff-purchase into <TariffPurchaseForm>)
|
||||
|
||||
// Classic mode helpers
|
||||
const toggleServer = (uuid: string) => {
|
||||
@@ -484,7 +431,6 @@ export default function SubscriptionPurchase() {
|
||||
onClose={() => setSwitchTariffId(null)}
|
||||
onExpiredFallback={(tariff) => {
|
||||
setSelectedTariff(tariff);
|
||||
setSelectedTariffPeriod(tariff.periods[0] || null);
|
||||
setShowTariffPurchase(true);
|
||||
}}
|
||||
/>
|
||||
@@ -756,7 +702,6 @@ export default function SubscriptionPurchase() {
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedTariff(tariff);
|
||||
setSelectedTariffPeriod(tariff.periods[0] || null);
|
||||
setShowTariffPurchase(true);
|
||||
}}
|
||||
className="btn-primary flex-1 py-2 text-sm"
|
||||
@@ -768,7 +713,6 @@ export default function SubscriptionPurchase() {
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedTariff(tariff);
|
||||
setSelectedTariffPeriod(tariff.periods[0] || null);
|
||||
setShowTariffPurchase(true);
|
||||
}}
|
||||
className="btn-primary flex-1 py-2 text-sm"
|
||||
@@ -786,7 +730,6 @@ export default function SubscriptionPurchase() {
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedTariff(tariff);
|
||||
setSelectedTariffPeriod(tariff.periods[0] || null);
|
||||
setShowTariffPurchase(true);
|
||||
}}
|
||||
className="btn-primary flex-1 py-2 text-sm"
|
||||
@@ -802,596 +745,17 @@ export default function SubscriptionPurchase() {
|
||||
</>
|
||||
) : (
|
||||
selectedTariff && (
|
||||
/* Tariff Purchase Form */
|
||||
<div ref={tariffPurchaseRef} className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-dark-100">{selectedTariff.name}</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
/* Tariff Purchase Form (extracted into its own component) */
|
||||
<TariffPurchaseForm
|
||||
key={selectedTariff.id}
|
||||
tariff={selectedTariff}
|
||||
subscriptionId={subscriptionId}
|
||||
balanceKopeks={purchaseOptions?.balance_kopeks}
|
||||
onBack={() => {
|
||||
setShowTariffPurchase(false);
|
||||
setSelectedTariff(null);
|
||||
setSelectedTariffPeriod(null);
|
||||
}}
|
||||
className="text-dark-400 hover:text-dark-200"
|
||||
>
|
||||
← {t('common.back')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tariff Info */}
|
||||
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-dark-500">{t('subscription.traffic')}:</span>
|
||||
<span className="ml-2 text-dark-200">
|
||||
{selectedTariff.traffic_limit_label}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-dark-500">{t('subscription.devices')}:</span>
|
||||
<span className="ml-2 text-dark-200">
|
||||
{selectedTariff.device_limit === 0 ? '∞' : selectedTariff.device_limit}
|
||||
{selectedTariff.extra_devices_count > 0 && (
|
||||
<span className="ml-1 text-xs text-accent-400">
|
||||
(+{selectedTariff.extra_devices_count})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Daily Tariff Purchase */}
|
||||
{selectedTariff.is_daily ||
|
||||
(selectedTariff.daily_price_kopeks && selectedTariff.daily_price_kopeks > 0) ? (
|
||||
<div className="rounded-xl border border-accent-500/30 bg-accent-500/10 p-5">
|
||||
<div className="mb-4 text-center">
|
||||
<div className="mb-2 text-sm text-dark-400">
|
||||
{t('subscription.dailyPurchase.costPerDay')}
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-accent-400">
|
||||
{formatPrice(selectedTariff.daily_price_kopeks || 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm text-dark-400">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-accent-400">•</span>
|
||||
<span>{t('subscription.dailyPurchase.chargedDaily')}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-accent-400">•</span>
|
||||
<span>{t('subscription.dailyPurchase.canPause')}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-accent-400">•</span>
|
||||
<span>{t('subscription.dailyPurchase.pausedOnLowBalance')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const dailyPrice = selectedTariff.daily_price_kopeks || 0;
|
||||
const hasEnoughBalance =
|
||||
purchaseOptions && dailyPrice <= purchaseOptions.balance_kopeks;
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
{purchaseOptions && !hasEnoughBalance && (
|
||||
<InsufficientBalancePrompt
|
||||
missingAmountKopeks={dailyPrice - purchaseOptions.balance_kopeks}
|
||||
compact
|
||||
className="mb-4"
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => tariffPurchaseMutation.mutate()}
|
||||
disabled={tariffPurchaseMutation.isPending}
|
||||
className="btn-primary w-full py-3"
|
||||
>
|
||||
{tariffPurchaseMutation.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.dailyPurchase.activate', {
|
||||
price: formatPrice(dailyPrice),
|
||||
})
|
||||
)}
|
||||
</button>
|
||||
|
||||
{tariffPurchaseMutation.isError &&
|
||||
!getInsufficientBalanceError(tariffPurchaseMutation.error) && (
|
||||
<div className="mt-3 text-center text-sm text-error-400">
|
||||
{getErrorMessage(tariffPurchaseMutation.error)}
|
||||
</div>
|
||||
)}
|
||||
{tariffPurchaseMutation.isError &&
|
||||
getInsufficientBalanceError(tariffPurchaseMutation.error) && (
|
||||
<div className="mt-3">
|
||||
<InsufficientBalancePrompt
|
||||
missingAmountKopeks={
|
||||
getInsufficientBalanceError(tariffPurchaseMutation.error)
|
||||
?.missingAmount ||
|
||||
dailyPrice - (purchaseOptions?.balance_kopeks || 0)
|
||||
}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Period Selection for non-daily tariffs */}
|
||||
<div>
|
||||
<div className="mb-3 text-sm text-dark-400">
|
||||
{t('subscription.selectPeriod')}
|
||||
</div>
|
||||
|
||||
{selectedTariff.periods.length > 0 && !useCustomDays && (
|
||||
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{selectedTariff.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 (
|
||||
<button
|
||||
key={period.days}
|
||||
onClick={() => {
|
||||
setSelectedTariffPeriod(period);
|
||||
setUseCustomDays(false);
|
||||
}}
|
||||
className={`relative rounded-xl border p-4 text-left transition-all ${
|
||||
selectedTariffPeriod?.days === period.days && !useCustomDays
|
||||
? 'border-accent-500 bg-accent-500/10'
|
||||
: 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
|
||||
}`}
|
||||
>
|
||||
{displayDiscount && displayDiscount > 0 && (
|
||||
<div
|
||||
className={`absolute -right-2 -top-2 rounded-full px-2 py-0.5 text-xs font-medium text-white ${
|
||||
promoPeriod.isPromoGroup ? 'bg-success-500' : 'bg-warning-500'
|
||||
}`}
|
||||
>
|
||||
-{displayDiscount}%
|
||||
</div>
|
||||
)}
|
||||
<div className="text-lg font-semibold text-dark-100">
|
||||
{period.label}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-accent-400">
|
||||
{formatPrice(displayPrice)}
|
||||
</span>
|
||||
{displayOriginal && displayOriginal > displayPrice && (
|
||||
<span className="text-sm text-dark-500 line-through">
|
||||
{formatPrice(displayOriginal)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-dark-500">
|
||||
{formatPrice(displayPerMonth)}/{t('subscription.month')}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No periods available fallback */}
|
||||
{selectedTariff.periods.length === 0 &&
|
||||
!useCustomDays &&
|
||||
!(
|
||||
selectedTariff.custom_days_enabled &&
|
||||
(selectedTariff.price_per_day_kopeks ?? 0) > 0
|
||||
) && (
|
||||
<div className="rounded-xl border border-warning-500/30 bg-warning-500/10 p-4 text-center">
|
||||
<div className="mb-2 text-sm font-medium text-warning-400">
|
||||
{t('subscription.noPeriodsAvailable')}
|
||||
</div>
|
||||
<div className="text-xs text-dark-400">
|
||||
{t('subscription.noPeriodsAvailableHint')}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowTariffPurchase(false);
|
||||
setSelectedTariff(null);
|
||||
setSelectedTariffPeriod(null);
|
||||
}}
|
||||
className="btn-secondary mt-3 px-4 py-2 text-sm"
|
||||
>
|
||||
{t('subscription.chooseDifferentTariff')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom days option */}
|
||||
{selectedTariff.custom_days_enabled &&
|
||||
(selectedTariff.price_per_day_kopeks ?? 0) > 0 && (
|
||||
<div className="rounded-xl border border-dark-700/50 bg-dark-800/50 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="font-medium text-dark-200">
|
||||
{t('subscription.customDays.title')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUseCustomDays(!useCustomDays)}
|
||||
role="switch"
|
||||
aria-checked={useCustomDays}
|
||||
aria-label={t('subscription.customDays.title')}
|
||||
className={`relative h-6 w-10 rounded-full transition-colors ${
|
||||
useCustomDays ? 'bg-accent-500' : 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
|
||||
useCustomDays ? 'left-5' : 'left-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{useCustomDays && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min={selectedTariff.min_days ?? 1}
|
||||
max={selectedTariff.max_days ?? 365}
|
||||
value={customDays}
|
||||
onChange={(e) => setCustomDays(parseInt(e.target.value))}
|
||||
className="flex-1 accent-accent-500"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={customDays}
|
||||
min={selectedTariff.min_days ?? 1}
|
||||
max={selectedTariff.max_days ?? 365}
|
||||
onChange={(e) =>
|
||||
setCustomDays(
|
||||
Math.max(
|
||||
selectedTariff.min_days ?? 1,
|
||||
Math.min(
|
||||
selectedTariff.max_days ?? 365,
|
||||
parseInt(e.target.value) ||
|
||||
(selectedTariff.min_days ?? 1),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
className="w-20 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-center text-dark-100"
|
||||
/>
|
||||
</div>
|
||||
{(() => {
|
||||
const basePrice =
|
||||
customDays * (selectedTariff.price_per_day_kopeks ?? 0);
|
||||
const existingOriginal =
|
||||
selectedTariff.original_price_per_day_kopeks &&
|
||||
selectedTariff.original_price_per_day_kopeks >
|
||||
(selectedTariff.price_per_day_kopeks ?? 0)
|
||||
? customDays * selectedTariff.original_price_per_day_kopeks
|
||||
: undefined;
|
||||
const promoCustom = applyPromoDiscount(
|
||||
basePrice,
|
||||
existingOriginal,
|
||||
);
|
||||
return (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-dark-400">
|
||||
{t('subscription.days', { count: customDays })} ×{' '}
|
||||
{formatPrice(selectedTariff.price_per_day_kopeks ?? 0)}/
|
||||
{t('subscription.customDays.perDay')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-accent-400">
|
||||
{formatPrice(promoCustom.price)}
|
||||
</span>
|
||||
{promoCustom.original &&
|
||||
promoCustom.original > promoCustom.price && (
|
||||
<>
|
||||
<span className="text-xs text-dark-500 line-through">
|
||||
{formatPrice(promoCustom.original)}
|
||||
</span>
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-xs ${
|
||||
promoCustom.isPromoGroup
|
||||
? 'bg-success-500/20 text-success-400'
|
||||
: 'bg-warning-500/20 text-warning-400'
|
||||
}`}
|
||||
>
|
||||
-{promoCustom.percent}%
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Custom traffic option */}
|
||||
{selectedTariff.custom_traffic_enabled &&
|
||||
(selectedTariff.traffic_price_per_gb_kopeks ?? 0) > 0 && (
|
||||
<div>
|
||||
<div className="mb-3 text-sm text-dark-400">
|
||||
{t('subscription.customTraffic.label')}
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700/50 bg-dark-800/50 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="font-medium text-dark-200">
|
||||
{t('subscription.customTraffic.selectVolume')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUseCustomTraffic(!useCustomTraffic)}
|
||||
role="switch"
|
||||
aria-checked={useCustomTraffic}
|
||||
aria-label={t('subscription.customTraffic.selectVolume')}
|
||||
className={`relative h-6 w-10 rounded-full transition-colors ${
|
||||
useCustomTraffic ? 'bg-accent-500' : 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
|
||||
useCustomTraffic ? 'left-5' : 'left-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{!useCustomTraffic && (
|
||||
<div className="text-sm text-dark-400">
|
||||
{t('subscription.customTraffic.default', {
|
||||
label: selectedTariff.traffic_limit_label,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{useCustomTraffic && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min={selectedTariff.min_traffic_gb ?? 1}
|
||||
max={selectedTariff.max_traffic_gb ?? 1000}
|
||||
value={customTrafficGb}
|
||||
onChange={(e) => setCustomTrafficGb(parseInt(e.target.value))}
|
||||
className="flex-1 accent-accent-500"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={customTrafficGb}
|
||||
min={selectedTariff.min_traffic_gb ?? 1}
|
||||
max={selectedTariff.max_traffic_gb ?? 1000}
|
||||
onChange={(e) =>
|
||||
setCustomTrafficGb(
|
||||
Math.max(
|
||||
selectedTariff.min_traffic_gb ?? 1,
|
||||
Math.min(
|
||||
selectedTariff.max_traffic_gb ?? 1000,
|
||||
parseInt(e.target.value) ||
|
||||
(selectedTariff.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"
|
||||
/>
|
||||
<span className="text-dark-400">{t('common.units.gb')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-dark-400">
|
||||
{customTrafficGb} {t('common.units.gb')} ×{' '}
|
||||
{formatPrice(selectedTariff.traffic_price_per_gb_kopeks ?? 0)}/
|
||||
{t('common.units.gb')}
|
||||
</span>
|
||||
<span className="font-medium text-accent-400">
|
||||
+
|
||||
{formatPrice(
|
||||
customTrafficGb *
|
||||
(selectedTariff.traffic_price_per_gb_kopeks ?? 0),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary & Purchase */}
|
||||
{(selectedTariffPeriod || useCustomDays) && (
|
||||
<div className="rounded-xl bg-dark-800/50 p-5">
|
||||
{(() => {
|
||||
const basePeriodPrice = useCustomDays
|
||||
? customDays * (selectedTariff.price_per_day_kopeks ?? 0)
|
||||
: selectedTariffPeriod?.price_kopeks || 0;
|
||||
const existingPeriodOriginal = useCustomDays
|
||||
? selectedTariff.original_price_per_day_kopeks &&
|
||||
selectedTariff.original_price_per_day_kopeks >
|
||||
(selectedTariff.price_per_day_kopeks ?? 0)
|
||||
? customDays * selectedTariff.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 && selectedTariff.custom_traffic_enabled
|
||||
? customTrafficGb * (selectedTariff.traffic_price_per_gb_kopeks ?? 0)
|
||||
: 0;
|
||||
|
||||
const totalPrice = promoPeriod.price + trafficPrice;
|
||||
const originalTotal = promoPeriod.original
|
||||
? promoPeriod.original + trafficPrice
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 space-y-2">
|
||||
{useCustomDays ? (
|
||||
<div className="flex justify-between text-sm text-dark-300">
|
||||
<span>
|
||||
{t('subscription.stepPeriod')}:{' '}
|
||||
{t('subscription.days', { count: customDays })}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{formatPrice(promoPeriod.price)}</span>
|
||||
{promoPeriod.original &&
|
||||
promoPeriod.original > promoPeriod.price && (
|
||||
<span className="text-xs text-dark-500 line-through">
|
||||
{formatPrice(promoPeriod.original)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
selectedTariffPeriod && (
|
||||
<>
|
||||
{(selectedTariffPeriod.extra_devices_count ?? 0) > 0 &&
|
||||
selectedTariffPeriod.base_tariff_price_kopeks ? (
|
||||
<>
|
||||
<div className="flex justify-between text-sm text-dark-300">
|
||||
<span>
|
||||
{t('subscription.baseTariff')}:{' '}
|
||||
{selectedTariffPeriod.label}
|
||||
</span>
|
||||
<span>
|
||||
{formatPrice(
|
||||
selectedTariffPeriod.base_tariff_price_kopeks,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-dark-300">
|
||||
<span>
|
||||
{t('subscription.extraDevices')} (
|
||||
{selectedTariffPeriod.extra_devices_count})
|
||||
</span>
|
||||
<span>
|
||||
+
|
||||
{formatPrice(
|
||||
selectedTariffPeriod.extra_devices_cost_kopeks ?? 0,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex justify-between text-sm text-dark-300">
|
||||
<span>
|
||||
{t('subscription.summary.period', {
|
||||
label: selectedTariffPeriod.label,
|
||||
})}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{formatPrice(promoPeriod.price)}</span>
|
||||
{promoPeriod.original &&
|
||||
promoPeriod.original > promoPeriod.price && (
|
||||
<span className="text-xs text-dark-500 line-through">
|
||||
{formatPrice(promoPeriod.original)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
{useCustomTraffic && selectedTariff.custom_traffic_enabled && (
|
||||
<div className="flex justify-between text-sm text-dark-300">
|
||||
<span>
|
||||
{t('subscription.summary.traffic', { gb: customTrafficGb })}
|
||||
</span>
|
||||
<span>+{formatPrice(trafficPrice)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{promoPeriod.percent && (
|
||||
<div className="mb-4 flex items-center justify-center gap-2 rounded-lg border border-warning-500/30 bg-warning-500/10 p-2">
|
||||
<span className="text-sm font-medium text-warning-400">
|
||||
{t('promo.discountApplied')} -{promoPeriod.percent}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4 flex items-center justify-between border-t border-dark-700/50 pt-2">
|
||||
<span className="font-medium text-dark-100">
|
||||
{t('subscription.total')}
|
||||
</span>
|
||||
<div className="text-right">
|
||||
<span className="text-2xl font-bold text-accent-400">
|
||||
{formatPrice(totalPrice)}
|
||||
</span>
|
||||
{originalTotal && (
|
||||
<div className="text-sm text-dark-500 line-through">
|
||||
{formatPrice(originalTotal)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => tariffPurchaseMutation.mutate()}
|
||||
disabled={tariffPurchaseMutation.isPending}
|
||||
className="btn-primary w-full py-3"
|
||||
>
|
||||
{tariffPurchaseMutation.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>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{tariffPurchaseMutation.isError &&
|
||||
!getInsufficientBalanceError(tariffPurchaseMutation.error) && (
|
||||
<div className="mt-3 text-center text-sm text-error-400">
|
||||
{getErrorMessage(tariffPurchaseMutation.error)}
|
||||
</div>
|
||||
)}
|
||||
{tariffPurchaseMutation.isError &&
|
||||
getInsufficientBalanceError(tariffPurchaseMutation.error) && (
|
||||
<div className="mt-3">
|
||||
<InsufficientBalancePrompt
|
||||
missingAmountKopeks={
|
||||
getInsufficientBalanceError(tariffPurchaseMutation.error)
|
||||
?.missingAmount || 0
|
||||
}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user