mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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:
11
src/App.tsx
11
src/App.tsx
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
223
src/pages/RenewSubscription.tsx
Normal file
223
src/pages/RenewSubscription.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1257,7 +1257,7 @@ export default function Subscription() {
|
||||
)}
|
||||
|
||||
{/* Purchase / Renewal CTA */}
|
||||
<PurchaseCTAButton subscription={subscription} />
|
||||
<PurchaseCTAButton subscription={subscription} isMultiTariff={isMultiTariff} />
|
||||
|
||||
{/* Additional Options (Buy Devices) */}
|
||||
{subscription &&
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user