feat: separate renewal and purchase flows in multi-tariff mode

Renewal flow (per-subscription):
- New /subscriptions/:id/renew page showing only period options for
  the specific tariff with balance display and renew button
- PurchaseCTAButton links to /renew in multi-tariff mode

Purchase flow (new tariffs only):
- Hide already purchased tariffs (is_purchased flag from backend)
- Fallback UI when all tariffs are owned
- Types: added is_purchased, all_tariffs_purchased fields
This commit is contained in:
c0mrade
2026-03-24 10:47:49 +03:00
parent a709ddbec2
commit 82eb03dec9
6 changed files with 280 additions and 3 deletions

View File

@@ -44,6 +44,7 @@ const Connection = lazy(() => import('./pages/Connection'));
const ConnectionQR = lazy(() => import('./pages/ConnectionQR'));
const QuickPurchase = lazy(() => import('./pages/QuickPurchase'));
const PurchaseSuccess = lazy(() => import('./pages/PurchaseSuccess'));
const RenewSubscription = lazy(() => import('./pages/RenewSubscription'));
const AutoLogin = lazy(() => import('./pages/AutoLogin'));
const TopUpMethodSelect = lazy(() => import('./pages/TopUpMethodSelect'));
const TopUpAmount = lazy(() => import('./pages/TopUpAmount'));
@@ -284,6 +285,16 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/subscriptions/:subscriptionId/renew"
element={
<ProtectedRoute>
<LazyPage>
<RenewSubscription />
</LazyPage>
</ProtectedRoute>
}
/>
{/* Legacy redirects for backward compatibility */}
<Route path="/subscription/:subscriptionId" element={<LegacySubscriptionRedirect />} />
<Route

View File

@@ -5,9 +5,14 @@ import type { Subscription } from '../../types';
interface PurchaseCTAButtonProps {
subscription: Subscription | null;
/** In multi-tariff mode, link to /subscriptions/:id/renew instead of /subscription/purchase */
isMultiTariff?: boolean;
}
export default function PurchaseCTAButton({ subscription }: PurchaseCTAButtonProps) {
export default function PurchaseCTAButton({
subscription,
isMultiTariff = false,
}: PurchaseCTAButtonProps) {
const { t } = useTranslation();
const isExpired = !subscription || (!subscription.is_active && !subscription.is_trial);
@@ -27,8 +32,14 @@ export default function PurchaseCTAButton({ subscription }: PurchaseCTAButtonPro
? t('subscription.cta.trialHint')
: t('subscription.cta.activeHint');
// In multi-tariff mode: renew goes to per-subscription renew page
const linkTo =
isMultiTariff && subscription?.id
? `/subscriptions/${subscription.id}/renew`
: '/subscription/purchase';
return (
<Link to="/subscription/purchase" className="block">
<Link to={linkTo} className="block">
<HoverBorderGradient
accentColor={accentColor}
duration={4}

View File

@@ -0,0 +1,223 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Navigate, useNavigate, useParams } from 'react-router';
import { subscriptionApi } from '../api/subscription';
import { useTheme } from '../hooks/useTheme';
import { getGlassColors } from '../utils/glassTheme';
import { useCurrency } from '../hooks/useCurrency';
import { useHaptic } from '../platform';
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
export default function RenewSubscription() {
const { subscriptionId } = useParams<{ subscriptionId: string }>();
const subId = subscriptionId ? Number(subscriptionId) : undefined;
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { isDark } = useTheme();
const g = getGlassColors(isDark);
const { formatAmount, currencySymbol } = useCurrency();
const { impact } = useHaptic();
const [selectedPeriod, setSelectedPeriod] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
// Load subscription detail for tariff name
const { data: subscriptionResponse } = useQuery({
queryKey: ['subscription', subId],
queryFn: () => subscriptionApi.getSubscription(subId),
enabled: !!subId,
staleTime: 30_000,
});
const subscription = subscriptionResponse?.subscription ?? null;
// Load renewal options
const { data: options, isLoading } = useQuery({
queryKey: ['renewal-options', subId],
queryFn: () => subscriptionApi.getRenewalOptions(subId),
enabled: !!subId,
staleTime: 0,
refetchOnMount: 'always',
});
// Load balance
const { data: purchaseOptions } = useQuery({
queryKey: ['purchase-options', subId],
queryFn: () => subscriptionApi.getPurchaseOptions(subId),
staleTime: 0,
});
const balanceKopeks = purchaseOptions?.balance_kopeks ?? 0;
const renewMutation = useMutation({
mutationFn: (periodDays: number) => subscriptionApi.renewSubscription(periodDays, subId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscription', subId] });
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
queryClient.invalidateQueries({ queryKey: ['renewal-options', subId] });
navigate(`/subscriptions/${subId}`, { replace: true });
},
onError: (err: unknown) => {
const detail =
err && typeof err === 'object' && 'response' in err
? ((err as { response?: { data?: { detail?: unknown } } }).response?.data?.detail ?? null)
: null;
if (detail && typeof detail === 'object' && 'code' in (detail as Record<string, unknown>)) {
const typed = detail as { code: string; missing_amount?: number };
if (typed.code === 'insufficient_funds' && typed.missing_amount) {
setError(`insufficient:${typed.missing_amount}`);
return;
}
}
setError(typeof detail === 'string' ? detail : t('common.error'));
},
});
const handleRenew = (periodDays: number) => {
impact('medium');
setError(null);
renewMutation.mutate(periodDays);
};
if (!subId) {
return <Navigate to="/subscriptions" replace />;
}
if (isLoading) {
return (
<div className="flex min-h-64 items-center justify-center">
<div className="h-10 w-10 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
);
}
const insufficientMatch = error?.match(/^insufficient:(\d+)$/);
const missingAmount = insufficientMatch ? Number(insufficientMatch[1]) : null;
return (
<div className="mx-auto max-w-lg space-y-5 p-4">
{/* Title */}
<div>
<h1 className="text-2xl font-bold" style={{ color: g.text }}>
{t('subscription.extend', 'Продлить подписку')}
</h1>
{subscription?.tariff_name && (
<p className="mt-1 text-sm" style={{ color: g.textSecondary }}>
{subscription.tariff_name}
</p>
)}
</div>
{/* Balance */}
<div
className="flex items-center justify-between rounded-2xl p-4"
style={{ background: g.cardBg, border: `1px solid ${g.cardBorder}` }}
>
<span className="text-sm" style={{ color: g.textSecondary }}>
{t('common.balance', 'Баланс')}
</span>
<span className="text-base font-semibold" style={{ color: g.text }}>
{formatAmount(balanceKopeks / 100)} {currencySymbol}
</span>
</div>
{/* Period options */}
{!options || options.length === 0 ? (
<div
className="rounded-2xl p-6 text-center"
style={{ background: g.cardBg, border: `1px solid ${g.cardBorder}` }}
>
<p style={{ color: g.textSecondary }}>
{t('subscription.noRenewalOptions', 'Нет доступных вариантов продления')}
</p>
</div>
) : (
<div className="space-y-2">
{options.map((option) => {
const isSelected = selectedPeriod === option.period_days;
const canAfford = balanceKopeks >= option.price_kopeks;
const months = Math.max(1, Math.round(option.period_days / 30));
const perMonth = option.price_kopeks / months;
return (
<button
key={option.period_days}
onClick={() => {
impact('light');
setSelectedPeriod(option.period_days);
setError(null);
}}
className="w-full rounded-2xl border p-4 text-left transition-all duration-200"
style={{
background: isSelected
? isDark
? 'rgba(var(--color-accent-400), 0.08)'
: 'rgba(var(--color-accent-400), 0.05)'
: g.cardBg,
borderColor: isSelected ? 'rgb(var(--color-accent-400))' : g.cardBorder,
}}
>
<div className="flex items-center justify-between">
<div>
<span className="text-base font-semibold" style={{ color: g.text }}>
{option.period_days} {t('common.units.days', 'дней')}
</span>
{option.discount_percent > 0 && (
<span className="ml-2 rounded-full bg-emerald-400/15 px-2 py-0.5 text-[10px] font-semibold text-emerald-400">
-{option.discount_percent}%
</span>
)}
</div>
<div className="text-right">
<div className="text-base font-semibold" style={{ color: g.text }}>
{formatAmount(option.price_kopeks / 100)} {currencySymbol}
</div>
{months > 1 && (
<div className="text-[11px]" style={{ color: g.textSecondary }}>
{formatAmount(perMonth / 100)} {currencySymbol}/
{t('common.units.mo', 'мес')}
</div>
)}
{option.original_price_kopeks && (
<div className="text-[11px] line-through" style={{ color: g.textSecondary }}>
{formatAmount(option.original_price_kopeks / 100)} {currencySymbol}
</div>
)}
</div>
</div>
{!canAfford && (
<div className="mt-1 text-[11px] text-red-400">
{t('subscription.insufficientBalance', 'Недостаточно средств')}
</div>
)}
</button>
);
})}
</div>
)}
{/* Insufficient balance prompt */}
{missingAmount && <InsufficientBalancePrompt missingAmountKopeks={missingAmount} compact />}
{/* Error */}
{error && !missingAmount && (
<div className="rounded-xl bg-red-400/10 p-3 text-center text-sm text-red-400">{error}</div>
)}
{/* Renew button */}
{selectedPeriod && (
<button
onClick={() => handleRenew(selectedPeriod)}
disabled={renewMutation.isPending}
className="w-full rounded-2xl bg-accent-500 py-3.5 text-base font-semibold text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
>
{renewMutation.isPending
? t('common.processing', 'Обработка...')
: t('subscription.extend', 'Продлить подписку')}
</button>
)}
</div>
);
}

View File

@@ -1257,7 +1257,7 @@ export default function Subscription() {
)}
{/* Purchase / Renewal CTA */}
<PurchaseCTAButton subscription={subscription} />
<PurchaseCTAButton subscription={subscription} isMultiTariff={isMultiTariff} />
{/* Additional Options (Buy Devices) */}
{subscription &&

View File

@@ -711,9 +711,37 @@ export default function SubscriptionPurchase() {
)}
{/* Tariff Grid */}
{isMultiTariff &&
purchaseOptions &&
'all_tariffs_purchased' in purchaseOptions &&
purchaseOptions.all_tariffs_purchased && (
<div
className="rounded-2xl border p-6 text-center"
style={{ background: g.cardBg, borderColor: g.cardBorder }}
>
<div className="mb-2 text-3xl"></div>
<h3 className="mb-1 text-lg font-semibold" style={{ color: g.text }}>
{t('subscription.allTariffsPurchased', 'Все тарифы подключены')}
</h3>
<p className="mb-4 text-sm" style={{ color: g.textSecondary }}>
{t(
'subscription.allTariffsPurchasedDesc',
'Вы уже приобрели все доступные тарифы. Продлить подписку можно на странице тарифа.',
)}
</p>
<button
onClick={() => navigate('/subscriptions')}
className="rounded-xl bg-accent-500 px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-600"
>
{t('subscription.backToList', 'Мои подписки')}
</button>
</div>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{[...tariffs]
.filter((tariff) => {
// In multi-tariff mode: hide already purchased tariffs
if (isMultiTariff && tariff.is_purchased) return false;
if (subscription?.is_trial && tariff.name.toLowerCase().includes('trial')) {
return false;
}

View File

@@ -343,6 +343,8 @@ export interface Tariff {
custom_days_discount_percent?: number;
// Traffic reset
traffic_reset_mode?: string;
// Multi-tariff: already purchased by user
is_purchased?: boolean;
}
export interface TariffsPurchaseOptions {
@@ -355,6 +357,8 @@ export interface TariffsPurchaseOptions {
subscription_status?: string;
subscription_is_expired?: boolean;
has_subscription?: boolean;
// Multi-tariff: all available tariffs already purchased
all_tariffs_purchased?: boolean;
}
export interface ClassicPurchaseOptions {