mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate, useSearchParams } from 'react-router';
|
import { useNavigate, useSearchParams } from 'react-router';
|
||||||
@@ -7,24 +7,15 @@ import { usePromoDiscount } from '../hooks/usePromoDiscount';
|
|||||||
import { WebBackButton } from '../components/WebBackButton';
|
import { WebBackButton } from '../components/WebBackButton';
|
||||||
import { getGlassColors } from '../utils/glassTheme';
|
import { getGlassColors } from '../utils/glassTheme';
|
||||||
import { useTheme } from '../hooks/useTheme';
|
import { useTheme } from '../hooks/useTheme';
|
||||||
import type {
|
import type { PurchaseSelection, PeriodOption, Tariff, ClassicPurchaseOptions } from '../types';
|
||||||
PurchaseSelection,
|
|
||||||
PeriodOption,
|
|
||||||
Tariff,
|
|
||||||
TariffPeriod,
|
|
||||||
ClassicPurchaseOptions,
|
|
||||||
} from '../types';
|
|
||||||
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
|
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
|
||||||
import { useCurrency } from '../hooks/useCurrency';
|
import { useCurrency } from '../hooks/useCurrency';
|
||||||
import { useCloseOnSuccessNotification } from '../store/successNotification';
|
import { useCloseOnSuccessNotification } from '../store/successNotification';
|
||||||
import { CheckIcon } from '../components/icons';
|
import { CheckIcon } from '../components/icons';
|
||||||
import Twemoji from 'react-twemoji';
|
import Twemoji from 'react-twemoji';
|
||||||
import { SwitchTariffSheet } from '../components/subscription/sheets/SwitchTariffSheet';
|
import { SwitchTariffSheet } from '../components/subscription/sheets/SwitchTariffSheet';
|
||||||
import {
|
import { TariffPurchaseForm } from '../components/subscription/purchase/TariffPurchaseForm';
|
||||||
getErrorMessage,
|
import { getErrorMessage, type PurchaseStep } from '../utils/subscriptionHelpers';
|
||||||
getInsufficientBalanceError,
|
|
||||||
type PurchaseStep,
|
|
||||||
} from '../utils/subscriptionHelpers';
|
|
||||||
|
|
||||||
export default function SubscriptionPurchase() {
|
export default function SubscriptionPurchase() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -95,15 +86,13 @@ export default function SubscriptionPurchase() {
|
|||||||
|
|
||||||
// Tariffs mode state
|
// Tariffs mode state
|
||||||
const [selectedTariff, setSelectedTariff] = useState<Tariff | null>(null);
|
const [selectedTariff, setSelectedTariff] = useState<Tariff | null>(null);
|
||||||
const [selectedTariffPeriod, setSelectedTariffPeriod] = useState<TariffPeriod | null>(null);
|
|
||||||
const [showTariffPurchase, setShowTariffPurchase] = useState(false);
|
const [showTariffPurchase, setShowTariffPurchase] = useState(false);
|
||||||
const [customDays, setCustomDays] = useState<number>(30);
|
// (selectedTariffPeriod / customDays / customTrafficGb / useCustomDays /
|
||||||
const [customTrafficGb, setCustomTrafficGb] = useState<number>(50);
|
// useCustomTraffic moved into <TariffPurchaseForm>; form remounts with
|
||||||
const [useCustomDays, setUseCustomDays] = useState(false);
|
// fresh state via key=tariff.id when the parent picks a new tariff)
|
||||||
const [useCustomTraffic, setUseCustomTraffic] = useState(false);
|
|
||||||
|
|
||||||
// Refs for auto-scroll (switch-modal ref moved into <SwitchTariffSheet>)
|
// (tariffPurchaseRef moved into <TariffPurchaseForm>; switch-modal ref
|
||||||
const tariffPurchaseRef = useRef<HTMLDivElement>(null);
|
// moved into <SwitchTariffSheet>)
|
||||||
|
|
||||||
// Tariff switch
|
// Tariff switch
|
||||||
const [switchTariffId, setSwitchTariffId] = useState<number | null>(null);
|
const [switchTariffId, setSwitchTariffId] = useState<number | null>(null);
|
||||||
@@ -115,7 +104,7 @@ export default function SubscriptionPurchase() {
|
|||||||
setSwitchTariffId(null);
|
setSwitchTariffId(null);
|
||||||
|
|
||||||
setSelectedTariff(null);
|
setSelectedTariff(null);
|
||||||
setSelectedTariffPeriod(null);
|
// (selectedTariffPeriod lives inside <TariffPurchaseForm> now; unmount clears it)
|
||||||
};
|
};
|
||||||
useCloseOnSuccessNotification(handleCloseAllModals);
|
useCloseOnSuccessNotification(handleCloseAllModals);
|
||||||
|
|
||||||
@@ -207,51 +196,9 @@ export default function SubscriptionPurchase() {
|
|||||||
|
|
||||||
// (switch preview query + switchTariffMutation moved into <SwitchTariffSheet>)
|
// (switch preview query + switchTariffMutation moved into <SwitchTariffSheet>)
|
||||||
|
|
||||||
// Tariff purchase mutation
|
// (tariffPurchaseMutation moved into <TariffPurchaseForm>)
|
||||||
const tariffPurchaseMutation = useMutation({
|
// (auto-scroll effects: switch-modal into <SwitchTariffSheet>,
|
||||||
mutationFn: () => {
|
// tariff-purchase into <TariffPurchaseForm>)
|
||||||
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]);
|
|
||||||
|
|
||||||
// Classic mode helpers
|
// Classic mode helpers
|
||||||
const toggleServer = (uuid: string) => {
|
const toggleServer = (uuid: string) => {
|
||||||
@@ -484,7 +431,6 @@ export default function SubscriptionPurchase() {
|
|||||||
onClose={() => setSwitchTariffId(null)}
|
onClose={() => setSwitchTariffId(null)}
|
||||||
onExpiredFallback={(tariff) => {
|
onExpiredFallback={(tariff) => {
|
||||||
setSelectedTariff(tariff);
|
setSelectedTariff(tariff);
|
||||||
setSelectedTariffPeriod(tariff.periods[0] || null);
|
|
||||||
setShowTariffPurchase(true);
|
setShowTariffPurchase(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -756,7 +702,6 @@ export default function SubscriptionPurchase() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedTariff(tariff);
|
setSelectedTariff(tariff);
|
||||||
setSelectedTariffPeriod(tariff.periods[0] || null);
|
|
||||||
setShowTariffPurchase(true);
|
setShowTariffPurchase(true);
|
||||||
}}
|
}}
|
||||||
className="btn-primary flex-1 py-2 text-sm"
|
className="btn-primary flex-1 py-2 text-sm"
|
||||||
@@ -768,7 +713,6 @@ export default function SubscriptionPurchase() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedTariff(tariff);
|
setSelectedTariff(tariff);
|
||||||
setSelectedTariffPeriod(tariff.periods[0] || null);
|
|
||||||
setShowTariffPurchase(true);
|
setShowTariffPurchase(true);
|
||||||
}}
|
}}
|
||||||
className="btn-primary flex-1 py-2 text-sm"
|
className="btn-primary flex-1 py-2 text-sm"
|
||||||
@@ -786,7 +730,6 @@ export default function SubscriptionPurchase() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedTariff(tariff);
|
setSelectedTariff(tariff);
|
||||||
setSelectedTariffPeriod(tariff.periods[0] || null);
|
|
||||||
setShowTariffPurchase(true);
|
setShowTariffPurchase(true);
|
||||||
}}
|
}}
|
||||||
className="btn-primary flex-1 py-2 text-sm"
|
className="btn-primary flex-1 py-2 text-sm"
|
||||||
@@ -802,596 +745,17 @@ export default function SubscriptionPurchase() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
selectedTariff && (
|
selectedTariff && (
|
||||||
/* Tariff Purchase Form */
|
/* Tariff Purchase Form (extracted into its own component) */
|
||||||
<div ref={tariffPurchaseRef} className="space-y-6">
|
<TariffPurchaseForm
|
||||||
<div className="flex items-center justify-between">
|
key={selectedTariff.id}
|
||||||
<h3 className="text-lg font-medium text-dark-100">{selectedTariff.name}</h3>
|
tariff={selectedTariff}
|
||||||
<button
|
subscriptionId={subscriptionId}
|
||||||
onClick={() => {
|
balanceKopeks={purchaseOptions?.balance_kopeks}
|
||||||
setShowTariffPurchase(false);
|
onBack={() => {
|
||||||
setSelectedTariff(null);
|
setShowTariffPurchase(false);
|
||||||
setSelectedTariffPeriod(null);
|
setSelectedTariff(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>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user