- {user.subscription ? (
+ {/* Subscription selector (when multiple) */}
+ {userSubscriptions.length > 1 && (
+
+ )}
+
+ {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', 'Создать подписку')}
- ) : (
-
-
- {t('admin.users.detail.subscription.noActive')}
-
-
)}
{/* Panel Info */}
diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx
index b04c140..64a08c0 100644
--- a/src/pages/Dashboard.tsx
+++ b/src/pages/Dashboard.tsx
@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Link } from 'react-router';
+import { Link, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/auth';
import { useBlockingStore } from '../store/blocking';
@@ -18,6 +18,7 @@ import StatsGrid from '../components/dashboard/StatsGrid';
import { giftApi } from '../api/gift';
import { promoApi } from '../api/promo';
import PendingGiftCard from '../components/dashboard/PendingGiftCard';
+import SubscriptionListCard from '../components/subscription/SubscriptionListCard';
import { API } from '../config/constants';
const ChevronRightIcon = () => (
@@ -28,6 +29,7 @@ const ChevronRightIcon = () => (
export default function Dashboard() {
const { t } = useTranslation();
+ const navigate = useNavigate();
const user = useAuthStore((state) => state.user);
const refreshUser = useAuthStore((state) => state.refreshUser);
const queryClient = useQueryClient();
@@ -111,6 +113,7 @@ export default function Dashboard() {
onSuccess: () => {
setTrialError(null);
queryClient.invalidateQueries({ queryKey: ['subscription'] });
+ queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
queryClient.invalidateQueries({ queryKey: ['trial-info'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
@@ -130,7 +133,7 @@ export default function Dashboard() {
} | null>(null);
const refreshTrafficMutation = useMutation({
- mutationFn: () => subscriptionApi.refreshTraffic(),
+ mutationFn: () => subscriptionApi.refreshTraffic(subscription?.id),
onSuccess: (data) => {
setTrafficData({
traffic_used_gb: data.traffic_used_gb,
@@ -143,7 +146,7 @@ export default function Dashboard() {
} else {
setTrafficRefreshCooldown(30);
}
- queryClient.invalidateQueries({ queryKey: ['subscription'] });
+ queryClient.invalidateQueries({ queryKey: ['subscription', subscription?.id] });
},
onError: (error: {
response?: { status?: number; headers?: { get?: (key: string) => string } };
@@ -274,7 +277,7 @@ export default function Dashboard() {
{/* Pending Gift Activations */}
{pendingGifts && pendingGifts.length > 0 &&
}
- {/* Multi-tariff: show mini-cards for each subscription */}
+ {/* Multi-tariff: show subscription cards (max 3) */}
{isMultiTariff && multiSubData?.subscriptions && (
@@ -285,42 +288,21 @@ export default function Dashboard() {
{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}
-
-
-
-
- );
- })}
+ {multiSubData.subscriptions.slice(0, 3).map((sub) => (
+
navigate(`/subscriptions/${sub.id}`)}
+ />
+ ))}
+ {multiSubData.subscriptions.length > 3 && (
+
+ {t('dashboard.showAll', 'Показать все')} ({multiSubData.subscriptions.length})
+
+ )}
navigate('/subscription')}
+ onClick={() => navigate('/subscriptions')}
className="w-full py-2 text-sm text-dark-500 transition-colors hover:text-dark-300"
>
{t('deepLink.backToCabinet')}
@@ -353,7 +353,7 @@ export default function DeepLinkRedirect() {
{t('deepLink.errorTitle')}
{t('deepLink.errorDesc')}
-
diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx
index 742eb7f..4347dae 100644
--- a/src/pages/Subscription.tsx
+++ b/src/pages/Subscription.tsx
@@ -196,6 +196,14 @@ export default function Subscription() {
is_unlimited: boolean;
} | null>(null);
+ // Detect multi-tariff mode from cached subscriptions-list
+ const { data: multiSubData } = useQuery({
+ queryKey: ['subscriptions-list'],
+ queryFn: () => subscriptionApi.getSubscriptions(),
+ staleTime: 60_000,
+ });
+ const isMultiTariff = multiSubData?.multi_tariff_enabled ?? false;
+
const { data: subscriptionResponse, isLoading } = useQuery({
queryKey: ['subscription', subscriptionId],
queryFn: () => subscriptionApi.getSubscription(subscriptionId),
@@ -376,7 +384,7 @@ export default function Subscription() {
} else {
setTrafficRefreshCooldown(30);
}
- queryClient.invalidateQueries({ queryKey: ['subscription'] });
+ queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
},
onError: (error: {
response?: { status?: number; headers?: { get?: (key: string) => string } };
@@ -430,6 +438,12 @@ 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;
+ }
+
if (isLoading) {
return (
@@ -438,9 +452,56 @@ export default function Subscription() {
);
}
+ if (!subscription && subscriptionId) {
+ return (
+
+
😕
+
+ {t('subscription.notFound', 'Подписка не найдена')}
+
+
+ {t('subscription.notFoundDesc', 'Возможно, подписка была удалена или не существует')}
+
+
navigate('/subscriptions')}
+ className="rounded-xl bg-accent-500 px-6 py-2.5 text-sm font-medium text-white"
+ >
+ {t('subscription.backToList', 'Мои подписки')}
+
+
+ );
+ }
+
return (
-
{t('subscription.title')}
+ {/* Multi-tariff: back navigation + tariff name */}
+ {isMultiTariff && subscriptionId ? (
+
+
navigate('/subscriptions')}
+ className="mb-2 flex items-center gap-1.5 text-sm text-dark-50/50 transition-colors hover:text-dark-50/80"
+ >
+
+ {t('subscription.backToList', 'Мои подписки')}
+
+
+ {subscription?.tariff_name || t('subscription.title')}
+
+
+ ) : (
+
{t('subscription.title')}
+ )}
{/* Current Subscription */}
{subscription ? (
diff --git a/src/pages/SubscriptionPurchase.tsx b/src/pages/SubscriptionPurchase.tsx
index 6655aaa..9d7987e 100644
--- a/src/pages/SubscriptionPurchase.tsx
+++ b/src/pages/SubscriptionPurchase.tsx
@@ -40,7 +40,7 @@ export default function SubscriptionPurchase() {
// Subscription query (shares cache with /subscription page)
const { data: subscriptionResponse, isLoading } = useQuery({
- queryKey: ['subscription'],
+ queryKey: ['subscription', subscriptionId],
queryFn: () => subscriptionApi.getSubscription(subscriptionId),
retry: false,
staleTime: 0,
@@ -55,7 +55,7 @@ export default function SubscriptionPurchase() {
isError: optionsError,
refetch: refetchOptions,
} = useQuery({
- queryKey: ['purchase-options'],
+ queryKey: ['purchase-options', subscriptionId],
queryFn: () => subscriptionApi.getPurchaseOptions(subscriptionId),
staleTime: 0,
refetchOnMount: 'always',
@@ -237,9 +237,9 @@ export default function SubscriptionPurchase() {
const purchaseMutation = useMutation({
mutationFn: () => subscriptionApi.submitPurchase(currentSelection, subscriptionId),
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['subscription'] });
- queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
- navigate('/subscription', { replace: true });
+ queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
+ queryClient.invalidateQueries({ queryKey: ['purchase-options', subscriptionId] });
+ navigate('/subscriptions', { replace: true });
},
});
@@ -254,11 +254,11 @@ export default function SubscriptionPurchase() {
const switchTariffMutation = useMutation({
mutationFn: (tariffId: number) => subscriptionApi.switchTariff(tariffId, subscriptionId),
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['subscription'] });
- queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
+ queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
+ queryClient.invalidateQueries({ queryKey: ['purchase-options', subscriptionId] });
setSwitchTariffId(null);
- navigate('/subscription', { replace: true });
+ navigate('/subscriptions', { replace: true });
},
onError: (error: unknown) => {
if (error instanceof AxiosError) {
@@ -275,7 +275,7 @@ export default function SubscriptionPurchase() {
setSelectedTariff(targetTariff);
setSelectedTariffPeriod(targetTariff.periods[0] || null);
setShowTariffPurchase(true);
- queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
+ queryClient.invalidateQueries({ queryKey: ['purchase-options', subscriptionId] });
}
}
}
@@ -303,7 +303,8 @@ export default function SubscriptionPurchase() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
- navigate('/subscription', { replace: true });
+ queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
+ navigate('/subscriptions', { replace: true });
},
});
@@ -436,7 +437,7 @@ export default function SubscriptionPurchase() {
{/* Header with back link */}
navigate('/subscription')}
+ onClick={() => navigate('/subscriptions')}
aria-label="Back"
className="flex h-9 w-9 items-center justify-center rounded-xl transition-colors"
style={{
diff --git a/src/pages/Subscriptions.tsx b/src/pages/Subscriptions.tsx
index 38aa285..228766e 100644
--- a/src/pages/Subscriptions.tsx
+++ b/src/pages/Subscriptions.tsx
@@ -2,116 +2,22 @@ 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 { isDark } = useTheme();
- const g = getGlassColors(isDark);
- const { impact } = useHaptic();
-
- const handleClick = () => {
- impact('light');
- onClick();
- };
-
- return (
-
-
-
-
- {subscription.tariff_name || 'Подписка'}
-
-
-
-
-
-
-
Трафик
-
- {formatTrafficDisplay(subscription.traffic_used_gb, subscription.traffic_limit_gb)}
-
-
-
-
Устройства
-
{subscription.device_limit}
-
-
-
До
-
{formatDate(subscription.end_date)}
-
-
-
- {subscription.is_trial && (
-
-
- Тестовая
-
-
- )}
-
- );
-}
+import SubscriptionListCard from '../components/subscription/SubscriptionListCard';
function EmptyState({ onBuy }: { onBuy: () => void }) {
+ const { t } = useTranslation();
return (
📋
-
Нет подписок
-
У вас пока нет активных подписок
+
{t('subscriptions.empty', 'Нет подписок')}
+
+ {t('subscriptions.emptyDesc', 'У вас пока нет активных подписок')}
+
- Купить подписку
+ {t('subscriptions.buy', 'Купить подписку')}
);
@@ -128,14 +34,15 @@ export default function Subscriptions() {
refetchOnMount: 'always',
});
- // If multi_tariff not enabled, redirect to legacy subscription page
- if (data && !data.multi_tariff_enabled) {
- navigate('/subscription', { replace: true });
+ const subscriptions = data?.subscriptions ?? [];
+ const isMultiTariff = data?.multi_tariff_enabled ?? false;
+
+ // 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;
}
- const subscriptions = data?.subscriptions ?? [];
-
return (
{t('subscriptions.title', 'Мои подписки')}
@@ -153,10 +60,10 @@ export default function Subscriptions() {
)}
{subscriptions.map((sub) => (
- navigate(`/subscription/${sub.id}`)}
+ onClick={() => navigate(`/subscriptions/${sub.id}`)}
/>
))}
diff --git a/src/pages/TopUpResult.tsx b/src/pages/TopUpResult.tsx
index 783a308..63c6c7f 100644
--- a/src/pages/TopUpResult.tsx
+++ b/src/pages/TopUpResult.tsx
@@ -305,7 +305,10 @@ export default function TopUpResult() {
clearTopUpPendingInfo();
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['transactions'] });
- queryClient.invalidateQueries({ queryKey: ['subscription'] });
+ queryClient.invalidateQueries({
+ predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription',
+ });
+ queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
refreshUser();
} else if (resolvedFailed) {
diff --git a/src/providers/WebSocketContext.ts b/src/providers/WebSocketContext.ts
index edee8f2..6c4cbb1 100644
--- a/src/providers/WebSocketContext.ts
+++ b/src/providers/WebSocketContext.ts
@@ -16,6 +16,7 @@ export interface WSMessage {
new_balance_rubles?: number;
description?: string;
// Subscription events
+ subscription_id?: number;
expires_at?: string;
new_expires_at?: string;
tariff_name?: string;