From 820ba46cc42d768279acdfbec4203ee591621302 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Thu, 19 Mar 2026 19:26:42 +0300 Subject: [PATCH 01/36] 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; From 44d60692948730ce458eed2758f0046c5cfaf2dd Mon Sep 17 00:00:00 2001 From: c0mrade Date: Thu, 19 Mar 2026 19:35:05 +0300 Subject: [PATCH 02/36] fix: correct glass theme and haptic API usage in Subscriptions page --- src/pages/Subscriptions.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/pages/Subscriptions.tsx b/src/pages/Subscriptions.tsx index 8e57a51..38aa285 100644 --- a/src/pages/Subscriptions.tsx +++ b/src/pages/Subscriptions.tsx @@ -42,19 +42,20 @@ function SubscriptionCard({ subscription: SubscriptionListItem; onClick: () => void; }) { - const { theme } = useTheme(); - const glass = getGlassColors(theme); - const haptic = useHaptic(); + const { isDark } = useTheme(); + const g = getGlassColors(isDark); + const { impact } = useHaptic(); const handleClick = () => { - haptic?.(); + impact('light'); onClick(); }; return (

- {subscription?.is_daily && !subscription?.is_trial - ? t('subscription.switchTariff.title') - : subscription && !subscription.is_trial - ? t('subscription.extend') - : t('subscription.getSubscription')} + {isMultiTariff && !subscriptionId + ? t('subscription.buyTariff', 'Купить тариф') + : subscription?.is_daily && !subscription?.is_trial + ? t('subscription.switchTariff.title') + : subscription && !subscription.is_trial + ? t('subscription.extend') + : t('subscription.getSubscription')}

@@ -776,6 +786,7 @@ export default function SubscriptionPurchase() { 'subscription_is_expired' in purchaseOptions && purchaseOptions.subscription_is_expired === true; const canSwitch = + !isMultiTariff && subscription && subscription.tariff_id && !isCurrentTariff && From 865a78b801f7bed89b2534e5bc20577b4bcb39f6 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Thu, 19 Mar 2026 21:26:42 +0300 Subject: [PATCH 06/36] fix: hide legacy subscription card on Dashboard in multi-tariff mode --- src/pages/Dashboard.tsx | 66 ++++++++++++++++++++++------------------- 1 file changed, 35 insertions(+), 31 deletions(-) diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 78ed892..911d36b 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -292,37 +292,41 @@ export default function Dashboard() { )} - {/* Subscription Status Card */} - {subLoading ? ( -
-
-
-
-
-
-
-
-
-
-
-
- ) : subscription?.is_expired || - subscription?.status === 'disabled' || - subscription?.is_limited ? ( - - ) : subscription ? ( - - ) : null} + {/* Subscription Status Card — hidden in multi-tariff (managed via /subscriptions) */} + {!isMultiTariff && ( + <> + {subLoading ? ( +
+
+
+
+
+
+
+
+
+
+
+
+ ) : subscription?.is_expired || + subscription?.status === 'disabled' || + subscription?.is_limited ? ( + + ) : subscription ? ( + + ) : null} + + )} {/* Trial Activation */} {hasNoSubscription && !trialLoading && trialInfo?.is_available && ( From 09f467bfb36e89e6922d3add3e255351a5e6cabc Mon Sep 17 00:00:00 2001 From: c0mrade Date: Thu, 19 Mar 2026 21:33:16 +0300 Subject: [PATCH 07/36] feat: show per-subscription mini-cards on Dashboard with tariff name, traffic, devices, date --- src/pages/Dashboard.tsx | 65 ++++++++++++++++++++++++++++++++--------- 1 file changed, 52 insertions(+), 13 deletions(-) diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 911d36b..653679c 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -275,21 +275,60 @@ export default function Dashboard() { {/* Pending Gift Activations */} {pendingGifts && pendingGifts.length > 0 && } - {/* Multi-tariff: show link to subscriptions list */} - {isMultiTariff && ( - -
-
+ {/* Multi-tariff: show mini-cards for each subscription */} + {isMultiTariff && multiSubData?.subscriptions && ( +
+
+ {t('dashboard.subscriptions', 'Подписки')} -
-
- {multiSubCount > 1 - ? `${multiSubCount} ${t('dashboard.activeTariffs', 'активных тарифов')}` - : t('dashboard.manageSubscriptions', 'Управление подписками')} -
+ + + {t('dashboard.manageAll', 'Управление')} → +
- - + {multiSubData.subscriptions.map((sub) => { + const isActive = sub.status === 'active' || sub.status === 'trial'; + const trafficDisplay = + sub.traffic_limit_gb === 0 + ? '∞' + : `${sub.traffic_used_gb.toFixed(1)} / ${sub.traffic_limit_gb} ГБ`; + const endDate = sub.end_date + ? new Date(sub.end_date).toLocaleDateString('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }) + : '—'; + return ( + +
+
+ + {sub.tariff_name || 'Подписка'} +
+
+ 📊 {trafficDisplay} + 📱 {sub.device_limit} + 📅 {endDate} +
+
+ + + ); + })} + + + {t('subscriptions.buyAnother', 'Купить ещё тариф')} + +
)} {/* Subscription Status Card — hidden in multi-tariff (managed via /subscriptions) */} From 258bfd765487cab96b0c7f9325457b6f43614e71 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Thu, 19 Mar 2026 21:40:51 +0300 Subject: [PATCH 08/36] fix: remove unused multiSubCount variable --- src/pages/Dashboard.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 653679c..b04c140 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -66,7 +66,6 @@ export default function Dashboard() { 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'], From f4de6d8ad843e5a1d47f5ae4ddaa0bc99d4ee5f2 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Mon, 23 Mar 2026 11:32:03 +0300 Subject: [PATCH 09/36] fix: multi-subscription UI audit fixes and cache invalidation improvements - Fix AdminUserDetail multi-subscription display with useMemo optimization - Update Dashboard subscription cards and purchase entry points - Fix WebSocket notification handlers for multi-subscription context - Update SubscriptionPurchase cache invalidation for multi-tariff mode - Fix Subscriptions list page and navigation patterns - Update App routing, DeepLinkRedirect, and CommandPalette for multi-sub - Add SubscriptionListCard component - Fix TopUpResult and PromoOffersSection navigation --- src/App.tsx | 16 +- src/AppWithNavigator.tsx | 2 +- src/api/adminUsers.ts | 2 + src/components/PromoOffersSection.tsx | 1 + src/components/SuccessNotificationModal.tsx | 2 +- src/components/WebSocketNotifications.tsx | 50 +++- .../dashboard/SubscriptionCardActive.tsx | 4 +- .../dashboard/SubscriptionCardExpired.tsx | 7 +- .../CommandPalette/CommandPalette.tsx | 4 +- .../subscription/SubscriptionListCard.tsx | 102 ++++++++ src/pages/AdminUserDetail.tsx | 223 +++++++++++------- src/pages/Dashboard.tsx | 62 ++--- src/pages/DeepLinkRedirect.tsx | 4 +- src/pages/Subscription.tsx | 65 ++++- src/pages/SubscriptionPurchase.tsx | 23 +- src/pages/Subscriptions.tsx | 123 ++-------- src/pages/TopUpResult.tsx | 5 +- src/providers/WebSocketContext.ts | 1 + 18 files changed, 426 insertions(+), 270 deletions(-) create mode 100644 src/components/subscription/SubscriptionListCard.tsx diff --git a/src/App.tsx b/src/App.tsx index d767355..98722dc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import { lazy, Suspense } from 'react'; -import { Routes, Route, Navigate, useLocation } from 'react-router'; +import { Routes, Route, Navigate, useLocation, useParams } from 'react-router'; import { useAuthStore } from './store/auth'; import { useBlockingStore } from './store/blocking'; import Layout from './components/layout/Layout'; @@ -191,6 +191,12 @@ function BlockingOverlay() { return null; } +/** Redirect /subscription/:id → /subscriptions/:id preserving the param */ +function LegacySubscriptionRedirect() { + const { subscriptionId } = useParams<{ subscriptionId: string }>(); + return ; +} + function App() { useAnalyticsCounters(); @@ -269,7 +275,7 @@ function App() { } /> @@ -278,13 +284,13 @@ function App() { } /> + {/* Legacy redirects for backward compatibility */} + } /> - - - + } /> diff --git a/src/AppWithNavigator.tsx b/src/AppWithNavigator.tsx index 4a4cbe3..823cce9 100644 --- a/src/AppWithNavigator.tsx +++ b/src/AppWithNavigator.tsx @@ -23,7 +23,7 @@ const TWEMOJI_OPTIONS = { className: 'twemoji', folder: 'svg', ext: '.svg' } as * Shows back button on non-root routes, hides on root. */ /** Pages reachable from bottom nav — treat as top-level (no back button). */ -const BOTTOM_NAV_PATHS = ['/', '/subscription', '/balance', '/referral', '/support', '/wheel']; +const BOTTOM_NAV_PATHS = ['/', '/subscriptions', '/balance', '/referral', '/support', '/wheel']; function TelegramBackButton() { const location = useLocation(); diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index 294bfcf..41b9980 100644 --- a/src/api/adminUsers.ts +++ b/src/api/adminUsers.ts @@ -103,6 +103,7 @@ export interface UserDetailResponse { last_activity: string | null; cabinet_last_login: string | null; subscription: UserSubscriptionInfo | null; + subscriptions: UserSubscriptionInfo[]; promo_group: UserPromoGroupInfo | null; referral: UserReferralInfo; total_spent_kopeks: number; @@ -296,6 +297,7 @@ export interface UpdateSubscriptionRequest { | 'remove_traffic' | 'set_device_limit' | 'shorten'; + subscription_id?: number; days?: number; end_date?: string; tariff_id?: number; diff --git a/src/components/PromoOffersSection.tsx b/src/components/PromoOffersSection.tsx index 998ccb3..63c4d1a 100644 --- a/src/components/PromoOffersSection.tsx +++ b/src/components/PromoOffersSection.tsx @@ -114,6 +114,7 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio queryClient.invalidateQueries({ queryKey: ['promo-offers'] }); queryClient.invalidateQueries({ queryKey: ['active-discount'] }); queryClient.invalidateQueries({ queryKey: ['subscription'] }); + queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] }); queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); queryClient.invalidateQueries({ queryKey: ['balance'] }); setSuccessMessage(result.message); diff --git a/src/components/SuccessNotificationModal.tsx b/src/components/SuccessNotificationModal.tsx index 08f1d74..1f43437 100644 --- a/src/components/SuccessNotificationModal.tsx +++ b/src/components/SuccessNotificationModal.tsx @@ -190,7 +190,7 @@ export default function SuccessNotificationModal() { const handleGoToSubscription = () => { hide(); - navigate('/subscription'); + navigate('/subscriptions'); }; const handleGoToBalance = () => { diff --git a/src/components/WebSocketNotifications.tsx b/src/components/WebSocketNotifications.tsx index d411263..f8b38ea 100644 --- a/src/components/WebSocketNotifications.tsx +++ b/src/components/WebSocketNotifications.tsx @@ -82,7 +82,11 @@ export default function WebSocketNotifications() { expiresAt: message.expires_at, tariffName: message.tariff_name, }); - queryClient.invalidateQueries({ queryKey: ['subscription'] }); + queryClient.invalidateQueries({ + predicate: (query) => + Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription', + }); + queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] }); queryClient.invalidateQueries({ queryKey: ['balance'] }); queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); refreshUser(); @@ -96,7 +100,11 @@ export default function WebSocketNotifications() { amountKopeks: message.amount_kopeks, expiresAt: message.new_expires_at, }); - queryClient.invalidateQueries({ queryKey: ['subscription'] }); + queryClient.invalidateQueries({ + predicate: (query) => + Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription', + }); + queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] }); queryClient.invalidateQueries({ queryKey: ['balance'] }); queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); refreshUser(); @@ -115,7 +123,7 @@ export default function WebSocketNotifications() { }, ), icon: , - onClick: () => navigate('/subscription'), + onClick: () => navigate('/subscriptions'), duration: 10000, }); return; @@ -130,10 +138,14 @@ export default function WebSocketNotifications() { 'Your subscription has expired. Renew to continue using the service.', ), icon: 😢, - onClick: () => navigate('/subscription'), + onClick: () => navigate('/subscriptions'), duration: 10000, }); - queryClient.invalidateQueries({ queryKey: ['subscription'] }); + queryClient.invalidateQueries({ + predicate: (query) => + Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription', + }); + queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] }); refreshUser(); return; } @@ -171,7 +183,11 @@ export default function WebSocketNotifications() { icon: 🔄, duration: 5000, }); - queryClient.invalidateQueries({ queryKey: ['subscription'] }); + queryClient.invalidateQueries({ + predicate: (query) => + Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription', + }); + queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] }); return; } @@ -183,7 +199,11 @@ export default function WebSocketNotifications() { devicesAdded: message.devices_added, newDeviceLimit: message.new_device_limit, }); - queryClient.invalidateQueries({ queryKey: ['subscription'] }); + queryClient.invalidateQueries({ + predicate: (query) => + Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription', + }); + queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] }); queryClient.invalidateQueries({ queryKey: ['balance'] }); queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); queryClient.invalidateQueries({ queryKey: ['transactions'] }); @@ -199,7 +219,11 @@ export default function WebSocketNotifications() { trafficGbAdded: message.traffic_gb_added, newTrafficLimitGb: message.new_traffic_limit_gb, }); - queryClient.invalidateQueries({ queryKey: ['subscription'] }); + queryClient.invalidateQueries({ + predicate: (query) => + Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription', + }); + queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] }); queryClient.invalidateQueries({ queryKey: ['balance'] }); queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); queryClient.invalidateQueries({ queryKey: ['transactions'] }); @@ -222,10 +246,14 @@ export default function WebSocketNotifications() { }, ), icon: 🔁, - onClick: () => navigate('/subscription'), + onClick: () => navigate('/subscriptions'), duration: 8000, }); - queryClient.invalidateQueries({ queryKey: ['subscription'] }); + queryClient.invalidateQueries({ + predicate: (query) => + Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription', + }); + queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] }); queryClient.invalidateQueries({ queryKey: ['balance'] }); queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); refreshUser(); @@ -240,7 +268,7 @@ export default function WebSocketNotifications() { message.reason || t('wsNotifications.autopay.failedMessage', 'Failed to auto-renew your subscription'), icon: , - onClick: () => navigate('/subscription'), + onClick: () => navigate('/subscriptions'), duration: 10000, }); return; diff --git a/src/components/dashboard/SubscriptionCardActive.tsx b/src/components/dashboard/SubscriptionCardActive.tsx index a3e09ad..02e2a62 100644 --- a/src/components/dashboard/SubscriptionCardActive.tsx +++ b/src/components/dashboard/SubscriptionCardActive.tsx @@ -312,7 +312,7 @@ export default function SubscriptionCardActive({
{/* Tariff badge — clickable */} 0 ? `${trafficRefreshCooldown}s` : t('common.refresh')} {t('dashboard.viewSubscription')} → diff --git a/src/components/dashboard/SubscriptionCardExpired.tsx b/src/components/dashboard/SubscriptionCardExpired.tsx index 27f8c57..499ab92 100644 --- a/src/components/dashboard/SubscriptionCardExpired.tsx +++ b/src/components/dashboard/SubscriptionCardExpired.tsx @@ -65,7 +65,10 @@ export default function SubscriptionCardExpired({ await subscriptionApi.renewSubscription(30, subscription.id); } haptic.success(); - queryClient.invalidateQueries({ queryKey: ['subscription'] }); + queryClient.invalidateQueries({ + predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription', + }); + queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] }); queryClient.invalidateQueries({ queryKey: ['balance'] }); queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); } catch (err: unknown) { @@ -257,7 +260,7 @@ export default function SubscriptionCardExpired({
{isLimited ? ( navigate('/subscription'), + action: () => navigate('/subscriptions'), }, { label: isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode', diff --git a/src/components/subscription/SubscriptionListCard.tsx b/src/components/subscription/SubscriptionListCard.tsx new file mode 100644 index 0000000..8cb2132 --- /dev/null +++ b/src/components/subscription/SubscriptionListCard.tsx @@ -0,0 +1,102 @@ +import { useTranslation } from 'react-i18next'; +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 ; +} + +export default function SubscriptionListCard({ + subscription, + onClick, +}: { + subscription: SubscriptionListItem; + onClick: () => void; +}) { + const { t } = useTranslation(); + const { isDark } = useTheme(); + const g = getGlassColors(isDark); + const { impact } = useHaptic(); + + const handleClick = () => { + impact('light'); + onClick(); + }; + + return ( + + ); +} diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index 75174e1..188ec57 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { useParams, useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; import i18n from '../i18n'; @@ -335,6 +335,7 @@ export default function AdminUserDetail() { const [subAction, setSubAction] = useState('extend'); const [subDays, setSubDays] = useState(30); const [selectedTariffId, setSelectedTariffId] = useState(null); + const [activeSubscriptionId, setActiveSubscriptionId] = useState(null); // Promo group const [promoGroups, setPromoGroups] = useState([]); @@ -602,6 +603,9 @@ export default function AdminUserDetail() { try { const data: UpdateSubscriptionRequest = { action: action as UpdateSubscriptionRequest['action'], + ...(activeSubscriptionId && action !== 'create' + ? { subscription_id: activeSubscriptionId } + : {}), ...(action === 'extend' || action === 'shorten' ? { days: toNumber(subDays, 30) } : {}), ...(action === 'change_tariff' && selectedTariffId ? { tariff_id: selectedTariffId } : {}), ...(action === 'create' @@ -721,7 +725,11 @@ export default function AdminUserDetail() { if (!userId) return; setActionLoading(true); try { - await adminUsersApi.updateSubscription(userId, { action: 'add_traffic', traffic_gb: gb }); + await adminUsersApi.updateSubscription(userId, { + action: 'add_traffic', + traffic_gb: gb, + ...(activeSubscriptionId ? { subscription_id: activeSubscriptionId } : {}), + }); notify.success(t('admin.users.detail.subscription.trafficAdded')); setSelectedTrafficGb(''); await loadUser(); @@ -739,6 +747,7 @@ export default function AdminUserDetail() { await adminUsersApi.updateSubscription(userId, { action: 'remove_traffic', traffic_purchase_id: purchaseId, + ...(activeSubscriptionId ? { subscription_id: activeSubscriptionId } : {}), }); notify.success(t('admin.users.detail.subscription.trafficRemoved')); await loadUser(); @@ -756,6 +765,7 @@ export default function AdminUserDetail() { await adminUsersApi.updateSubscription(userId, { action: 'set_device_limit', device_limit: newLimit, + ...(activeSubscriptionId ? { subscription_id: activeSubscriptionId } : {}), }); notify.success(t('admin.users.detail.subscription.deviceLimitUpdated')); await loadUser(); @@ -766,7 +776,20 @@ export default function AdminUserDetail() { } }; - const currentTariff = tariffs.find((t) => t.id === user?.subscription?.tariff_id) || null; + // Multi-subscription: pick active subscription or first from list + const userSubscriptions = useMemo(() => user?.subscriptions ?? [], [user?.subscriptions]); + const selectedSub = + userSubscriptions.find((s) => s.id === activeSubscriptionId) ?? user?.subscription ?? null; + + // Auto-select first subscription when user loads + useEffect(() => { + if (user && userSubscriptions.length > 0 && !activeSubscriptionId) { + const activeSub = userSubscriptions.find((s) => s.is_active) ?? userSubscriptions[0]; + setActiveSubscriptionId(activeSub.id); + } + }, [user, userSubscriptions, activeSubscriptionId]); + + const currentTariff = tariffs.find((t) => t.id === selectedSub?.tariff_id) || null; const handleChangePromoGroup = async (groupId: number | null) => { if (!userId) return; @@ -1356,15 +1379,50 @@ export default function AdminUserDetail() { {/* Subscription Tab */} {activeTab === 'subscription' && (
- {user.subscription ? ( + {/* Subscription selector (when multiple) */} + {userSubscriptions.length > 1 && ( +
+
+ {t('admin.users.detail.subscription.selectSubscription', 'Выберите подписку')} ( + {userSubscriptions.length}) +
+
+ {userSubscriptions.map((sub) => ( + + ))} +
+
+ )} + + {selectedSub ? ( <> {/* Current subscription */}
{t('admin.users.detail.subscription.current')} + {userSubscriptions.length > 1 && ( + #{selectedSub.id} + )} - +
@@ -1372,7 +1430,7 @@ export default function AdminUserDetail() { {t('admin.users.detail.subscription.tariff')}
- {user.subscription.tariff_name || + {selectedSub.tariff_name || t('admin.users.detail.subscription.notSpecified')}
@@ -1380,7 +1438,7 @@ export default function AdminUserDetail() {
{t('admin.users.detail.subscription.validUntil')}
-
{formatDate(user.subscription.end_date)}
+
{formatDate(selectedSub.end_date)}
@@ -1389,8 +1447,8 @@ export default function AdminUserDetail() {
{panelInfo?.found ? (panelInfo.used_traffic_bytes / (1024 * 1024 * 1024)).toFixed(1) - : user.subscription.traffic_used_gb.toFixed(1)}{' '} - / {user.subscription.traffic_limit_gb} {t('common.units.gb')} + : selectedSub.traffic_used_gb.toFixed(1)}{' '} + / {selectedSub.traffic_limit_gb} {t('common.units.gb')}
@@ -1399,21 +1457,21 @@ export default function AdminUserDetail() {
- {user.subscription.device_limit} + {selectedSub.device_limit}
{/* Traffic Packages */} - {user.subscription.traffic_purchases && - user.subscription.traffic_purchases.length > 0 && ( -
-
- - {t('admin.users.detail.subscription.trafficPackages')} - {user.subscription.purchased_traffic_gb > 0 && ( - - ({user.subscription.purchased_traffic_gb} {t('common.units.gb')}) - - )} - -
-
- {user.subscription.traffic_purchases.map((tp) => ( -
-
-
- - {tp.traffic_gb} {t('common.units.gb')} - - {tp.is_expired ? ( - - {t('admin.users.detail.subscription.expired')} - - ) : ( - - {tp.days_remaining}{' '} - {t('admin.users.detail.subscription.daysLeft')} - - )} -
-
- {!tp.is_expired && ( - - )} -
- ))} -
+ {selectedSub.traffic_purchases && selectedSub.traffic_purchases.length > 0 && ( +
+
+ + {t('admin.users.detail.subscription.trafficPackages')} + {selectedSub.purchased_traffic_gb > 0 && ( + + ({selectedSub.purchased_traffic_gb} {t('common.units.gb')}) + + )} +
- )} +
+ {selectedSub.traffic_purchases.map((tp) => ( +
+
+
+ + {tp.traffic_gb} {t('common.units.gb')} + + {tp.is_expired ? ( + + {t('admin.users.detail.subscription.expired')} + + ) : ( + + {tp.days_remaining}{' '} + {t('admin.users.detail.subscription.daysLeft')} + + )} +
+
+ {!tp.is_expired && ( + + )} +
+ ))} +
+
+ )} {/* Add Traffic */} {currentTariff && @@ -1602,10 +1659,18 @@ export default function AdminUserDetail() {
)} - ) : hasPermission('users:subscription') ? ( + ) : null} + + {/* Create new subscription (shown when no subs, or always in multi-tariff) */} + {hasPermission('users:subscription') && (
-
- {t('admin.users.detail.subscription.noActive')} + {userSubscriptions.length === 0 && ( +
+ {t('admin.users.detail.subscription.noActive')} +
+ )} +
+ {t('admin.users.detail.subscription.createNew', 'Создать подписку')}
Date: Tue, 24 Mar 2026 17:44:58 +0300 Subject: [PATCH 24/36] =?UTF-8?q?redesign:=20admin=20subscription=20tab=20?= =?UTF-8?q?=E2=80=94=20two-level=20navigation=20for=20multi-tariff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Level 1 (subscription list): - Cards with tariff name, status badge, traffic, date, devices - Click to drill into subscription detail - "Создать подписку" with filtered purchased tariffs Level 2 (subscription detail): - Back button to return to list - All actions scoped to selected subscription - No "Сменить тариф" in multi-subscription mode - Panel info, devices, traffic stats per-subscription Single subscription: keeps current behavior (direct detail view) --- src/pages/AdminUserDetail.tsx | 267 +++++++++++++++++++++++----------- 1 file changed, 182 insertions(+), 85 deletions(-) diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index b402ae8..cdda415 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -336,6 +336,7 @@ export default function AdminUserDetail() { const [subDays, setSubDays] = useState(30); const [selectedTariffId, setSelectedTariffId] = useState(null); const [activeSubscriptionId, setActiveSubscriptionId] = useState(null); + const [subscriptionDetailView, setSubscriptionDetailView] = useState(false); // Promo group const [promoGroups, setPromoGroups] = useState([]); @@ -1379,40 +1380,133 @@ export default function AdminUserDetail() { {/* Subscription Tab */} {activeTab === 'subscription' && (
- {/* Subscription selector (when multiple) */} - {userSubscriptions.length > 1 && ( -
-
- {t('admin.users.detail.subscription.selectSubscription', 'Выберите подписку')} ( - {userSubscriptions.length}) -
-
+ {/* Multi-subscription: Level 1 — subscription list */} + {userSubscriptions.length > 1 && !subscriptionDetailView && ( + <> +
{userSubscriptions.map((sub) => ( ))}
-
+ + {/* Create new subscription — at list level */} + {hasPermission('users:subscription') && ( +
+
+ {t('admin.users.detail.subscription.createNew', 'Создать подписку')} +
+
+ + + +
+
+ )} + )} - {selectedSub ? ( + {/* Level 2 — subscription detail (or single subscription) */} + {(subscriptionDetailView || userSubscriptions.length <= 1) && selectedSub ? ( <> + {/* Back to list (multi-subscription) */} + {subscriptionDetailView && userSubscriptions.length > 1 && ( + + )} + {/* Current subscription */}
@@ -1602,9 +1696,11 @@ export default function AdminUserDetail() { - + {userSubscriptions.length <= 1 && ( + + )} @@ -1661,66 +1757,67 @@ export default function AdminUserDetail() { ) : null} - {/* Create new subscription (shown when no subs, or always in multi-tariff) */} - {hasPermission('users:subscription') && ( -
- {userSubscriptions.length === 0 && ( -
- {t('admin.users.detail.subscription.noActive')} + {/* Create new subscription (shown when no subs, or single-tariff mode) */} + {hasPermission('users:subscription') && + !(subscriptionDetailView && userSubscriptions.length > 1) && ( +
+ {userSubscriptions.length === 0 && ( +
+ {t('admin.users.detail.subscription.noActive')} +
+ )} +
+ {t('admin.users.detail.subscription.createNew', 'Создать подписку')} +
+
+ + +
- )} -
- {t('admin.users.detail.subscription.createNew', 'Создать подписку')}
-
- - - -
-
- )} + )} {/* Panel Info */} {panelInfoLoading ? ( From 20f0e44d2df1c86ed79694a638612ba6d3470a43 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 24 Mar 2026 17:53:28 +0300 Subject: [PATCH 25/36] fix: remove duplicate 'Create subscription' block in multi-subscription admin view --- src/pages/AdminUserDetail.tsx | 117 +++++++++++++++++----------------- 1 file changed, 58 insertions(+), 59 deletions(-) diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index cdda415..4026da6 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -1757,67 +1757,66 @@ export default function AdminUserDetail() { ) : null} - {/* Create new subscription (shown when no subs, or single-tariff mode) */} - {hasPermission('users:subscription') && - !(subscriptionDetailView && userSubscriptions.length > 1) && ( -
- {userSubscriptions.length === 0 && ( -
- {t('admin.users.detail.subscription.noActive')} -
- )} -
- {t('admin.users.detail.subscription.createNew', 'Создать подписку')} -
-
- - - + {/* Create new subscription — only for single-sub users or no subs */} + {hasPermission('users:subscription') && userSubscriptions.length <= 1 && ( +
+ {userSubscriptions.length === 0 && ( +
+ {t('admin.users.detail.subscription.noActive')}
+ )} +
+ {t('admin.users.detail.subscription.createNew', 'Создать подписку')}
- )} +
+ + + +
+
+ )} {/* Panel Info */} {panelInfoLoading ? ( From 23edd6a2115e443143c0a7e59a9edabdc071bfd3 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 24 Mar 2026 18:05:42 +0300 Subject: [PATCH 26/36] fix: hide panel info/traffic/devices from subscription list level, show only in detail --- src/pages/AdminUserDetail.tsx | 565 +++++++++++++++++----------------- 1 file changed, 290 insertions(+), 275 deletions(-) diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index 4026da6..c920a43 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -1818,312 +1818,327 @@ export default function AdminUserDetail() {
)} - {/* Panel Info */} - {panelInfoLoading ? ( -
-
-
- ) : panelInfo && !panelInfo.found ? ( -
- {t('admin.users.detail.panelNotFound')} -
- ) : panelInfo && panelInfo.found ? ( + {/* Panel Info, Traffic, Devices — only inside subscription detail */} + {(subscriptionDetailView || userSubscriptions.length <= 1) && ( <> - {/* Links */} - {(panelInfo.subscription_url || panelInfo.happ_link) && ( -
-
- {t('admin.users.detail.subscriptionUrl')} / {t('admin.users.detail.happLink')} -
-
- {panelInfo.subscription_url && ( - - )} - {panelInfo.happ_link && ( - - )} -
+ {panelInfoLoading ? ( +
+
- )} - - {/* Config */} - {(panelInfo.trojan_password || panelInfo.vless_uuid || panelInfo.ss_password) && ( -
-
- {t('admin.users.detail.panelConfig')} -
-
- {panelInfo.trojan_password && ( - - )} - {panelInfo.vless_uuid && ( - - )} - {panelInfo.ss_password && ( - - )} -
+ ) : panelInfo && !panelInfo.found ? ( +
+ {t('admin.users.detail.panelNotFound')}
- )} - - {/* Connection info */} -
-
-
-
- {t('admin.users.detail.firstConnected')} -
-
- {formatDate(panelInfo.first_connected_at)} -
-
-
-
- {t('admin.users.detail.lastOnline')} -
-
{formatDate(panelInfo.online_at)}
-
- {panelInfo.last_connected_node_name && ( -
-
- {t('admin.users.detail.lastNode')} + ) : panelInfo && panelInfo.found ? ( + <> + {/* Links */} + {(panelInfo.subscription_url || panelInfo.happ_link) && ( +
+
+ {t('admin.users.detail.subscriptionUrl')} /{' '} + {t('admin.users.detail.happLink')}
-
- {panelInfo.last_connected_node_name} +
+ {panelInfo.subscription_url && ( + + )} + {panelInfo.happ_link && ( + + )}
)} -
-
- {/* Live traffic */} -
-
- {t('admin.users.detail.liveTraffic')} -
-
-
- - {formatBytes(panelInfo.used_traffic_bytes)} - - - {panelInfo.traffic_limit_bytes > 0 - ? formatBytes(panelInfo.traffic_limit_bytes) - : '∞'} - -
-
-
0 - ? `${Math.min(100, (panelInfo.used_traffic_bytes / panelInfo.traffic_limit_bytes) * 100)}%` - : '0%', - }} - /> -
-
-
- {t('admin.users.detail.lifetime')}:{' '} - {formatBytes(panelInfo.lifetime_used_traffic_bytes)} -
-
+ {/* Config */} + {(panelInfo.trojan_password || + panelInfo.vless_uuid || + panelInfo.ss_password) && ( +
+
+ {t('admin.users.detail.panelConfig')} +
+
+ {panelInfo.trojan_password && ( + + )} + {panelInfo.vless_uuid && ( + + )} + {panelInfo.ss_password && ( + + )} +
+
+ )} - {/* Node usage */} + {/* Connection info */} +
+
+
+
+ {t('admin.users.detail.firstConnected')} +
+
+ {formatDate(panelInfo.first_connected_at)} +
+
+
+
+ {t('admin.users.detail.lastOnline')} +
+
+ {formatDate(panelInfo.online_at)} +
+
+ {panelInfo.last_connected_node_name && ( +
+
+ {t('admin.users.detail.lastNode')} +
+
+ {panelInfo.last_connected_node_name} +
+
+ )} +
+
+ + {/* Live traffic */} +
+
+ {t('admin.users.detail.liveTraffic')} +
+
+
+ + {formatBytes(panelInfo.used_traffic_bytes)} + + + {panelInfo.traffic_limit_bytes > 0 + ? formatBytes(panelInfo.traffic_limit_bytes) + : '∞'} + +
+
+
0 + ? `${Math.min(100, (panelInfo.used_traffic_bytes / panelInfo.traffic_limit_bytes) * 100)}%` + : '0%', + }} + /> +
+
+
+ {t('admin.users.detail.lifetime')}:{' '} + {formatBytes(panelInfo.lifetime_used_traffic_bytes)} +
+
+ + {/* Node usage */} +
+
+ + {t('admin.users.detail.nodeUsage')} + +
+
+ {[1, 3, 7, 14, 30].map((d) => ( + + ))} +
+ +
+
+ {nodeUsageForPeriod.length > 0 ? ( +
+ {nodeUsageForPeriod.map((item) => { + const maxBytes = nodeUsageForPeriod[0].total_bytes; + const pct = maxBytes > 0 ? (item.total_bytes / maxBytes) * 100 : 0; + return ( +
+
+ + {item.country_code && ( + + {getCountryFlag(item.country_code)} + + )} + {item.node_name} + + + {formatBytes(item.total_bytes)} + +
+
+
+
+
+ ); + })} +
+ ) : ( +
-
+ )} +
+ + ) : null} + + {/* Devices */}
- {t('admin.users.detail.nodeUsage')} + {t('admin.users.detail.devices.title')} ({devicesTotal}/{deviceLimit})
-
- {[1, 3, 7, 14, 30].map((d) => ( - - ))} -
+ {devices.length > 0 && ( + + )}
- {nodeUsageForPeriod.length > 0 ? ( + {devicesLoading ? ( +
+
+
+ ) : devices.length > 0 ? (
- {nodeUsageForPeriod.map((item) => { - const maxBytes = nodeUsageForPeriod[0].total_bytes; - const pct = maxBytes > 0 ? (item.total_bytes / maxBytes) * 100 : 0; - return ( -
-
- - {item.country_code && ( - {getCountryFlag(item.country_code)} - )} - {item.node_name} - - {formatBytes(item.total_bytes)} + {devices.map((device) => ( +
+
+
+ {device.platform || device.device_model || device.hwid.slice(0, 12)}
-
-
+
+ {device.device_model && device.platform && ( + {device.device_model} + )} + {device.hwid.slice(0, 8)}... + {device.created_at && ( + + {new Date(device.created_at).toLocaleDateString(locale)} + + )}
- ); - })} + +
+ ))}
) : ( -
-
+
+ {t('admin.users.detail.devices.none')} +
)}
- ) : null} - - {/* Devices */} -
-
- - {t('admin.users.detail.devices.title')} ({devicesTotal}/{deviceLimit}) - -
- - {devices.length > 0 && ( - - )} -
-
- {devicesLoading ? ( -
-
-
- ) : devices.length > 0 ? ( -
- {devices.map((device) => ( -
-
-
- {device.platform || device.device_model || device.hwid.slice(0, 12)} -
-
- {device.device_model && device.platform && ( - {device.device_model} - )} - {device.hwid.slice(0, 8)}... - {device.created_at && ( - {new Date(device.created_at).toLocaleDateString(locale)} - )} -
-
- -
- ))} -
- ) : ( -
- {t('admin.users.detail.devices.none')} -
- )} -
+ )}
)} From debee7729fdbd8b3794656abd6c53c1689e32c0b Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 24 Mar 2026 18:30:59 +0300 Subject: [PATCH 27/36] fix: platform-aware delete confirmation + trial CTA to purchase Delete subscription: - Telegram mini app: native destructive popup (red button) - Web (mobile + desktop): Sheet bottom panel with full-width buttons - Both: proper loading state on delete button Trial CTA: leads to /subscription/purchase (trial cannot be renewed) --- .../subscription/PurchaseCTAButton.tsx | 9 +- src/pages/Subscription.tsx | 92 +++++++++++++------ 2 files changed, 68 insertions(+), 33 deletions(-) diff --git a/src/components/subscription/PurchaseCTAButton.tsx b/src/components/subscription/PurchaseCTAButton.tsx index b5e8c77..1fa7b0f 100644 --- a/src/components/subscription/PurchaseCTAButton.tsx +++ b/src/components/subscription/PurchaseCTAButton.tsx @@ -38,9 +38,12 @@ export default function PurchaseCTAButton({ ? t('subscription.cta.renewHint', 'Продление подписки') : t('subscription.cta.activeHint'); - // In multi-tariff mode: renew goes to per-subscription renew page - const linkTo = - isMultiTariff && subscription?.id + // Trial → purchase page (buy a real tariff, trial can't be renewed) + // Multi-tariff active → per-subscription renew page + // Otherwise → purchase page + const linkTo = isTrial + ? '/subscription/purchase' + : isMultiTariff && subscription?.id ? `/subscriptions/${subscription.id}/renew` : '/subscription/purchase'; diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 55c6642..2a70165 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -5,13 +5,15 @@ import { useNavigate, useParams } from 'react-router'; import { subscriptionApi } from '../api/subscription'; import { WebBackButton } from '../components/WebBackButton'; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '../components/primitives/Dialog'; + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from '../components/primitives/Sheet'; +import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog'; +import { usePlatform } from '../platform'; import TrafficProgressBar from '../components/dashboard/TrafficProgressBar'; import { HoverBorderGradient } from '../components/ui/hover-border-gradient'; import { useTrafficZone } from '../hooks/useTrafficZone'; @@ -183,8 +185,10 @@ export default function Subscription() { const g = getGlassColors(isDark); const haptic = useHaptic(); const [copied, setCopied] = useState(false); - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [showDeleteSheet, setShowDeleteSheet] = useState(false); const [deleteLoading, setDeleteLoading] = useState(false); + const { platform } = usePlatform(); + const destructiveConfirm = useDestructiveConfirm(); // Helper to format price from kopeks const formatPrice = (kopeks: number) => `${formatAmount(kopeks / 100)} ${currencySymbol}`; @@ -1278,8 +1282,33 @@ export default function Subscription() { {isMultiTariff && subscription && !subscription.is_active && !subscription.is_trial && ( <> - - - - {t('subscription.deleteTitle', 'Удалить подписку?')} - + {/* Web: Sheet confirmation */} + + + + {t('subscription.deleteTitle', 'Удалить подписку?')} + {t( 'subscription.deleteWarning', 'Подписка будет удалена безвозвратно. Все данные, устройства и настройки будут потеряны. Это действие нельзя отменить.', )} - - - - + + + - - - + + + + )} From 93fa435ff7075c680cb31bf67839958e176a7ddf Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 24 Mar 2026 18:57:01 +0300 Subject: [PATCH 28/36] =?UTF-8?q?fix:=20constrain=20delete=20Sheet=20width?= =?UTF-8?q?=20on=20desktop=20=E2=80=94=20centered=20max-w-md=20with=20roun?= =?UTF-8?q?ded=20corners?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/Subscription.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 2a70165..09b87ee 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -1330,7 +1330,7 @@ export default function Subscription() { {/* Web: Sheet confirmation */} - + {t('subscription.deleteTitle', 'Удалить подписку?')} From 324aaa1f33475650e8c678b63f4370a2dae8cca2 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 24 Mar 2026 19:11:48 +0300 Subject: [PATCH 29/36] =?UTF-8?q?redesign:=20inline=20delete=20confirmatio?= =?UTF-8?q?n=20on=20web=20=E2=80=94=20expands=20in=20place=20instead=20of?= =?UTF-8?q?=20modal/sheet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/Subscription.tsx | 137 +++++++++++++++++-------------------- 1 file changed, 64 insertions(+), 73 deletions(-) diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 09b87ee..84f8aa4 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -4,14 +4,6 @@ import { useTranslation } from 'react-i18next'; import { useNavigate, useParams } from 'react-router'; import { subscriptionApi } from '../api/subscription'; import { WebBackButton } from '../components/WebBackButton'; -import { - Sheet, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, -} from '../components/primitives/Sheet'; import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog'; import { usePlatform } from '../platform'; import TrafficProgressBar from '../components/dashboard/TrafficProgressBar'; @@ -1280,67 +1272,65 @@ export default function Subscription() { {/* Delete expired subscription */} {isMultiTariff && subscription && !subscription.is_active && !subscription.is_trial && ( - <> - - - {/* Web: Sheet confirmation */} - - - - {t('subscription.deleteTitle', 'Удалить подписку?')} - - {t( - 'subscription.deleteWarning', - 'Подписка будет удалена безвозвратно. Все данные, устройства и настройки будут потеряны. Это действие нельзя отменить.', - )} - - - + + + + {t('subscription.delete', 'Удалить подписку')} + + ) : ( +
+
+ {t('subscription.deleteTitle', 'Удалить подписку?')} +
+
+ {t( + 'subscription.deleteWarning', + 'Подписка будет удалена безвозвратно. Все данные, устройства и настройки будут потеряны. Это действие нельзя отменить.', + )} +
+
- - - - +
+
+ )} +
)} {/* Additional Options (Buy Devices) */} From 4de47cfa949189a1ff967ae522028fc8f843dee4 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 24 Mar 2026 22:18:02 +0300 Subject: [PATCH 30/36] fix: multi-subscription frontend improvements - Connection page: reads subscription_id from ?sub= query param - Subscription detail: passes subscriptionId to /connection URL - Dashboard: skip single-subscription and devices queries in multi-tariff mode (data comes from subscriptions-list instead) --- src/pages/Connection.tsx | 8 +++++--- src/pages/Dashboard.tsx | 23 ++++++++++++----------- src/pages/Subscription.tsx | 2 +- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/pages/Connection.tsx b/src/pages/Connection.tsx index 24fd911..0bd6742 100644 --- a/src/pages/Connection.tsx +++ b/src/pages/Connection.tsx @@ -1,5 +1,5 @@ import { useEffect, useCallback, useMemo, useRef } from 'react'; -import { Link, useNavigate } from 'react-router'; +import { Link, useNavigate, useSearchParams } from 'react-router'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import { openLink as sdkOpenLink } from '@telegram-apps/sdk-react'; @@ -14,6 +14,8 @@ import InstallationGuide from '../components/connection/InstallationGuide'; export default function Connection() { const { t, i18n } = useTranslation(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const subId = searchParams.get('sub') ? Number(searchParams.get('sub')) : undefined; const user = useAuthStore((state) => state.user); const isAdmin = useAuthStore((state) => state.isAdmin); const { isTelegramWebApp } = useTelegramSDK(); @@ -27,8 +29,8 @@ export default function Connection() { isLoading, error, } = useQuery({ - queryKey: ['appConfig'], - queryFn: () => subscriptionApi.getAppConfig(), + queryKey: ['appConfig', subId], + queryFn: () => subscriptionApi.getAppConfig(subId), }); const handleGoBack = useCallback(() => { diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 64a08c0..7cdf4df 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -51,16 +51,6 @@ export default function Dashboard() { refetchOnMount: 'always', }); - const { data: subscriptionResponse, isLoading: subLoading } = useQuery({ - queryKey: ['subscription'], - queryFn: () => subscriptionApi.getSubscription(), - retry: false, - staleTime: API.BALANCE_STALE_TIME_MS, - refetchOnMount: 'always', - }); - - const subscription = subscriptionResponse?.subscription ?? null; - // Multi-tariff: check if user has multiple subscriptions const { data: multiSubData } = useQuery({ queryKey: ['subscriptions-list'], @@ -69,6 +59,17 @@ export default function Dashboard() { }); const isMultiTariff = multiSubData?.multi_tariff_enabled ?? false; + const { data: subscriptionResponse, isLoading: subLoading } = useQuery({ + queryKey: ['subscription'], + queryFn: () => subscriptionApi.getSubscription(), + retry: false, + staleTime: API.BALANCE_STALE_TIME_MS, + refetchOnMount: 'always', + enabled: !isMultiTariff, + }); + + const subscription = subscriptionResponse?.subscription ?? null; + const { data: trialInfo, isLoading: trialLoading } = useQuery({ queryKey: ['trial-info'], queryFn: () => subscriptionApi.getTrialInfo(), @@ -78,7 +79,7 @@ export default function Dashboard() { const { data: devicesData } = useQuery({ queryKey: ['devices'], queryFn: () => subscriptionApi.getDevices(), - enabled: !!subscription, + enabled: !!subscription && !isMultiTariff, staleTime: API.BALANCE_STALE_TIME_MS, }); diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 84f8aa4..f51f94b 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -794,7 +794,7 @@ export default function Subscription() { haptic.notification('error'); return; } - navigate('/connection'); + navigate(subscriptionId ? `/connection?sub=${subscriptionId}` : '/connection'); }} className={`mb-5 flex w-full items-center gap-3.5 rounded-[14px] p-3.5 text-left transition-shadow duration-300${isAtDeviceLimit ? 'cursor-not-allowed opacity-50' : ''}`} style={{ fontFamily: 'inherit' }} From fd01c0f393491576230aff55275a5ef6d8c2ffb8 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Wed, 25 Mar 2026 11:47:31 +0300 Subject: [PATCH 31/36] fix: preserve subscription context in navigation and cache keys SubscriptionCardActive: passes ?sub= to /connection navigation. Connection: QR state includes subscriptionId for back-navigation. ConnectionQR: preserves subscription context on back button. Dashboard + Subscription: localStorage traffic cache keyed per-subscription to prevent cooldown bleed between different subscriptions. --- src/components/dashboard/SubscriptionCardActive.tsx | 2 +- src/pages/Connection.tsx | 3 ++- src/pages/ConnectionQR.tsx | 9 ++++++--- src/pages/Dashboard.tsx | 7 +++++-- src/pages/Subscription.tsx | 9 ++++++--- 5 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/components/dashboard/SubscriptionCardActive.tsx b/src/components/dashboard/SubscriptionCardActive.tsx index 02e2a62..cf2adeb 100644 --- a/src/components/dashboard/SubscriptionCardActive.tsx +++ b/src/components/dashboard/SubscriptionCardActive.tsx @@ -214,7 +214,7 @@ export default function SubscriptionCardActive({ haptic.notification('error'); return; } - navigate('/connection'); + navigate(`/connection?sub=${subscription.id}`); }} className={`mb-2.5 flex w-full items-center gap-3.5 rounded-[14px] p-3.5 text-left transition-shadow duration-300${isAtDeviceLimit ? 'cursor-not-allowed opacity-50' : ''}`} data-onboarding="connect-devices" diff --git a/src/pages/Connection.tsx b/src/pages/Connection.tsx index 0bd6742..d22e5f4 100644 --- a/src/pages/Connection.tsx +++ b/src/pages/Connection.tsx @@ -43,9 +43,10 @@ export default function Connection() { state: { url: appConfig?.subscriptionUrl, hideLink: appConfig?.hideLink ?? false, + subscriptionId: subId, }, }); - }, [navigate, appConfig?.subscriptionUrl, appConfig?.hideLink, isTelegramWebApp]); + }, [navigate, appConfig?.subscriptionUrl, appConfig?.hideLink, isTelegramWebApp, subId]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { diff --git a/src/pages/ConnectionQR.tsx b/src/pages/ConnectionQR.tsx index 5671a2a..f6ae3e4 100644 --- a/src/pages/ConnectionQR.tsx +++ b/src/pages/ConnectionQR.tsx @@ -8,6 +8,7 @@ import { AdminBackButton } from '@/components/admin'; interface ConnectionQRState { url: string; hideLink: boolean; + subscriptionId?: number; } function isValidState(state: unknown): state is ConnectionQRState { @@ -24,12 +25,14 @@ export default function ConnectionQR() { const state = location.state as unknown; const validState = isValidState(state) ? state : null; + const subId = validState?.subscriptionId; + const connectionPath = subId ? `/connection?sub=${subId}` : '/connection'; useEffect(() => { if (!validState) { - navigate('/connection', { replace: true }); + navigate(connectionPath, { replace: true }); } - }, [validState, navigate]); + }, [validState, navigate, connectionPath]); if (!validState) { return null; @@ -38,7 +41,7 @@ export default function ConnectionQR() { return (
- +

{t('subscription.connection.qrTitle')}

diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 7cdf4df..33feec3 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -141,7 +141,10 @@ export default function Dashboard() { traffic_used_percent: data.traffic_used_percent, is_unlimited: data.is_unlimited, }); - localStorage.setItem('traffic_refresh_ts', Date.now().toString()); + localStorage.setItem( + `traffic_refresh_ts_${subscription?.id ?? 'default'}`, + Date.now().toString(), + ); if (data.rate_limited && data.retry_after_seconds) { setTrafficRefreshCooldown(data.retry_after_seconds); } else { @@ -176,7 +179,7 @@ export default function Dashboard() { if (hasAutoRefreshed.current) return; hasAutoRefreshed.current = true; - const lastRefresh = localStorage.getItem('traffic_refresh_ts'); + const lastRefresh = localStorage.getItem(`traffic_refresh_ts_${subscription?.id ?? 'default'}`); const now = Date.now(); const cacheMs = API.TRAFFIC_CACHE_MS; diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index f51f94b..5129da1 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -391,7 +391,10 @@ export default function Subscription() { traffic_used_percent: data.traffic_used_percent, is_unlimited: data.is_unlimited, }); - localStorage.setItem('traffic_refresh_ts', Date.now().toString()); + localStorage.setItem( + `traffic_refresh_ts_${subscriptionId ?? 'default'}`, + Date.now().toString(), + ); if (data.rate_limited && data.retry_after_seconds) { setTrafficRefreshCooldown(data.retry_after_seconds); } else { @@ -427,7 +430,7 @@ export default function Subscription() { if (hasAutoRefreshed.current) return; hasAutoRefreshed.current = true; - const lastRefresh = localStorage.getItem('traffic_refresh_ts'); + const lastRefresh = localStorage.getItem(`traffic_refresh_ts_${subscriptionId ?? 'default'}`); const now = Date.now(); const cacheMs = 30 * 1000; @@ -441,7 +444,7 @@ export default function Subscription() { } refreshTrafficMutation.mutate(); - }, [subscription, refreshTrafficMutation]); + }, [subscription, refreshTrafficMutation, subscriptionId]); const copyUrl = () => { if (subscription?.subscription_url) { From e86b214008ce378e26f250a0601aae2002b8dbf9 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Wed, 25 Mar 2026 11:47:41 +0300 Subject: [PATCH 32/36] fix: resolve eslint warnings in NewsSection and AdminTickets NewsSection: wrap items in useMemo to stabilize dependency reference. AdminTickets: copy ref values to local vars for cleanup function. --- src/components/news/NewsSection.tsx | 2 +- src/pages/AdminTickets.tsx | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/news/NewsSection.tsx b/src/components/news/NewsSection.tsx index 0c99707..0ea43ae 100644 --- a/src/components/news/NewsSection.tsx +++ b/src/components/news/NewsSection.tsx @@ -369,7 +369,7 @@ export default function NewsSection() { gcTime: 10 * 60_000, }); - const items = data?.items ?? []; + const items = useMemo(() => data?.items ?? [], [data?.items]); const total = data?.total ?? 0; const categories = data?.categories ?? []; diff --git a/src/pages/AdminTickets.tsx b/src/pages/AdminTickets.tsx index 4253912..5ae505f 100644 --- a/src/pages/AdminTickets.tsx +++ b/src/pages/AdminTickets.tsx @@ -181,10 +181,12 @@ export default function AdminTickets() { // Cancel in-flight uploads and cleanup blob URL on unmount useEffect(() => { + const uploadRef = uploadIdRef; + const prevRef = previewRef; return () => { - uploadIdRef.current++; - if (previewRef.current) { - URL.revokeObjectURL(previewRef.current); + uploadRef.current++; + if (prevRef.current) { + URL.revokeObjectURL(prevRef.current); } }; }, []); From 16ccf78c95f813747051e79ac13041e4a81879bc Mon Sep 17 00:00:00 2001 From: c0mrade Date: Wed, 25 Mar 2026 16:49:45 +0300 Subject: [PATCH 33/36] fix: show warning hint when subscription must be selected before wheel spin When user has 2+ eligible subscriptions and chooses subscription_days payment, a visible warning hint now appears explaining they need to select a subscription before spinning. --- src/pages/Wheel.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/pages/Wheel.tsx b/src/pages/Wheel.tsx index 979d141..eb4b8e5 100644 --- a/src/pages/Wheel.tsx +++ b/src/pages/Wheel.tsx @@ -690,6 +690,14 @@ export default function Wheel() {

{t('wheel.errors.dailyLimitReached')}

)} + {/* Subscription selection required hint */} + {!isSpinning && needsSubscriptionPick && ( +
+

+ {t('wheel.errors.selectSubscription', 'Выберите подписку для списания дней')} +

+
+ )} {/* Inline Result Card */} {spinResult && !isSpinning && ( From 29003a6cbe91d4702dbb7fee90293653ad15bbe1 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Wed, 25 Mar 2026 16:53:32 +0300 Subject: [PATCH 34/36] fix: make buy-another-tariff button more prominent on dashboard Changed from faded dashed border (opacity-50, text-xs) to accent-colored background with larger text for better visibility in multi-tariff mode. --- src/pages/Dashboard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 33feec3..5fa39d9 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -309,9 +309,9 @@ export default function Dashboard() { )} - + {t('subscriptions.buyAnother', 'Купить ещё тариф')} + + {t('subscriptions.buyAnother', 'Купить ещё тариф')}
)} From 98e9cfadc6785bf7e713028d2d2c0ab262939d7f Mon Sep 17 00:00:00 2001 From: c0mrade Date: Wed, 25 Mar 2026 18:11:30 +0300 Subject: [PATCH 35/36] fix: use declarative Navigate instead of navigate() in render + fix useEffect deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subscriptions.tsx: navigate() in render → component (React safe). Subscription.tsx: same fix for multi-tariff redirect. AdminUserDetail.tsx: useRef guard for one-time auto-select instead of activeSubscriptionId in dependency array (prevented manual selection). --- src/pages/AdminUserDetail.tsx | 8 +++++--- src/pages/Subscription.tsx | 5 ++--- src/pages/Subscriptions.tsx | 5 ++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index c920a43..12bbab9 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -336,6 +336,7 @@ export default function AdminUserDetail() { const [subDays, setSubDays] = useState(30); const [selectedTariffId, setSelectedTariffId] = useState(null); const [activeSubscriptionId, setActiveSubscriptionId] = useState(null); + const hasAutoSelectedSub = useRef(false); const [subscriptionDetailView, setSubscriptionDetailView] = useState(false); // Promo group @@ -782,13 +783,14 @@ export default function AdminUserDetail() { const selectedSub = userSubscriptions.find((s) => s.id === activeSubscriptionId) ?? user?.subscription ?? null; - // Auto-select first subscription when user loads + // Auto-select first subscription when user loads (one-time init) useEffect(() => { - if (user && userSubscriptions.length > 0 && !activeSubscriptionId) { + if (user && userSubscriptions.length > 0 && !hasAutoSelectedSub.current) { const activeSub = userSubscriptions.find((s) => s.is_active) ?? userSubscriptions[0]; setActiveSubscriptionId(activeSub.id); + hasAutoSelectedSub.current = true; } - }, [user, userSubscriptions, activeSubscriptionId]); + }, [user, userSubscriptions]); const currentTariff = tariffs.find((t) => t.id === selectedSub?.tariff_id) || null; diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 5129da1..8c1871f 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, useParams } from 'react-router'; +import { Navigate, useNavigate, useParams } from 'react-router'; import { subscriptionApi } from '../api/subscription'; import { WebBackButton } from '../components/WebBackButton'; import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog'; @@ -456,8 +456,7 @@ export default function Subscription() { // In multi-tariff mode without a specific subscription ID, redirect to list if (isMultiTariff && !subscriptionId && !isLoading) { - navigate('/subscriptions', { replace: true }); - return null; + return ; } if (isLoading) { diff --git a/src/pages/Subscriptions.tsx b/src/pages/Subscriptions.tsx index 5511b2c..1b2e600 100644 --- a/src/pages/Subscriptions.tsx +++ b/src/pages/Subscriptions.tsx @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import { useNavigate } from 'react-router'; +import { Navigate, useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; import { subscriptionApi } from '../api/subscription'; import { useTheme } from '../hooks/useTheme'; @@ -68,8 +68,7 @@ export default function Subscriptions() { // Single-tariff mode with one subscription: skip list, go directly to detail if (data && !isMultiTariff && subscriptions.length === 1) { - navigate(`/subscriptions/${subscriptions[0].id}`, { replace: true }); - return null; + return ; } return ( From 45b7c857fcdd5a395bac68e88ff5402dcff5e9d8 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Wed, 25 Mar 2026 18:40:50 +0300 Subject: [PATCH 36/36] fix: back button goes to dashboard in single-tariff to prevent redirect loop In single-tariff mode, Subscriptions list auto-redirects to detail page. Back button from detail now goes to / (dashboard) instead of /subscriptions, breaking the redirect loop. --- src/pages/Subscription.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 8c1871f..b3e5664 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -491,7 +491,7 @@ export default function Subscription() {
{/* Page title */}
- +

{isMultiTariff && subscription?.tariff_name ? subscription.tariff_name