diff --git a/src/App.tsx b/src/App.tsx index 98722dc..4d16ad9 100644 --- a/src/App.tsx +++ b/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() { } /> + + + + + + } + /> {/* Legacy redirects for backward compatibility */} } /> + (); + 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(null); + const [error, setError] = useState(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)) { + 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 ; + } + + if (isLoading) { + return ( +
+
+
+ ); + } + + const insufficientMatch = error?.match(/^insufficient:(\d+)$/); + const missingAmount = insufficientMatch ? Number(insufficientMatch[1]) : null; + + return ( +
+ {/* Title */} +
+

+ {t('subscription.extend', 'Продлить подписку')} +

+ {subscription?.tariff_name && ( +

+ {subscription.tariff_name} +

+ )} +
+ + {/* Balance */} +
+ + {t('common.balance', 'Баланс')} + + + {formatAmount(balanceKopeks / 100)} {currencySymbol} + +
+ + {/* Period options */} + {!options || options.length === 0 ? ( +
+

+ {t('subscription.noRenewalOptions', 'Нет доступных вариантов продления')} +

+
+ ) : ( +
+ {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 ( + + ); + })} +
+ )} + + {/* Insufficient balance prompt */} + {missingAmount && } + + {/* Error */} + {error && !missingAmount && ( +
{error}
+ )} + + {/* Renew button */} + {selectedPeriod && ( + + )} +
+ ); +} diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 80a9e75..95f7504 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -1257,7 +1257,7 @@ export default function Subscription() { )} {/* Purchase / Renewal CTA */} - + {/* Additional Options (Buy Devices) */} {subscription && diff --git a/src/pages/SubscriptionPurchase.tsx b/src/pages/SubscriptionPurchase.tsx index 1a8aa35..24753b5 100644 --- a/src/pages/SubscriptionPurchase.tsx +++ b/src/pages/SubscriptionPurchase.tsx @@ -711,9 +711,37 @@ export default function SubscriptionPurchase() { )} {/* Tariff Grid */} + {isMultiTariff && + purchaseOptions && + 'all_tariffs_purchased' in purchaseOptions && + purchaseOptions.all_tariffs_purchased && ( +
+
+

+ {t('subscription.allTariffsPurchased', 'Все тарифы подключены')} +

+

+ {t( + 'subscription.allTariffsPurchasedDesc', + 'Вы уже приобрели все доступные тарифы. Продлить подписку можно на странице тарифа.', + )} +

+ +
+ )}
{[...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; } diff --git a/src/types/index.ts b/src/types/index.ts index aed35eb..d78cd5d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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 {