From 820ba46cc42d768279acdfbec4203ee591621302 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Thu, 19 Mar 2026 19:26:42 +0300 Subject: [PATCH] feat: multi-subscription frontend support - Add SubscriptionListItem and SubscriptionsListResponse types - Add getSubscriptions/getSubscriptionById API methods - Add optional subscriptionId parameter to all 30+ API methods - Create /subscriptions page with subscription list cards - Add /subscription/:subscriptionId route for per-subscription management - Dashboard: show multi-tariff navigation block when user has 2+ subscriptions - Subscription page: read subscriptionId from URL params, pass to all queries - SubscriptionPurchase: accept subscriptionId from query params - SubscriptionCardExpired: pass subscription.id to API calls - Full backward compatibility: without subscriptionId, works as before --- src/App.tsx | 21 + src/api/subscription.ts | 375 ++++++++++++------ .../dashboard/SubscriptionCardExpired.tsx | 4 +- src/pages/Dashboard.tsx | 34 +- src/pages/Subscription.tsx | 84 ++-- src/pages/SubscriptionPurchase.tsx | 18 +- src/pages/Subscriptions.tsx | 176 ++++++++ src/types/index.ts | 23 ++ 8 files changed, 562 insertions(+), 173 deletions(-) create mode 100644 src/pages/Subscriptions.tsx diff --git a/src/App.tsx b/src/App.tsx index 4d2ab16..d767355 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,7 @@ import OAuthCallback from './pages/OAuthCallback'; import Dashboard from './pages/Dashboard'; // User pages - lazy load +const Subscriptions = lazy(() => import('./pages/Subscriptions')); const Subscription = lazy(() => import('./pages/Subscription')); const SubscriptionPurchase = lazy(() => import('./pages/SubscriptionPurchase')); const Balance = lazy(() => import('./pages/Balance')); @@ -257,6 +258,26 @@ function App() { } /> + + + + + + } + /> + + + + + + } + /> ) => ({ + params: { + ...(subscriptionId != null && { subscription_id: subscriptionId }), + ...extra, + }, +}); + +/** Helper: merge subscription_id into request body */ +const bodyWithSubId = (body: Record, subscriptionId?: number) => ({ + ...body, + ...(subscriptionId != null && { subscription_id: subscriptionId }), +}); + export const subscriptionApi = { - getSubscription: async (): Promise => { - const response = await apiClient.get('/cabinet/subscription'); + // ── Multi-tariff endpoints ────────────────────────────────────────── + + getSubscriptions: async (): Promise => { + const response = await apiClient.get('/cabinet/subscriptions'); return response.data; }, - getRenewalOptions: async (): Promise => { - const response = await apiClient.get('/cabinet/subscription/renewal-options'); + getSubscriptionById: async (subscriptionId: number): Promise => { + const response = await apiClient.get( + `/cabinet/subscriptions/${subscriptionId}`, + ); + return response.data; + }, + + // ── Legacy single-subscription status ─────────────────────────────── + + getSubscription: async (subscriptionId?: number): Promise => { + const response = await apiClient.get( + '/cabinet/subscription', + withSubId(subscriptionId), + ); + return response.data; + }, + + // ── Renewal ───────────────────────────────────────────────────────── + + getRenewalOptions: async (subscriptionId?: number): Promise => { + const response = await apiClient.get( + '/cabinet/subscription/renewal-options', + withSubId(subscriptionId), + ); return response.data; }, renewSubscription: async ( periodDays: number, + subscriptionId?: number, ): Promise<{ message: string; new_end_date: string; amount_paid_kopeks: number; }> => { - const response = await apiClient.post('/cabinet/subscription/renew', { - period_days: periodDays, - }); + const response = await apiClient.post( + '/cabinet/subscription/renew', + bodyWithSubId({ period_days: periodDays }, subscriptionId), + ); return response.data; }, - getTrafficPackages: async (): Promise => { + // ── Traffic ───────────────────────────────────────────────────────── + + getTrafficPackages: async (subscriptionId?: number): Promise => { const response = await apiClient.get( '/cabinet/subscription/traffic-packages', + withSubId(subscriptionId), ); return response.data; }, purchaseTraffic: async ( gb: number, + subscriptionId?: number, ): Promise<{ message: string; gb_added: number; amount_paid_kopeks: number; }> => { - const response = await apiClient.post('/cabinet/subscription/traffic', { gb }); + const response = await apiClient.post( + '/cabinet/subscription/traffic', + bodyWithSubId({ gb }, subscriptionId), + ); return response.data; }, + switchTraffic: async ( + gb: number, + subscriptionId?: number, + ): Promise<{ + success: boolean; + message: string; + old_traffic_gb: number; + new_traffic_gb: number; + charged_kopeks: number; + balance_kopeks: number; + balance_label: string; + }> => { + const response = await apiClient.put( + '/cabinet/subscription/traffic', + bodyWithSubId({ gb }, subscriptionId), + ); + return response.data; + }, + + saveTrafficCart: async (trafficGb: number, subscriptionId?: number): Promise => { + await apiClient.post( + '/cabinet/subscription/traffic/save-cart', + bodyWithSubId({ gb: trafficGb }, subscriptionId), + ); + }, + + refreshTraffic: async ( + subscriptionId?: number, + ): Promise<{ + success: boolean; + cached: boolean; + rate_limited?: boolean; + retry_after_seconds?: number; + source?: string; + traffic_used_bytes: number; + traffic_used_gb: number; + traffic_limit_bytes: number; + traffic_limit_gb: number; + traffic_used_percent: number; + is_unlimited: boolean; + lifetime_used_bytes?: number; + lifetime_used_gb?: number; + }> => { + const response = await apiClient.post( + '/cabinet/subscription/refresh-traffic', + subscriptionId != null ? { subscription_id: subscriptionId } : undefined, + ); + return response.data; + }, + + // ── Devices ───────────────────────────────────────────────────────── + purchaseDevices: async ( devices: number, + subscriptionId?: number, ): Promise<{ success: boolean; message: string; @@ -65,12 +167,16 @@ export const subscriptionApi = { balance_kopeks: number; balance_label: string; }> => { - const response = await apiClient.post('/cabinet/subscription/devices/purchase', { devices }); + const response = await apiClient.post( + '/cabinet/subscription/devices/purchase', + bodyWithSubId({ devices }, subscriptionId), + ); return response.data; }, getDevicePrice: async ( devices: number = 1, + subscriptionId?: number, ): Promise<{ available: boolean; reason?: string; @@ -84,23 +190,30 @@ export const subscriptionApi = { can_add?: number; days_left?: number; base_device_price_kopeks?: number; - // Discount fields (from promo group) original_price_per_device_kopeks?: number; base_total_price_kopeks?: number; discount_percent?: number; discount_kopeks?: number; }> => { const response = await apiClient.get('/cabinet/subscription/devices/price', { - params: { devices }, + params: { + devices, + ...(subscriptionId != null && { subscription_id: subscriptionId }), + }, }); return response.data; }, - saveDevicesCart: async (devices: number): Promise => { - await apiClient.post('/cabinet/subscription/devices/save-cart', { devices }); + saveDevicesCart: async (devices: number, subscriptionId?: number): Promise => { + await apiClient.post( + '/cabinet/subscription/devices/save-cart', + bodyWithSubId({ devices }, subscriptionId), + ); }, - getDeviceReductionInfo: async (): Promise<{ + getDeviceReductionInfo: async ( + subscriptionId?: number, + ): Promise<{ available: boolean; reason?: string; current_device_limit: number; @@ -108,43 +221,97 @@ export const subscriptionApi = { can_reduce: number; connected_devices_count: number; }> => { - const response = await apiClient.get('/cabinet/subscription/devices/reduction-info'); + const response = await apiClient.get( + '/cabinet/subscription/devices/reduction-info', + withSubId(subscriptionId), + ); return response.data; }, reduceDevices: async ( newDeviceLimit: number, + subscriptionId?: number, ): Promise<{ success: boolean; message: string; old_device_limit: number; new_device_limit: number; }> => { - const response = await apiClient.post('/cabinet/subscription/devices/reduce', { - new_device_limit: newDeviceLimit, - }); + const response = await apiClient.post( + '/cabinet/subscription/devices/reduce', + bodyWithSubId({ new_device_limit: newDeviceLimit }, subscriptionId), + ); return response.data; }, - saveTrafficCart: async (trafficGb: number): Promise => { - await apiClient.post('/cabinet/subscription/traffic/save-cart', { gb: trafficGb }); + getDevices: async ( + subscriptionId?: number, + ): Promise<{ + devices: Array<{ + hwid: string; + platform: string; + device_model: string; + created_at: string | null; + }>; + total: number; + device_limit: number; + }> => { + const response = await apiClient.get( + '/cabinet/subscription/devices', + withSubId(subscriptionId), + ); + return response.data; }, + deleteDevice: async ( + hwid: string, + subscriptionId?: number, + ): Promise<{ + success: boolean; + message: string; + deleted_hwid: string; + }> => { + const response = await apiClient.delete( + `/cabinet/subscription/devices/${encodeURIComponent(hwid)}`, + withSubId(subscriptionId), + ); + return response.data; + }, + + deleteAllDevices: async ( + subscriptionId?: number, + ): Promise<{ + success: boolean; + message: string; + deleted_count: number; + }> => { + const response = await apiClient.delete( + '/cabinet/subscription/devices', + withSubId(subscriptionId), + ); + return response.data; + }, + + // ── Autopay ───────────────────────────────────────────────────────── + updateAutopay: async ( enabled: boolean, daysBefore?: number, + subscriptionId?: number, ): Promise<{ message: string; autopay_enabled: boolean; autopay_days_before: number; }> => { - const response = await apiClient.patch('/cabinet/subscription/autopay', { - enabled, - days_before: daysBefore, - }); + const response = await apiClient.patch( + '/cabinet/subscription/autopay', + bodyWithSubId({ enabled, days_before: daysBefore }, subscriptionId), + ); return response.data; }, + // ── Trial ─────────────────────────────────────────────────────────── + getTrialInfo: async (): Promise => { const response = await apiClient.get('/cabinet/subscription/trial'); return response.data; @@ -155,32 +322,40 @@ export const subscriptionApi = { return response.data; }, - getPurchaseOptions: async (): Promise => { - const response = await apiClient.get('/cabinet/subscription/purchase-options'); + // ── Purchase ──────────────────────────────────────────────────────── + + getPurchaseOptions: async (subscriptionId?: number): Promise => { + const response = await apiClient.get( + '/cabinet/subscription/purchase-options', + withSubId(subscriptionId), + ); return response.data; }, - previewPurchase: async (selection: PurchaseSelection): Promise => { + previewPurchase: async ( + selection: PurchaseSelection, + subscriptionId?: number, + ): Promise => { const response = await apiClient.post( '/cabinet/subscription/purchase-preview', - { - selection, - }, + bodyWithSubId({ selection }, subscriptionId), ); return response.data; }, submitPurchase: async ( selection: PurchaseSelection, + subscriptionId?: number, ): Promise<{ success: boolean; message: string; subscription: Subscription; was_trial_conversion: boolean; }> => { - const response = await apiClient.post('/cabinet/subscription/purchase', { - selection, - }); + const response = await apiClient.post( + '/cabinet/subscription/purchase', + bodyWithSubId({ selection }, subscriptionId), + ); return response.data; }, @@ -205,12 +380,11 @@ export const subscriptionApi = { return response.data; }, - getAppConfig: async (): Promise => { - const response = await apiClient.get('/cabinet/subscription/app-config'); - return response.data; - }, + // ── Countries / Servers ───────────────────────────────────────────── - getCountries: async (): Promise<{ + getCountries: async ( + subscriptionId?: number, + ): Promise<{ countries: Array<{ uuid: string; name: string; @@ -229,12 +403,16 @@ export const subscriptionApi = { days_left: number; discount_percent: number; }> => { - const response = await apiClient.get('/cabinet/subscription/countries'); + const response = await apiClient.get( + '/cabinet/subscription/countries', + withSubId(subscriptionId), + ); return response.data; }, updateCountries: async ( countries: string[], + subscriptionId?: number, ): Promise<{ message: string; added: string[]; @@ -242,11 +420,18 @@ export const subscriptionApi = { amount_paid_kopeks: number; connected_squads: string[]; }> => { - const response = await apiClient.post('/cabinet/subscription/countries', { countries }); + const response = await apiClient.post( + '/cabinet/subscription/countries', + bodyWithSubId({ countries }, subscriptionId), + ); return response.data; }, - getConnectionLink: async (): Promise<{ + // ── Connection ────────────────────────────────────────────────────── + + getConnectionLink: async ( + subscriptionId?: number, + ): Promise<{ subscription_url: string | null; display_link: string | null; happ_redirect_link: string | null; @@ -257,7 +442,10 @@ export const subscriptionApi = { steps: string[]; }; }> => { - const response = await apiClient.get('/cabinet/subscription/connection-link'); + const response = await apiClient.get( + '/cabinet/subscription/connection-link', + withSubId(subscriptionId), + ); return response.data; }, @@ -276,44 +464,19 @@ export const subscriptionApi = { return response.data; }, - getDevices: async (): Promise<{ - devices: Array<{ - hwid: string; - platform: string; - device_model: string; - created_at: string | null; - }>; - total: number; - device_limit: number; - }> => { - const response = await apiClient.get('/cabinet/subscription/devices'); - return response.data; - }, - - deleteDevice: async ( - hwid: string, - ): Promise<{ - success: boolean; - message: string; - deleted_hwid: string; - }> => { - const response = await apiClient.delete( - `/cabinet/subscription/devices/${encodeURIComponent(hwid)}`, + getAppConfig: async (subscriptionId?: number): Promise => { + const response = await apiClient.get( + '/cabinet/subscription/app-config', + withSubId(subscriptionId), ); return response.data; }, - deleteAllDevices: async (): Promise<{ - success: boolean; - message: string; - deleted_count: number; - }> => { - const response = await apiClient.delete('/cabinet/subscription/devices'); - return response.data; - }, + // ── Tariff switch ─────────────────────────────────────────────────── previewTariffSwitch: async ( tariffId: number, + subscriptionId?: number, ): Promise<{ can_switch: boolean; current_tariff_id: number | null; @@ -329,20 +492,20 @@ export const subscriptionApi = { missing_amount_kopeks: number; missing_amount_label: string; is_upgrade: boolean; - // Discount fields (from promo group) base_upgrade_cost_kopeks?: number; discount_percent?: number; discount_kopeks?: number; }> => { - const response = await apiClient.post('/cabinet/subscription/tariff/switch/preview', { - tariff_id: tariffId, - period_days: 30, // Default period for switch - }); + const response = await apiClient.post( + '/cabinet/subscription/tariff/switch/preview', + bodyWithSubId({ tariff_id: tariffId, period_days: 30 }, subscriptionId), + ); return response.data; }, switchTariff: async ( tariffId: number, + subscriptionId?: number, ): Promise<{ success: boolean; message: string; @@ -354,56 +517,28 @@ export const subscriptionApi = { balance_kopeks: number; balance_label: string; }> => { - const response = await apiClient.post('/cabinet/subscription/tariff/switch', { - tariff_id: tariffId, - period_days: 30, - }); + const response = await apiClient.post( + '/cabinet/subscription/tariff/switch', + bodyWithSubId({ tariff_id: tariffId, period_days: 30 }, subscriptionId), + ); return response.data; }, - togglePause: async (): Promise<{ + // ── Daily subscription ────────────────────────────────────────────── + + togglePause: async ( + subscriptionId?: number, + ): Promise<{ success: boolean; message: string; is_paused: boolean; balance_kopeks: number; balance_label: string; }> => { - const response = await apiClient.post('/cabinet/subscription/pause'); - return response.data; - }, - - switchTraffic: async ( - gb: number, - ): Promise<{ - success: boolean; - message: string; - old_traffic_gb: number; - new_traffic_gb: number; - charged_kopeks: number; - balance_kopeks: number; - balance_label: string; - }> => { - const response = await apiClient.put('/cabinet/subscription/traffic', { gb }); - return response.data; - }, - - // Refresh traffic usage from RemnaWave (rate limited: 1 per 60 seconds) - refreshTraffic: async (): Promise<{ - success: boolean; - cached: boolean; - rate_limited?: boolean; - retry_after_seconds?: number; - source?: string; - traffic_used_bytes: number; - traffic_used_gb: number; - traffic_limit_bytes: number; - traffic_limit_gb: number; - traffic_used_percent: number; - is_unlimited: boolean; - lifetime_used_bytes?: number; - lifetime_used_gb?: number; - }> => { - const response = await apiClient.post('/cabinet/subscription/refresh-traffic'); + const response = await apiClient.post( + '/cabinet/subscription/pause', + subscriptionId != null ? { subscription_id: subscriptionId } : undefined, + ); return response.data; }, }; diff --git a/src/components/dashboard/SubscriptionCardExpired.tsx b/src/components/dashboard/SubscriptionCardExpired.tsx index 7bcfb4e..27f8c57 100644 --- a/src/components/dashboard/SubscriptionCardExpired.tsx +++ b/src/components/dashboard/SubscriptionCardExpired.tsx @@ -57,12 +57,12 @@ export default function SubscriptionCardExpired({ try { if (isDisabledDaily) { // Resume daily subscription via toggle pause endpoint - await subscriptionApi.togglePause(); + await subscriptionApi.togglePause(subscription.id); } else if (isDaily && subscription.tariff_id) { // Expired daily tariff — purchase for 1 day await subscriptionApi.purchaseTariff(subscription.tariff_id, 1); } else { - await subscriptionApi.renewSubscription(30); + await subscriptionApi.renewSubscription(30, subscription.id); } haptic.success(); queryClient.invalidateQueries({ queryKey: ['subscription'] }); diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 184145e..4a0e25f 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -51,7 +51,7 @@ export default function Dashboard() { const { data: subscriptionResponse, isLoading: subLoading } = useQuery({ queryKey: ['subscription'], - queryFn: subscriptionApi.getSubscription, + queryFn: () => subscriptionApi.getSubscription(), retry: false, staleTime: API.BALANCE_STALE_TIME_MS, refetchOnMount: 'always', @@ -59,15 +59,24 @@ export default function Dashboard() { const subscription = subscriptionResponse?.subscription ?? null; + // Multi-tariff: check if user has multiple subscriptions + const { data: multiSubData } = useQuery({ + queryKey: ['subscriptions-list'], + queryFn: () => subscriptionApi.getSubscriptions(), + staleTime: 60_000, + }); + const isMultiTariff = multiSubData?.multi_tariff_enabled ?? false; + const multiSubCount = multiSubData?.subscriptions?.length ?? 0; + const { data: trialInfo, isLoading: trialLoading } = useQuery({ queryKey: ['trial-info'], - queryFn: subscriptionApi.getTrialInfo, + queryFn: () => subscriptionApi.getTrialInfo(), enabled: !subscription && !subLoading, }); const { data: devicesData } = useQuery({ queryKey: ['devices'], - queryFn: subscriptionApi.getDevices, + queryFn: () => subscriptionApi.getDevices(), enabled: !!subscription, staleTime: API.BALANCE_STALE_TIME_MS, }); @@ -99,7 +108,7 @@ export default function Dashboard() { }); const activateTrialMutation = useMutation({ - mutationFn: subscriptionApi.activateTrial, + mutationFn: () => subscriptionApi.activateTrial(), onSuccess: () => { setTrialError(null); queryClient.invalidateQueries({ queryKey: ['subscription'] }); @@ -122,7 +131,7 @@ export default function Dashboard() { } | null>(null); const refreshTrafficMutation = useMutation({ - mutationFn: subscriptionApi.refreshTraffic, + mutationFn: () => subscriptionApi.refreshTraffic(), onSuccess: (data) => { setTrafficData({ traffic_used_gb: data.traffic_used_gb, @@ -266,6 +275,21 @@ export default function Dashboard() { {/* Pending Gift Activations */} {pendingGifts && pendingGifts.length > 0 && } + {/* Multi-tariff: show link to subscriptions list */} + {isMultiTariff && multiSubCount > 1 && ( + +
+
+ {t('dashboard.subscriptions', 'Подписки')} +
+
+ {multiSubCount} {t('dashboard.activeTariffs', 'активных тарифов')} +
+
+ + + )} + {/* Subscription Status Card */} {subLoading ? (
diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index e55104e..742eb7f 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef, useCallback, memo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router'; +import { useNavigate, useParams } from 'react-router'; import { subscriptionApi } from '../api/subscription'; import TrafficProgressBar from '../components/dashboard/TrafficProgressBar'; import { HoverBorderGradient } from '../components/ui/hover-border-gradient'; @@ -168,6 +168,8 @@ export default function Subscription() { const queryClient = useQueryClient(); const { formatAmount, currencySymbol } = useCurrency(); const navigate = useNavigate(); + const { subscriptionId: subIdParam } = useParams<{ subscriptionId?: string }>(); + const subscriptionId = subIdParam ? parseInt(subIdParam, 10) : undefined; const { isDark } = useTheme(); const g = getGlassColors(isDark); const haptic = useHaptic(); @@ -195,10 +197,10 @@ export default function Subscription() { } | null>(null); const { data: subscriptionResponse, isLoading } = useQuery({ - queryKey: ['subscription'], - queryFn: subscriptionApi.getSubscription, + queryKey: ['subscription', subscriptionId], + queryFn: () => subscriptionApi.getSubscription(subscriptionId), retry: false, - staleTime: 0, // Always refetch to get latest data + staleTime: 0, refetchOnMount: 'always', }); @@ -211,8 +213,8 @@ export default function Subscription() { // Purchase options (needed for balance_kopeks in device/traffic/server management) const { data: purchaseOptions } = useQuery({ - queryKey: ['purchase-options'], - queryFn: subscriptionApi.getPurchaseOptions, + queryKey: ['purchase-options', subscriptionId], + queryFn: () => subscriptionApi.getPurchaseOptions(subscriptionId), staleTime: 0, refetchOnMount: 'always', }); @@ -220,40 +222,41 @@ export default function Subscription() { const isTariffsMode = purchaseOptions?.sales_mode === 'tariffs'; const autopayMutation = useMutation({ - mutationFn: (enabled: boolean) => subscriptionApi.updateAutopay(enabled), + mutationFn: (enabled: boolean) => + subscriptionApi.updateAutopay(enabled, undefined, subscriptionId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['subscription'] }); + queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] }); }, }); // Devices query const { data: devicesData, isLoading: devicesLoading } = useQuery({ - queryKey: ['devices'], - queryFn: subscriptionApi.getDevices, + queryKey: ['devices', subscriptionId], + queryFn: () => subscriptionApi.getDevices(subscriptionId), enabled: !!subscription, }); // Delete device mutation const deleteDeviceMutation = useMutation({ - mutationFn: (hwid: string) => subscriptionApi.deleteDevice(hwid), + mutationFn: (hwid: string) => subscriptionApi.deleteDevice(hwid, subscriptionId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['devices'] }); + queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] }); }, }); // Delete all devices mutation const deleteAllDevicesMutation = useMutation({ - mutationFn: () => subscriptionApi.deleteAllDevices(), + mutationFn: () => subscriptionApi.deleteAllDevices(subscriptionId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['devices'] }); + queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] }); }, }); // Pause subscription mutation const pauseMutation = useMutation({ - mutationFn: () => subscriptionApi.togglePause(), + mutationFn: () => subscriptionApi.togglePause(subscriptionId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['subscription'] }); + queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] }); queryClient.invalidateQueries({ queryKey: ['balance'] }); }, }); @@ -269,17 +272,17 @@ export default function Subscription() { // Device price query const { data: devicePriceData } = useQuery({ - queryKey: ['device-price', devicesToAdd], - queryFn: () => subscriptionApi.getDevicePrice(devicesToAdd), + queryKey: ['device-price', devicesToAdd, subscriptionId], + queryFn: () => subscriptionApi.getDevicePrice(devicesToAdd, subscriptionId), enabled: showDeviceTopup && !!subscription, }); // Device purchase mutation const devicePurchaseMutation = useMutation({ - mutationFn: () => subscriptionApi.purchaseDevices(devicesToAdd), + mutationFn: () => subscriptionApi.purchaseDevices(devicesToAdd, subscriptionId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['subscription'] }); - queryClient.invalidateQueries({ queryKey: ['devices'] }); + queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] }); + queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] }); queryClient.invalidateQueries({ queryKey: ['device-price'] }); setShowDeviceTopup(false); setDevicesToAdd(1); @@ -288,8 +291,8 @@ export default function Subscription() { // Device reduction info query const { data: deviceReductionInfo } = useQuery({ - queryKey: ['device-reduction-info'], - queryFn: subscriptionApi.getDeviceReductionInfo, + queryKey: ['device-reduction-info', subscriptionId], + queryFn: () => subscriptionApi.getDeviceReductionInfo(subscriptionId), enabled: showDeviceReduction && !!subscription, }); @@ -307,27 +310,27 @@ export default function Subscription() { // Device reduction mutation const deviceReductionMutation = useMutation({ - mutationFn: () => subscriptionApi.reduceDevices(targetDeviceLimit), + mutationFn: () => subscriptionApi.reduceDevices(targetDeviceLimit, subscriptionId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['subscription'] }); - queryClient.invalidateQueries({ queryKey: ['devices'] }); - queryClient.invalidateQueries({ queryKey: ['device-reduction-info'] }); + queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] }); + queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] }); + queryClient.invalidateQueries({ queryKey: ['device-reduction-info', subscriptionId] }); setShowDeviceReduction(false); }, }); // Traffic packages query const { data: trafficPackages } = useQuery({ - queryKey: ['traffic-packages'], - queryFn: subscriptionApi.getTrafficPackages, + queryKey: ['traffic-packages', subscriptionId], + queryFn: () => subscriptionApi.getTrafficPackages(subscriptionId), enabled: showTrafficTopup && !!subscription, }); // Traffic purchase mutation const trafficPurchaseMutation = useMutation({ - mutationFn: (gb: number) => subscriptionApi.purchaseTraffic(gb), + mutationFn: (gb: number) => subscriptionApi.purchaseTraffic(gb, subscriptionId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['subscription'] }); + queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] }); setShowTrafficTopup(false); setSelectedTrafficPackage(null); }, @@ -335,8 +338,8 @@ export default function Subscription() { // Countries/servers query const { data: countriesData, isLoading: countriesLoading } = useQuery({ - queryKey: ['countries'], - queryFn: subscriptionApi.getCountries, + queryKey: ['countries', subscriptionId], + queryFn: () => subscriptionApi.getCountries(subscriptionId), enabled: showServerManagement && !!subscription && !subscription.is_trial, }); @@ -350,17 +353,17 @@ export default function Subscription() { // Countries update mutation const updateCountriesMutation = useMutation({ - mutationFn: (countries: string[]) => subscriptionApi.updateCountries(countries), + mutationFn: (countries: string[]) => subscriptionApi.updateCountries(countries, subscriptionId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['subscription'] }); - queryClient.invalidateQueries({ queryKey: ['countries'] }); + queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] }); + queryClient.invalidateQueries({ queryKey: ['countries', subscriptionId] }); setShowServerManagement(false); }, }); // Traffic refresh mutation const refreshTrafficMutation = useMutation({ - mutationFn: subscriptionApi.refreshTraffic, + mutationFn: () => subscriptionApi.refreshTraffic(subscriptionId), onSuccess: (data) => { setTrafficData({ traffic_used_gb: data.traffic_used_gb, @@ -1381,7 +1384,7 @@ export default function Subscription() { } compact onBeforeTopUp={async () => { - await subscriptionApi.saveDevicesCart(devicesToAdd); + await subscriptionApi.saveDevicesCart(devicesToAdd, subscriptionId); }} /> )} @@ -1721,7 +1724,10 @@ export default function Subscription() { compact className="mb-3" onBeforeTopUp={async () => { - await subscriptionApi.saveTrafficCart(selectedTrafficPackage); + await subscriptionApi.saveTrafficCart( + selectedTrafficPackage, + subscriptionId, + ); }} /> )} diff --git a/src/pages/SubscriptionPurchase.tsx b/src/pages/SubscriptionPurchase.tsx index 829a9fe..71a866d 100644 --- a/src/pages/SubscriptionPurchase.tsx +++ b/src/pages/SubscriptionPurchase.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router'; +import { useNavigate, useSearchParams } from 'react-router'; import { AxiosError } from 'axios'; import { subscriptionApi } from '../api/subscription'; import { promoApi } from '../api/promo'; @@ -28,6 +28,10 @@ export default function SubscriptionPurchase() { const { t } = useTranslation(); const queryClient = useQueryClient(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const subscriptionId = searchParams.get('subscriptionId') + ? parseInt(searchParams.get('subscriptionId')!, 10) + : undefined; const { formatAmount, currencySymbol } = useCurrency(); const { isDark } = useTheme(); const g = getGlassColors(isDark); @@ -37,7 +41,7 @@ export default function SubscriptionPurchase() { // Subscription query (shares cache with /subscription page) const { data: subscriptionResponse, isLoading } = useQuery({ queryKey: ['subscription'], - queryFn: subscriptionApi.getSubscription, + queryFn: () => subscriptionApi.getSubscription(subscriptionId), retry: false, staleTime: 0, refetchOnMount: 'always', @@ -52,7 +56,7 @@ export default function SubscriptionPurchase() { refetch: refetchOptions, } = useQuery({ queryKey: ['purchase-options'], - queryFn: subscriptionApi.getPurchaseOptions, + queryFn: () => subscriptionApi.getPurchaseOptions(subscriptionId), staleTime: 0, refetchOnMount: 'always', }); @@ -217,13 +221,13 @@ export default function SubscriptionPurchase() { // Preview query (classic) const { data: preview, isLoading: previewLoading } = useQuery({ queryKey: ['purchase-preview', currentSelection], - queryFn: () => subscriptionApi.previewPurchase(currentSelection), + queryFn: () => subscriptionApi.previewPurchase(currentSelection, subscriptionId), enabled: !!selectedPeriod && showPurchaseForm && currentStep === 'confirm', }); // Classic purchase mutation const purchaseMutation = useMutation({ - mutationFn: () => subscriptionApi.submitPurchase(currentSelection), + mutationFn: () => subscriptionApi.submitPurchase(currentSelection, subscriptionId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['subscription'] }); queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); @@ -234,13 +238,13 @@ export default function SubscriptionPurchase() { // Switch preview query const { data: switchPreview, isLoading: switchPreviewLoading } = useQuery({ queryKey: ['tariff-switch-preview', switchTariffId], - queryFn: () => subscriptionApi.previewTariffSwitch(switchTariffId!), + queryFn: () => subscriptionApi.previewTariffSwitch(switchTariffId!, subscriptionId), enabled: !!switchTariffId, }); // Tariff switch mutation const switchTariffMutation = useMutation({ - mutationFn: (tariffId: number) => subscriptionApi.switchTariff(tariffId), + mutationFn: (tariffId: number) => subscriptionApi.switchTariff(tariffId, subscriptionId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['subscription'] }); queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); diff --git a/src/pages/Subscriptions.tsx b/src/pages/Subscriptions.tsx new file mode 100644 index 0000000..8e57a51 --- /dev/null +++ b/src/pages/Subscriptions.tsx @@ -0,0 +1,176 @@ +import { useQuery } from '@tanstack/react-query'; +import { useNavigate } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { subscriptionApi } from '../api/subscription'; +import { useTheme } from '../hooks/useTheme'; +import { getGlassColors } from '../utils/glassTheme'; +import { useHaptic } from '../platform'; +import type { SubscriptionListItem } from '../types'; + +function formatTrafficDisplay(used: number, limit: number): string { + if (limit === 0) return '∞'; + return `${used.toFixed(1)} / ${limit} ГБ`; +} + +function formatDate(iso: string | null): string { + if (!iso) return '—'; + try { + return new Date(iso).toLocaleDateString('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); + } catch { + return '—'; + } +} + +function StatusDot({ status }: { status: string }) { + const color = + status === 'active' || status === 'trial' + ? 'bg-emerald-400' + : status === 'limited' + ? 'bg-amber-400' + : 'bg-red-400'; + return ; +} + +function SubscriptionCard({ + subscription, + onClick, +}: { + subscription: SubscriptionListItem; + onClick: () => void; +}) { + const { theme } = useTheme(); + const glass = getGlassColors(theme); + const haptic = useHaptic(); + + const handleClick = () => { + haptic?.(); + onClick(); + }; + + return ( + + ); +} + +function EmptyState({ onBuy }: { onBuy: () => void }) { + const { theme } = useTheme(); + const glass = getGlassColors(theme); + + return ( +
+
📋
+

Нет подписок

+

У вас пока нет активных подписок

+ +
+ ); +} + +export default function Subscriptions() { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const { data, isLoading } = useQuery({ + queryKey: ['subscriptions-list'], + queryFn: () => subscriptionApi.getSubscriptions(), + staleTime: 30_000, + refetchOnMount: 'always', + }); + + // If multi_tariff not enabled, redirect to legacy subscription page + if (data && !data.multi_tariff_enabled) { + navigate('/subscription', { replace: true }); + return null; + } + + const subscriptions = data?.subscriptions ?? []; + + return ( +
+

{t('subscriptions.title', 'Мои подписки')}

+ + {isLoading && ( +
+ {[1, 2].map((i) => ( +
+ ))} +
+ )} + + {!isLoading && subscriptions.length === 0 && ( + navigate('/subscription/purchase')} /> + )} + + {subscriptions.map((sub) => ( + navigate(`/subscription/${sub.id}`)} + /> + ))} + + {!isLoading && subscriptions.length > 0 && ( + + )} +
+ ); +} diff --git a/src/types/index.ts b/src/types/index.ts index ad61a5e..aed35eb 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -110,6 +110,29 @@ export interface SubscriptionStatusResponse { subscription: Subscription | null; } +// Multi-tariff subscription list item (from GET /cabinet/subscriptions) +export interface SubscriptionListItem { + id: number; + status: string; + tariff_id: number | null; + tariff_name: string | null; + traffic_limit_gb: number; + traffic_used_gb: number; + device_limit: number; + end_date: string | null; + subscription_url: string | null; + subscription_crypto_link: string | null; + is_trial: boolean; + autopay_enabled: boolean; + connected_squads: string[] | null; +} + +// Response from GET /cabinet/subscriptions (multi-tariff) +export interface SubscriptionsListResponse { + subscriptions: SubscriptionListItem[]; + multi_tariff_enabled: boolean; +} + // Device types export interface Device { hwid: string;