@@ -435,9 +467,37 @@ export default function Subscription() {
);
}
+ if (!subscription && subscriptionId) {
+ return (
+
-
{t('subscription.title')}
+ {/* Page title */}
+
+
+
+ {isMultiTariff && subscription?.tariff_name
+ ? subscription.tariff_name
+ : t('subscription.title')}
+
+
{/* Current Subscription */}
{subscription ? (
@@ -736,7 +796,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' }}
@@ -1210,7 +1270,100 @@ export default function Subscription() {
)}
{/* Purchase / Renewal CTA */}
-
+
+
+ {/* Delete expired subscription */}
+ {isMultiTariff && subscription && !subscription.is_active && !subscription.is_trial && (
+
+ {!showDeleteSheet ? (
+
{
+ if (platform === 'telegram') {
+ const confirmed = await destructiveConfirm(
+ t(
+ 'subscription.deleteWarning',
+ 'Подписка будет удалена безвозвратно. Все данные, устройства и настройки будут потеряны.',
+ ),
+ t('subscription.confirmDelete', 'Да, удалить'),
+ t('subscription.deleteTitle', 'Удалить подписку?'),
+ );
+ if (!confirmed) return;
+ setDeleteLoading(true);
+ try {
+ await subscriptionApi.deleteSubscription(subscription.id);
+ queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
+ navigate('/subscriptions', { replace: true });
+ } catch {
+ setDeleteLoading(false);
+ }
+ } else {
+ setShowDeleteSheet(true);
+ }
+ }}
+ disabled={deleteLoading}
+ className="flex w-full items-center justify-center gap-2 rounded-2xl border border-red-400/20 bg-red-400/5 p-3.5 text-sm font-medium text-red-400 transition-colors hover:bg-red-400/10 disabled:opacity-50"
+ >
+
+
+
+ {t('subscription.delete', 'Удалить подписку')}
+
+ ) : (
+
+
+ {t('subscription.deleteTitle', 'Удалить подписку?')}
+
+
+ {t(
+ 'subscription.deleteWarning',
+ 'Подписка будет удалена безвозвратно. Все данные, устройства и настройки будут потеряны. Это действие нельзя отменить.',
+ )}
+
+
+ {
+ setDeleteLoading(true);
+ try {
+ await subscriptionApi.deleteSubscription(subscription.id);
+ queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
+ navigate('/subscriptions', { replace: true });
+ } catch {
+ setDeleteLoading(false);
+ setShowDeleteSheet(false);
+ }
+ }}
+ disabled={deleteLoading}
+ className="flex-1 rounded-xl bg-red-500 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-red-600 disabled:opacity-50"
+ >
+ {deleteLoading
+ ? t('common.processing', 'Удаление...')
+ : t('subscription.confirmDelete', 'Да, удалить')}
+
+ setShowDeleteSheet(false)}
+ className="flex-1 rounded-xl border border-dark-700 py-2.5 text-sm font-medium transition-colors hover:bg-dark-700"
+ style={{ color: g.textSecondary }}
+ >
+ {t('common.cancel', 'Отмена')}
+
+
+
+ )}
+
+ )}
{/* Additional Options (Buy Devices) */}
{subscription &&
@@ -1381,7 +1534,7 @@ export default function Subscription() {
}
compact
onBeforeTopUp={async () => {
- await subscriptionApi.saveDevicesCart(devicesToAdd);
+ await subscriptionApi.saveDevicesCart(devicesToAdd, subscriptionId);
}}
/>
)}
@@ -1721,7 +1874,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..97e8815 100644
--- a/src/pages/SubscriptionPurchase.tsx
+++ b/src/pages/SubscriptionPurchase.tsx
@@ -1,10 +1,11 @@
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';
+import { WebBackButton } from '../components/WebBackButton';
import { getGlassColors } from '../utils/glassTheme';
import { useTheme } from '../hooks/useTheme';
import type {
@@ -28,6 +29,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);
@@ -36,8 +41,8 @@ export default function SubscriptionPurchase() {
// Subscription query (shares cache with /subscription page)
const { data: subscriptionResponse, isLoading } = useQuery({
- queryKey: ['subscription'],
- queryFn: subscriptionApi.getSubscription,
+ queryKey: ['subscription', subscriptionId],
+ queryFn: () => subscriptionApi.getSubscription(subscriptionId),
retry: false,
staleTime: 0,
refetchOnMount: 'always',
@@ -51,8 +56,8 @@ export default function SubscriptionPurchase() {
isError: optionsError,
refetch: refetchOptions,
} = useQuery({
- queryKey: ['purchase-options'],
- queryFn: subscriptionApi.getPurchaseOptions,
+ queryKey: ['purchase-options', subscriptionId],
+ queryFn: () => subscriptionApi.getPurchaseOptions(subscriptionId),
staleTime: 0,
refetchOnMount: 'always',
});
@@ -70,6 +75,14 @@ export default function SubscriptionPurchase() {
const tariffs =
isTariffsMode && purchaseOptions && 'tariffs' in purchaseOptions ? purchaseOptions.tariffs : [];
+ // Multi-tariff: check via subscriptions list query
+ const { data: multiSubData } = useQuery({
+ queryKey: ['subscriptions-list'],
+ queryFn: () => subscriptionApi.getSubscriptions(),
+ staleTime: 60_000,
+ });
+ const isMultiTariff = multiSubData?.multi_tariff_enabled ?? false;
+
// Helper to apply promo discount
const applyPromoDiscount = (
priceKopeks: number,
@@ -217,36 +230,36 @@ 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'] });
- navigate('/subscription', { replace: true });
+ queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
+ queryClient.invalidateQueries({ queryKey: ['purchase-options', subscriptionId] });
+ navigate('/subscriptions', { replace: true });
},
});
// 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'] });
+ 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) {
@@ -263,7 +276,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] });
}
}
}
@@ -291,7 +304,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 });
},
});
@@ -370,34 +384,7 @@ export default function SubscriptionPurchase() {
if (optionsError || (!purchaseOptions && !optionsLoading)) {
return (
-
-
navigate('/subscription')}
- aria-label="Back"
- className="flex h-9 w-9 items-center justify-center rounded-xl transition-colors"
- style={{
- background: g.innerBg,
- border: `1px solid ${g.innerBorder}`,
- }}
- >
-
-
-
-
-
- {t('subscription.extend')}
-
-
+
{t('subscription.extend')}
- {/* Header with back link */}
+ {/* Header */}
-
navigate('/subscription')}
- aria-label="Back"
- className="flex h-9 w-9 items-center justify-center rounded-xl transition-colors"
- style={{
- background: g.innerBg,
- border: `1px solid ${g.innerBorder}`,
- }}
- >
-
-
-
-
+
- {subscription?.is_daily && !subscription?.is_trial
- ? t('subscription.switchTariff.title')
- : subscription && !subscription.is_trial
- ? t('subscription.extend')
- : t('subscription.getSubscription')}
+ {isMultiTariff && !subscriptionId
+ ? t('subscription.newTariff', 'Новый тариф')
+ : !isMultiTariff && subscription?.is_daily && !subscription?.is_trial
+ ? t('subscription.switchTariff.title')
+ : subscription && !subscription.is_trial
+ ? t('subscription.extend')
+ : t('subscription.getSubscription')}
@@ -748,9 +717,37 @@ export default function SubscriptionPurchase() {
)}
{/* Tariff Grid */}
+ {isMultiTariff &&
+ purchaseOptions &&
+ 'all_tariffs_purchased' in purchaseOptions &&
+ purchaseOptions.all_tariffs_purchased && (
+
+
✅
+
+ {t('subscription.allTariffsPurchased', 'Все тарифы подключены')}
+
+
+ {t(
+ 'subscription.allTariffsPurchasedDesc',
+ 'Вы уже приобрели все доступные тарифы. Продлить подписку можно на странице тарифа.',
+ )}
+
+
navigate('/subscriptions')}
+ className="rounded-xl bg-accent-500 px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-600"
+ >
+ {t('subscription.backToList', 'Мои подписки')}
+
+
+ )}
{[...tariffs]
.filter((tariff) => {
+ // In multi-tariff mode: hide already purchased tariffs
+ if (isMultiTariff && tariff.is_purchased) return false;
if (subscription?.is_trial && tariff.name.toLowerCase().includes('trial')) {
return false;
}
@@ -772,6 +769,7 @@ export default function SubscriptionPurchase() {
'subscription_is_expired' in purchaseOptions &&
purchaseOptions.subscription_is_expired === true;
const canSwitch =
+ !isMultiTariff &&
subscription &&
subscription.tariff_id &&
!isCurrentTariff &&
diff --git a/src/pages/Subscriptions.tsx b/src/pages/Subscriptions.tsx
new file mode 100644
index 0000000..1b2e600
--- /dev/null
+++ b/src/pages/Subscriptions.tsx
@@ -0,0 +1,137 @@
+import { useQuery } from '@tanstack/react-query';
+import { Navigate, 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 SubscriptionListCard from '../components/subscription/SubscriptionListCard';
+
+function EmptyState({ onBuy }: { onBuy: () => void }) {
+ const { t } = useTranslation();
+ const { isDark } = useTheme();
+ const g = getGlassColors(isDark);
+
+ return (
+
+
+
+ {t('subscriptions.empty', 'Нет подписок')}
+
+
+ {t('subscriptions.emptyDesc', 'У вас пока нет активных подписок')}
+
+
+ {t('subscriptions.buy', 'Купить подписку')}
+
+
+ );
+}
+
+export default function Subscriptions() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const { isDark } = useTheme();
+ const g = getGlassColors(isDark);
+
+ const { data, isLoading } = useQuery({
+ queryKey: ['subscriptions-list'],
+ queryFn: () => subscriptionApi.getSubscriptions(),
+ staleTime: 30_000,
+ refetchOnMount: 'always',
+ });
+
+ 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) {
+ return
;
+ }
+
+ return (
+
+ {/* Header */}
+
+
+ {t('subscriptions.title', 'Мои подписки')}
+
+ {!isLoading && subscriptions.length > 0 && (
+
navigate('/subscription/purchase')}
+ className="flex items-center gap-1.5 rounded-xl px-4 py-2 text-sm font-medium transition-colors"
+ style={{
+ background: 'rgba(var(--color-accent-400), 0.1)',
+ color: 'rgb(var(--color-accent-400))',
+ border: '1px solid rgba(var(--color-accent-400), 0.2)',
+ }}
+ >
+
+
+
+ {t('subscriptions.buyAnother', 'Новый тариф')}
+
+ )}
+
+
+ {/* Loading */}
+ {isLoading && (
+
+ {[1, 2].map((i) => (
+
+ ))}
+
+ )}
+
+ {/* Empty state */}
+ {!isLoading && subscriptions.length === 0 && (
+
navigate('/subscription/purchase')} />
+ )}
+
+ {/* Subscription grid */}
+ {subscriptions.length > 0 && (
+
+ {subscriptions.map((sub) => (
+ 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/pages/Wheel.tsx b/src/pages/Wheel.tsx
index 737b05b..eb4b8e5 100644
--- a/src/pages/Wheel.tsx
+++ b/src/pages/Wheel.tsx
@@ -107,6 +107,7 @@ export default function Wheel() {
const [isPayingStars, setIsPayingStars] = useState(false);
const [historyExpanded, setHistoryExpanded] = useState(false);
const [showStarsConfirm, setShowStarsConfirm] = useState(false);
+ const [selectedSubscriptionId, setSelectedSubscriptionId] = useState
(null);
const paymentTypeInitialized = useRef(false);
const {
@@ -136,6 +137,11 @@ export default function Wheel() {
} else if (daysEnabled) {
setPaymentType('subscription_days');
}
+
+ // Auto-select subscription if only one eligible
+ if (config.eligible_subscriptions?.length === 1) {
+ setSelectedSubscriptionId(config.eligible_subscriptions[0].id);
+ }
}, [config]);
// Function to poll for new spin result after Stars payment
@@ -368,7 +374,7 @@ export default function Wheel() {
};
const spinMutation = useMutation({
- mutationFn: () => wheelApi.spin(paymentType),
+ mutationFn: () => wheelApi.spin(paymentType, selectedSubscriptionId ?? undefined),
onSuccess: (result) => {
if (result.success) {
setTargetRotation(result.rotation_degrees);
@@ -506,11 +512,18 @@ export default function Wheel() {
// Stars via Telegram invoice don't require ruble balance, so only check daily limit
const dailyLimitReached = config.daily_limit > 0 && config.user_spins_today >= config.daily_limit;
const noSubscription = !config.has_subscription;
+ const needsSubscriptionPick =
+ paymentType === 'subscription_days' &&
+ config.eligible_subscriptions &&
+ config.eligible_subscriptions.length > 1 &&
+ !selectedSubscriptionId;
+
const spinDisabled =
isSpinning ||
isPayingStars ||
dailyLimitReached ||
noSubscription ||
+ needsSubscriptionPick ||
(paymentType === 'telegram_stars' ? !starsEnabled : !config.can_spin);
return (
@@ -582,6 +595,38 @@ export default function Wheel() {
)}
+ {/* Subscription selector for days payment in multi-tariff */}
+ {paymentType === 'subscription_days' &&
+ config.eligible_subscriptions &&
+ config.eligible_subscriptions.length > 1 && (
+
+
+ {t('wheel.selectSubscription', 'Выберите подписку')}
+
+
+ {config.eligible_subscriptions.map((sub) => (
+ setSelectedSubscriptionId(sub.id)}
+ disabled={isSpinning}
+ className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm transition-all ${
+ selectedSubscriptionId === sub.id
+ ? 'bg-accent-500/15 text-accent-400'
+ : 'text-dark-400 hover:text-dark-200'
+ }`}
+ >
+
+ {sub.tariff_name || t('subscription.defaultName', 'Подписка')}
+
+
+ {sub.days_left} {t('common.units.days', 'дней')}
+
+
+ ))}
+
+
+ )}
+
{/* Stars confirmation panel */}
{showStarsConfirm && !isSpinning && !isPayingStars ? (
@@ -645,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 && (
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;
diff --git a/src/types/index.ts b/src/types/index.ts
index ad61a5e..edb6d87 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -110,6 +110,31 @@ 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;
+ is_daily?: boolean;
+ is_daily_paused?: 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;
@@ -320,6 +345,8 @@ export interface Tariff {
custom_days_discount_percent?: number;
// Traffic reset
traffic_reset_mode?: string;
+ // Multi-tariff: already purchased by user
+ is_purchased?: boolean;
}
export interface TariffsPurchaseOptions {
@@ -332,6 +359,8 @@ export interface TariffsPurchaseOptions {
subscription_status?: string;
subscription_is_expired?: boolean;
has_subscription?: boolean;
+ // Multi-tariff: all available tariffs already purchased
+ all_tariffs_purchased?: boolean;
}
export interface ClassicPurchaseOptions {