From f4de6d8ad843e5a1d47f5ae4ddaa0bc99d4ee5f2 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Mon, 23 Mar 2026 11:32:03 +0300 Subject: [PATCH] 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', 'Создать подписку')}