Merge pull request #359 from BEDOLAGA-DEV/feat/multi-subscription

feat: multi-subscription frontend support
This commit is contained in:
c0mrade
2026-03-25 18:48:38 +03:00
committed by GitHub
35 changed files with 2113 additions and 5280 deletions

4602
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -65,7 +65,6 @@
"react-i18next": "^16.5.4",
"react-router": "^7.13.0",
"react-twemoji": "^0.7.2",
"reactjs-tiptap-editor": "^1.0.19",
"recharts": "^3.7.0",
"sigma": "^3.0.2",
"simplex-noise": "^4.0.3",

View File

@@ -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';
@@ -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'));
@@ -43,6 +44,7 @@ const Connection = lazy(() => import('./pages/Connection'));
const ConnectionQR = lazy(() => import('./pages/ConnectionQR'));
const QuickPurchase = lazy(() => import('./pages/QuickPurchase'));
const PurchaseSuccess = lazy(() => import('./pages/PurchaseSuccess'));
const RenewSubscription = lazy(() => import('./pages/RenewSubscription'));
const AutoLogin = lazy(() => import('./pages/AutoLogin'));
const TopUpMethodSelect = lazy(() => import('./pages/TopUpMethodSelect'));
const TopUpAmount = lazy(() => import('./pages/TopUpAmount'));
@@ -190,6 +192,12 @@ function BlockingOverlay() {
return null;
}
/** Redirect /subscription/:id → /subscriptions/:id preserving the param */
function LegacySubscriptionRedirect() {
const { subscriptionId } = useParams<{ subscriptionId: string }>();
return <Navigate to={`/subscriptions/${subscriptionId}`} replace />;
}
function App() {
useAnalyticsCounters();
@@ -258,7 +266,17 @@ function App() {
}
/>
<Route
path="/subscription"
path="/subscriptions"
element={
<ProtectedRoute>
<LazyPage>
<Subscriptions />
</LazyPage>
</ProtectedRoute>
}
/>
<Route
path="/subscriptions/:subscriptionId"
element={
<ProtectedRoute>
<LazyPage>
@@ -267,6 +285,26 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/subscriptions/:subscriptionId/renew"
element={
<ProtectedRoute>
<LazyPage>
<RenewSubscription />
</LazyPage>
</ProtectedRoute>
}
/>
{/* Legacy redirects for backward compatibility */}
<Route path="/subscription/:subscriptionId" element={<LegacySubscriptionRedirect />} />
<Route
path="/subscription"
element={
<ProtectedRoute>
<Navigate to="/subscriptions" replace />
</ProtectedRoute>
}
/>
<Route
path="/subscription/purchase"
element={

View File

@@ -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();

View File

@@ -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;
@@ -601,26 +603,33 @@ export const adminUsersApi = {
},
// Get panel info
getPanelInfo: async (userId: number): Promise<UserPanelInfo> => {
const response = await apiClient.get(`/cabinet/admin/users/${userId}/panel-info`);
getPanelInfo: async (userId: number, subscriptionId?: number): Promise<UserPanelInfo> => {
const response = await apiClient.get(`/cabinet/admin/users/${userId}/panel-info`, {
params: subscriptionId != null ? { subscription_id: subscriptionId } : undefined,
});
return response.data;
},
// Get node usage (always 30 days with daily breakdown)
getNodeUsage: async (userId: number): Promise<UserNodeUsageResponse> => {
const response = await apiClient.get(`/cabinet/admin/users/${userId}/node-usage`);
getNodeUsage: async (userId: number, subscriptionId?: number): Promise<UserNodeUsageResponse> => {
const response = await apiClient.get(`/cabinet/admin/users/${userId}/node-usage`, {
params: subscriptionId != null ? { subscription_id: subscriptionId } : undefined,
});
return response.data;
},
// Get user devices
getUserDevices: async (
userId: number,
subscriptionId?: number,
): Promise<{
devices: { hwid: string; platform: string; device_model: string; created_at: string | null }[];
total: number;
device_limit: number;
}> => {
const response = await apiClient.get(`/cabinet/admin/users/${userId}/devices`);
const response = await apiClient.get(`/cabinet/admin/users/${userId}/devices`, {
params: subscriptionId != null ? { subscription_id: subscriptionId } : undefined,
});
return response.data;
},
@@ -628,16 +637,22 @@ export const adminUsersApi = {
deleteUserDevice: async (
userId: number,
hwid: string,
subscriptionId?: number,
): Promise<{ success: boolean; message: string; deleted_hwid: string | null }> => {
const response = await apiClient.delete(`/cabinet/admin/users/${userId}/devices/${hwid}`);
const response = await apiClient.delete(`/cabinet/admin/users/${userId}/devices/${hwid}`, {
params: subscriptionId != null ? { subscription_id: subscriptionId } : undefined,
});
return response.data;
},
// Reset all devices
resetUserDevices: async (
userId: number,
subscriptionId?: number,
): Promise<{ success: boolean; message: string; deleted_count: number }> => {
const response = await apiClient.delete(`/cabinet/admin/users/${userId}/devices`);
const response = await apiClient.delete(`/cabinet/admin/users/${userId}/devices`, {
params: subscriptionId != null ? { subscription_id: subscriptionId } : undefined,
});
return response.data;
},

View File

@@ -2,6 +2,8 @@ import apiClient from './client';
import type {
Subscription,
SubscriptionStatusResponse,
SubscriptionListItem,
SubscriptionsListResponse,
RenewalOption,
TrafficPackage,
TrialInfo,
@@ -11,50 +13,161 @@ import type {
AppConfig,
} from '../types';
/** Helper: build query params with optional subscription_id */
const withSubId = (subscriptionId?: number, extra?: Record<string, unknown>) => ({
params: {
...(subscriptionId != null && { subscription_id: subscriptionId }),
...extra,
},
});
/**
* Helper for POST/PATCH/PUT: returns [body, axiosConfig] tuple.
* subscription_id goes as query param (backend expects Query(...)), NOT in body.
*/
const bodyWithSubId = (
body: Record<string, unknown>,
subscriptionId?: number,
): [Record<string, unknown>, { params?: Record<string, unknown> }] => [
body,
subscriptionId != null ? { params: { subscription_id: subscriptionId } } : {},
];
export const subscriptionApi = {
getSubscription: async (): Promise<SubscriptionStatusResponse> => {
const response = await apiClient.get<SubscriptionStatusResponse>('/cabinet/subscription');
// ── Multi-tariff endpoints ──────────────────────────────────────────
getSubscriptions: async (): Promise<SubscriptionsListResponse> => {
const response = await apiClient.get<SubscriptionsListResponse>('/cabinet/subscriptions');
return response.data;
},
getRenewalOptions: async (): Promise<RenewalOption[]> => {
const response = await apiClient.get<RenewalOption[]>('/cabinet/subscription/renewal-options');
getSubscriptionById: async (subscriptionId: number): Promise<SubscriptionListItem> => {
const response = await apiClient.get<SubscriptionListItem>(
`/cabinet/subscriptions/${subscriptionId}`,
);
return response.data;
},
deleteSubscription: async (subscriptionId: number): Promise<{ message: string }> => {
const response = await apiClient.delete(`/cabinet/subscriptions/${subscriptionId}`);
return response.data;
},
// ── Legacy single-subscription status ───────────────────────────────
getSubscription: async (subscriptionId?: number): Promise<SubscriptionStatusResponse> => {
const response = await apiClient.get<SubscriptionStatusResponse>(
'/cabinet/subscription',
withSubId(subscriptionId),
);
return response.data;
},
// ── Renewal ─────────────────────────────────────────────────────────
getRenewalOptions: async (subscriptionId?: number): Promise<RenewalOption[]> => {
const response = await apiClient.get<RenewalOption[]>(
'/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<TrafficPackage[]> => {
// ── Traffic ─────────────────────────────────────────────────────────
getTrafficPackages: async (subscriptionId?: number): Promise<TrafficPackage[]> => {
const response = await apiClient.get<TrafficPackage[]>(
'/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<void> => {
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 +178,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 +201,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<void> => {
await apiClient.post('/cabinet/subscription/devices/save-cart', { devices });
saveDevicesCart: async (devices: number, subscriptionId?: number): Promise<void> => {
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 +232,98 @@ 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<void> => {
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',
{ enabled, days_before: daysBefore },
withSubId(subscriptionId),
);
return response.data;
},
// ── Trial ───────────────────────────────────────────────────────────
getTrialInfo: async (): Promise<TrialInfo> => {
const response = await apiClient.get<TrialInfo>('/cabinet/subscription/trial');
return response.data;
@@ -155,32 +334,40 @@ export const subscriptionApi = {
return response.data;
},
getPurchaseOptions: async (): Promise<PurchaseOptions> => {
const response = await apiClient.get<PurchaseOptions>('/cabinet/subscription/purchase-options');
// ── Purchase ────────────────────────────────────────────────────────
getPurchaseOptions: async (subscriptionId?: number): Promise<PurchaseOptions> => {
const response = await apiClient.get<PurchaseOptions>(
'/cabinet/subscription/purchase-options',
withSubId(subscriptionId),
);
return response.data;
},
previewPurchase: async (selection: PurchaseSelection): Promise<PurchasePreview> => {
previewPurchase: async (
selection: PurchaseSelection,
subscriptionId?: number,
): Promise<PurchasePreview> => {
const response = await apiClient.post<PurchasePreview>(
'/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 +392,11 @@ export const subscriptionApi = {
return response.data;
},
getAppConfig: async (): Promise<AppConfig> => {
const response = await apiClient.get<AppConfig>('/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 +415,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 +432,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 +454,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 +476,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<AppConfig> => {
const response = await apiClient.get<AppConfig>(
'/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 +504,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 +529,29 @@ 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',
undefined,
withSubId(subscriptionId),
);
return response.data;
},
};

View File

@@ -8,6 +8,12 @@ export interface WheelPrize {
prize_type: string;
}
export interface EligibleSubscription {
id: number;
tariff_name: string | null;
days_left: number;
}
export interface WheelConfig {
is_enabled: boolean;
name: string;
@@ -25,6 +31,7 @@ export interface WheelConfig {
user_balance_kopeks: number;
required_balance_kopeks: number;
has_subscription: boolean;
eligible_subscriptions: EligibleSubscription[] | null;
}
export interface SpinAvailability {
@@ -187,9 +194,13 @@ export const wheelApi = {
return response.data;
},
spin: async (paymentType: 'telegram_stars' | 'subscription_days'): Promise<SpinResult> => {
spin: async (
paymentType: 'telegram_stars' | 'subscription_days',
subscriptionId?: number,
): Promise<SpinResult> => {
const response = await apiClient.post<SpinResult>('/cabinet/wheel/spin', {
payment_type: paymentType,
...(subscriptionId != null && { subscription_id: subscriptionId }),
});
return response.data;
},

View File

@@ -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);

View File

@@ -190,7 +190,7 @@ export default function SuccessNotificationModal() {
const handleGoToSubscription = () => {
hide();
navigate('/subscription');
navigate('/subscriptions');
};
const handleGoToBalance = () => {

View File

@@ -0,0 +1,32 @@
import { Link } from 'react-router';
import { usePlatform } from '../platform';
import { BackIcon } from './icons';
interface WebBackButtonProps {
to: string;
replace?: boolean;
className?: string;
}
/**
* Back button visible only on web platform.
* Hidden in Telegram Mini App — native back button handles navigation there.
*/
export function WebBackButton({ to, replace, className }: WebBackButtonProps) {
const { platform } = usePlatform();
if (platform === 'telegram') return null;
return (
<Link
to={to}
replace={replace}
className={
className ||
'flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600'
}
>
<BackIcon />
</Link>
);
}

View File

@@ -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: <span className="text-lg"></span>,
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: <span className="text-lg">😢</span>,
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: <span className="text-lg">🔄</span>,
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: <span className="text-lg">🔁</span>,
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: <span className="text-lg"></span>,
onClick: () => navigate('/subscription'),
onClick: () => navigate('/subscriptions'),
duration: 10000,
});
return;

View File

@@ -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"
@@ -312,7 +312,7 @@ export default function SubscriptionCardActive({
<div className="mb-5 flex gap-2.5">
{/* Tariff badge — clickable */}
<Link
to="/subscription"
to={`/subscriptions/${subscription.id}`}
className="flex-1 rounded-[14px] p-3.5 transition-all duration-500"
style={{
background: `linear-gradient(135deg, rgba(${zone.mainVarRaw}, 0.07), rgba(${zone.mainVarRaw}, 0.02))`,
@@ -396,7 +396,7 @@ export default function SubscriptionCardActive({
{trafficRefreshCooldown > 0 ? `${trafficRefreshCooldown}s` : t('common.refresh')}
</button>
<Link
to="/subscription"
to={`/subscriptions/${subscription.id}`}
className="text-[11px] font-medium text-dark-50/25 transition-colors hover:text-dark-50/40"
>
{t('dashboard.viewSubscription')} &rarr;

View File

@@ -57,15 +57,18 @@ 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'] });
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({
<div className="flex gap-2.5">
{isLimited ? (
<Link
to="/subscription"
to={`/subscriptions/${subscription.id}`}
className="flex flex-1 items-center justify-center gap-2 rounded-[14px] py-3.5 text-[15px] font-semibold tracking-tight text-white transition-all duration-300"
style={{
background: accent.gradient,

View File

@@ -160,7 +160,7 @@ export function AppHeader({
const navItems = [
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
{ path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon },
{ path: '/subscriptions', label: t('nav.subscription'), icon: SubscriptionIcon },
{ path: '/balance', label: t('nav.balance'), icon: WalletIcon },
...(referralEnabled ? [{ path: '/referral', label: t('nav.referral'), icon: UsersIcon }] : []),
{ path: '/support', label: t('nav.support'), icon: ChatIcon },

View File

@@ -254,7 +254,7 @@ export function AppShell({ children }: AppShellProps) {
// Desktop navigation items
const desktopNavItems = [
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
{ path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon },
{ path: '/subscriptions', label: t('nav.subscription'), icon: SubscriptionIcon },
{ path: '/balance', label: t('nav.balance'), icon: CreditCardIcon },
{ path: '/support', label: t('nav.support'), icon: ChatIcon },
{ path: '/info', label: t('nav.info'), icon: InfoIcon },

View File

@@ -79,7 +79,7 @@ export function DesktopSidebar({
const navItems = [
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
{ path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon },
{ path: '/subscriptions', label: t('nav.subscription'), icon: SubscriptionIcon },
{ path: '/balance', label: t('nav.balance'), icon: WalletIcon },
...(referralEnabled ? [{ path: '/referral', label: t('nav.referral'), icon: UsersIcon }] : []),
{ path: '/support', label: t('nav.support'), icon: ChatIcon },

View File

@@ -29,7 +29,7 @@ export function MobileBottomNav({
// When wheel is enabled, it replaces Support in the bottom nav (Support is still accessible via hamburger menu)
const coreItems = [
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
{ path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon },
{ path: '/subscriptions', label: t('nav.subscription'), icon: SubscriptionIcon },
{ path: '/balance', label: t('nav.balance'), icon: WalletIcon },
...(referralEnabled ? [{ path: '/referral', label: t('nav.referral'), icon: UsersIcon }] : []),
...(wheelEnabled

View File

@@ -78,7 +78,7 @@ export function CommandPalette({
// Navigation items
const navigationItems = [
{ label: t('nav.dashboard'), icon: HomeIcon, path: '/' },
{ label: t('nav.subscription'), icon: SubscriptionIcon, path: '/subscription' },
{ label: t('nav.subscription'), icon: SubscriptionIcon, path: '/subscriptions' },
{ label: t('nav.balance'), icon: WalletIcon, path: '/balance' },
...(referralEnabled ? [{ label: t('nav.referral'), icon: UsersIcon, path: '/referral' }] : []),
{ label: t('nav.support'), icon: ChatIcon, path: '/support' },
@@ -100,7 +100,7 @@ export function CommandPalette({
{
label: t('subscription.get_config') || 'Get VPN config',
icon: DownloadIcon,
action: () => navigate('/subscription'),
action: () => navigate('/subscriptions'),
},
{
label: isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode',

View File

@@ -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 ?? [];

View File

@@ -5,13 +5,22 @@ import type { Subscription } from '../../types';
interface PurchaseCTAButtonProps {
subscription: Subscription | null;
/** In multi-tariff mode, link to /subscriptions/:id/renew instead of /subscription/purchase */
isMultiTariff?: boolean;
}
export default function PurchaseCTAButton({ subscription }: PurchaseCTAButtonProps) {
export default function PurchaseCTAButton({
subscription,
isMultiTariff = false,
}: PurchaseCTAButtonProps) {
const { t } = useTranslation();
const isExpired = !subscription || (!subscription.is_active && !subscription.is_trial);
const isTrial = subscription?.is_trial;
const isDaily = subscription?.is_daily;
// Daily tariffs renew automatically — no manual renewal button needed in multi-tariff
if (isMultiTariff && isDaily && !isExpired) return null;
const accentColor = isExpired ? '#FF3B5C' : 'rgb(var(--color-accent-400))';
@@ -25,10 +34,21 @@ export default function PurchaseCTAButton({ subscription }: PurchaseCTAButtonPro
? t('subscription.cta.expiredHint')
: isTrial
? t('subscription.cta.trialHint')
: isMultiTariff
? t('subscription.cta.renewHint', 'Продление подписки')
: t('subscription.cta.activeHint');
// 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';
return (
<Link to="/subscription/purchase" className="block">
<Link to={linkTo} className="block">
<HoverBorderGradient
accentColor={accentColor}
duration={4}

View File

@@ -0,0 +1,225 @@
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 formatDate(iso: string | null, locale?: string): string {
if (!iso) return '—';
try {
return new Date(iso).toLocaleDateString(locale ?? undefined, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
} catch {
return '—';
}
}
function StatusBadge({
status,
isTrial,
t,
}: {
status: string;
isTrial: boolean;
t: (key: string, fallback: string) => string;
}) {
const isActive = status === 'active' || status === 'trial';
const isLimited = status === 'limited';
const isExpired = status === 'expired' || status === 'disabled';
if (isTrial) {
return (
<span className="inline-flex items-center gap-1 rounded-full border border-amber-400/25 bg-amber-400/10 px-2 py-0.5 text-[10px] font-semibold text-amber-400">
<svg className="h-2.5 w-2.5" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z" />
</svg>
{t('subscription.statusTrial', 'Тестовая')}
</span>
);
}
const color = isActive
? 'bg-emerald-400/15 text-emerald-400 border-emerald-400/20'
: isLimited
? 'bg-amber-400/15 text-amber-400 border-amber-400/20'
: 'bg-red-400/15 text-red-400 border-red-400/20';
const label = isActive
? t('subscription.statusActive', 'Активна')
: isLimited
? t('subscription.statusLimited', 'Ограничена')
: isExpired
? t('subscription.statusExpired', 'Истекла')
: status;
return (
<span
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold ${color}`}
>
{label}
</span>
);
}
export default function SubscriptionListCard({
subscription,
onClick,
}: {
subscription: SubscriptionListItem;
onClick: () => void;
}) {
const { t, i18n } = useTranslation();
const { isDark } = useTheme();
const g = getGlassColors(isDark);
const { impact } = useHaptic();
const handleClick = () => {
impact('light');
onClick();
};
const isTrial = subscription.is_trial;
const isActive = subscription.status === 'active' || subscription.status === 'trial';
const isExpired = subscription.status === 'expired' || subscription.status === 'disabled';
const trafficLimit = subscription.traffic_limit_gb;
const trafficUsed = subscription.traffic_used_gb;
const isUnlimited = trafficLimit === 0;
const trafficPercent = isUnlimited
? 0
: trafficLimit > 0
? Math.min(100, (trafficUsed / trafficLimit) * 100)
: 0;
const trafficColor =
trafficPercent >= 90 ? 'bg-red-400' : trafficPercent >= 70 ? 'bg-amber-400' : 'bg-emerald-400';
const borderColor = isTrial
? 'rgba(251,191,36,0.2)'
: isExpired
? 'rgba(255,59,92,0.15)'
: g.cardBorder;
const bgColor = isTrial
? isDark
? 'rgba(251,191,36,0.04)'
: 'rgba(251,191,36,0.03)'
: isExpired
? isDark
? 'rgba(255,59,92,0.04)'
: 'rgba(255,59,92,0.03)'
: g.cardBg;
return (
<button
onClick={handleClick}
className="w-full rounded-2xl border p-4 text-left transition-all duration-200 hover:scale-[1.01] active:scale-[0.99]"
style={{ background: bgColor, borderColor }}
>
{/* Header: tariff name + status badge + chevron */}
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-base font-semibold" style={{ color: g.text }}>
{subscription.tariff_name || t('subscription.defaultName', 'Подписка')}
</span>
<StatusBadge status={subscription.status} isTrial={isTrial} t={t} />
</div>
<svg
className="h-4 w-4 shrink-0 opacity-30"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</div>
{/* Traffic mini progress bar */}
{isActive && (
<div className="mt-3">
<div className="mb-1 flex items-baseline justify-between">
<span className="text-[11px] font-medium" style={{ color: g.textSecondary }}>
{t('subscription.traffic', 'Трафик')}
</span>
<span className="text-[11px] tabular-nums" style={{ color: g.textSecondary }}>
{isUnlimited
? '∞'
: `${trafficUsed.toFixed(1)} / ${trafficLimit} ${t('common.units.gb', 'ГБ')}`}
</span>
</div>
{!isUnlimited && (
<div className="h-1.5 overflow-hidden rounded-full" style={{ background: g.innerBg }}>
<div
className={`h-full rounded-full transition-all ${trafficColor}`}
style={{ width: `${Math.max(1, trafficPercent)}%` }}
/>
</div>
)}
</div>
)}
{/* Stats row */}
<div
className="mt-2.5 flex items-center gap-4 text-[12px]"
style={{ color: g.textSecondary }}
>
<span className="flex items-center gap-1">
<svg
className="h-3.5 w-3.5 opacity-50"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<rect x="5" y="2" width="14" height="20" rx="2" />
<path d="M12 18h.01" />
</svg>
{subscription.device_limit}
</span>
<span className="flex items-center gap-1">
<svg
className="h-3.5 w-3.5 opacity-50"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" />
</svg>
{formatDate(subscription.end_date, i18n.language)}
</span>
{!isTrial &&
(() => {
const isDaily = subscription.is_daily;
const enabled = isDaily ? !subscription.is_daily_paused : subscription.autopay_enabled;
const label = isDaily
? t('subscription.dailyAutoCharge', 'Автосписание')
: t('subscription.autopay', 'Автопродление');
return (
<span
className={`flex items-center gap-1 ${enabled ? 'text-emerald-400/70' : 'text-red-400/50'}`}
>
<svg
className="h-3 w-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
>
{enabled ? (
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
)}
</svg>
{label}
</span>
);
})()}
</div>
</button>
);
}

View File

@@ -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);
}
};
}, []);

View File

@@ -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,9 @@ export default function AdminUserDetail() {
const [subAction, setSubAction] = useState<string>('extend');
const [subDays, setSubDays] = useState<number | ''>(30);
const [selectedTariffId, setSelectedTariffId] = useState<number | null>(null);
const [activeSubscriptionId, setActiveSubscriptionId] = useState<number | null>(null);
const hasAutoSelectedSub = useRef(false);
const [subscriptionDetailView, setSubscriptionDetailView] = useState(false);
// Promo group
const [promoGroups, setPromoGroups] = useState<PromoGroup[]>([]);
@@ -442,27 +445,27 @@ export default function AdminUserDetail() {
if (!userId) return;
try {
setPanelInfoLoading(true);
const data = await adminUsersApi.getPanelInfo(userId);
const data = await adminUsersApi.getPanelInfo(userId, activeSubscriptionId ?? undefined);
setPanelInfo(data);
} catch {
} finally {
setPanelInfoLoading(false);
}
}, [userId]);
}, [userId, activeSubscriptionId]);
const loadNodeUsage = useCallback(async () => {
if (!userId) return;
try {
const data = await adminUsersApi.getNodeUsage(userId);
const data = await adminUsersApi.getNodeUsage(userId, activeSubscriptionId ?? undefined);
setNodeUsage(data);
} catch {}
}, [userId]);
}, [userId, activeSubscriptionId]);
const loadDevices = useCallback(async () => {
if (!userId) return;
try {
setDevicesLoading(true);
const data = await adminUsersApi.getUserDevices(userId);
const data = await adminUsersApi.getUserDevices(userId, activeSubscriptionId ?? undefined);
setDevices(data.devices);
setDevicesTotal(data.total);
setDeviceLimit(data.device_limit);
@@ -470,7 +473,7 @@ export default function AdminUserDetail() {
} finally {
setDevicesLoading(false);
}
}, [userId]);
}, [userId, activeSubscriptionId]);
const loadSubscriptionData = useCallback(async () => {
await Promise.all([loadPanelInfo(), loadNodeUsage(), loadDevices()]);
@@ -602,6 +605,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'
@@ -693,7 +699,7 @@ export default function AdminUserDetail() {
if (!userId) return;
setActionLoading(true);
try {
await adminUsersApi.deleteUserDevice(userId, hwid);
await adminUsersApi.deleteUserDevice(userId, hwid, activeSubscriptionId ?? undefined);
notify.success(t('admin.users.detail.devices.deleted'));
await loadDevices();
} catch {
@@ -707,7 +713,7 @@ export default function AdminUserDetail() {
if (!userId) return;
setActionLoading(true);
try {
await adminUsersApi.resetUserDevices(userId);
await adminUsersApi.resetUserDevices(userId, activeSubscriptionId ?? undefined);
notify.success(t('admin.users.detail.devices.allDeleted'));
await loadDevices();
} catch {
@@ -721,7 +727,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 +749,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 +767,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 +778,21 @@ 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 (one-time init)
useEffect(() => {
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]);
const currentTariff = tariffs.find((t) => t.id === selectedSub?.tariff_id) || null;
const handleChangePromoGroup = async (groupId: number | null) => {
if (!userId) return;
@@ -1356,15 +1382,143 @@ export default function AdminUserDetail() {
{/* Subscription Tab */}
{activeTab === 'subscription' && (
<div className="space-y-4">
{user.subscription ? (
{/* Multi-subscription: Level 1 — subscription list */}
{userSubscriptions.length > 1 && !subscriptionDetailView && (
<>
<div className="space-y-3">
{userSubscriptions.map((sub) => (
<button
key={sub.id}
onClick={() => {
setActiveSubscriptionId(sub.id);
setSubscriptionDetailView(true);
}}
className="w-full rounded-xl border border-dark-700/50 bg-dark-800/50 p-4 text-left transition-all hover:border-dark-600"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-dark-100">
{sub.tariff_name || `#${sub.id}`}
</span>
<StatusBadge status={sub.status} />
</div>
<svg
className="h-4 w-4 text-dark-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 4.5l7.5 7.5-7.5 7.5"
/>
</svg>
</div>
<div className="mt-2 flex items-center gap-4 text-xs text-dark-400">
<span>
{sub.traffic_used_gb.toFixed(1)} / {sub.traffic_limit_gb}{' '}
{t('common.units.gb')}
</span>
<span>{formatDate(sub.end_date)}</span>
<span>
{sub.device_limit}{' '}
{t('admin.users.detail.subscription.devices', 'устройств')}
</span>
</div>
</button>
))}
</div>
{/* Create new subscription — at list level */}
{hasPermission('users:subscription') && (
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="mb-3 text-sm font-medium text-dark-200">
{t('admin.users.detail.subscription.createNew', 'Создать подписку')}
</div>
<div className="space-y-3">
<select
value={selectedTariffId || ''}
onChange={(e) =>
setSelectedTariffId(e.target.value ? parseInt(e.target.value) : null)
}
className="input"
>
<option value="">
{t('admin.users.detail.subscription.selectTariff')}
</option>
{tariffs
.filter((tariffItem) => {
const purchasedIds = new Set(
userSubscriptions
.filter((s) => s.is_active || s.status === 'trial')
.map((s) => s.tariff_id),
);
return !purchasedIds.has(tariffItem.id);
})
.map((tariffItem) => (
<option key={tariffItem.id} value={tariffItem.id}>
{tariffItem.name}
</option>
))}
</select>
<input
type="number"
value={subDays}
onChange={createNumberInputHandler(setSubDays, 1)}
placeholder={t('admin.users.detail.subscription.days')}
className="input"
min={1}
max={3650}
/>
<button
onClick={() => handleUpdateSubscription('create')}
disabled={actionLoading}
className="btn-primary w-full"
>
{actionLoading
? t('admin.users.detail.subscription.creating')
: t('admin.users.detail.subscription.create')}
</button>
</div>
</div>
)}
</>
)}
{/* Level 2 — subscription detail (or single subscription) */}
{(subscriptionDetailView || userSubscriptions.length <= 1) && selectedSub ? (
<>
{/* Back to list (multi-subscription) */}
{subscriptionDetailView && userSubscriptions.length > 1 && (
<button
onClick={() => setSubscriptionDetailView(false)}
className="flex items-center gap-1.5 text-sm text-dark-400 transition-colors hover:text-dark-200"
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
{t('admin.users.detail.subscription.backToList', 'Все подписки')}
</button>
)}
{/* Current subscription */}
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="mb-3 flex items-center justify-between">
<span className="font-medium text-dark-200">
{t('admin.users.detail.subscription.current')}
{userSubscriptions.length > 1 && (
<span className="ml-2 text-xs text-dark-500">#{selectedSub.id}</span>
)}
</span>
<StatusBadge status={user.subscription.status} />
<StatusBadge status={selectedSub.status} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
@@ -1372,7 +1526,7 @@ export default function AdminUserDetail() {
{t('admin.users.detail.subscription.tariff')}
</div>
<div className="text-dark-100">
{user.subscription.tariff_name ||
{selectedSub.tariff_name ||
t('admin.users.detail.subscription.notSpecified')}
</div>
</div>
@@ -1380,7 +1534,7 @@ export default function AdminUserDetail() {
<div className="text-xs text-dark-500">
{t('admin.users.detail.subscription.validUntil')}
</div>
<div className="text-dark-100">{formatDate(user.subscription.end_date)}</div>
<div className="text-dark-100">{formatDate(selectedSub.end_date)}</div>
</div>
<div>
<div className="text-xs text-dark-500">
@@ -1389,8 +1543,8 @@ export default function AdminUserDetail() {
<div className="text-dark-100">
{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')}
</div>
</div>
<div>
@@ -1399,21 +1553,21 @@ export default function AdminUserDetail() {
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleSetDeviceLimit(user.subscription!.device_limit - 1)}
disabled={actionLoading || user.subscription.device_limit <= 1}
onClick={() => handleSetDeviceLimit(selectedSub.device_limit - 1)}
disabled={actionLoading || selectedSub.device_limit <= 1}
className="flex h-6 w-6 items-center justify-center rounded-md bg-dark-700 text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-30"
>
<MinusIcon />
</button>
<span className="min-w-[2ch] text-center text-dark-100">
{user.subscription.device_limit}
{selectedSub.device_limit}
</span>
<button
onClick={() => handleSetDeviceLimit(user.subscription!.device_limit + 1)}
onClick={() => handleSetDeviceLimit(selectedSub.device_limit + 1)}
disabled={
actionLoading ||
(currentTariff?.max_device_limit != null &&
user.subscription.device_limit >= currentTariff.max_device_limit)
selectedSub.device_limit >= currentTariff.max_device_limit)
}
className="flex h-6 w-6 items-center justify-center rounded-md bg-dark-700 text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-30"
>
@@ -1425,21 +1579,20 @@ export default function AdminUserDetail() {
</div>
{/* Traffic Packages */}
{user.subscription.traffic_purchases &&
user.subscription.traffic_purchases.length > 0 && (
{selectedSub.traffic_purchases && selectedSub.traffic_purchases.length > 0 && (
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm font-medium text-dark-200">
{t('admin.users.detail.subscription.trafficPackages')}
{user.subscription.purchased_traffic_gb > 0 && (
{selectedSub.purchased_traffic_gb > 0 && (
<span className="ml-2 text-xs text-dark-400">
({user.subscription.purchased_traffic_gb} {t('common.units.gb')})
({selectedSub.purchased_traffic_gb} {t('common.units.gb')})
</span>
)}
</span>
</div>
<div className="space-y-2">
{user.subscription.traffic_purchases.map((tp) => (
{selectedSub.traffic_purchases.map((tp) => (
<div
key={tp.id}
className={`flex items-center justify-between rounded-lg px-3 py-2 ${
@@ -1545,9 +1698,11 @@ export default function AdminUserDetail() {
<option value="shorten">
{t('admin.users.detail.subscription.shorten')}
</option>
{userSubscriptions.length <= 1 && (
<option value="change_tariff">
{t('admin.users.detail.subscription.changeTariff')}
</option>
)}
<option value="cancel">
{t('admin.users.detail.subscription.cancel')}
</option>
@@ -1602,11 +1757,19 @@ export default function AdminUserDetail() {
</div>
)}
</>
) : hasPermission('users:subscription') ? (
) : null}
{/* Create new subscription — only for single-sub users or no subs */}
{hasPermission('users:subscription') && userSubscriptions.length <= 1 && (
<div className="rounded-xl bg-dark-800/50 p-4">
{userSubscriptions.length === 0 && (
<div className="mb-4 text-center text-dark-400">
{t('admin.users.detail.subscription.noActive')}
</div>
)}
<div className="mb-3 text-sm font-medium text-dark-200">
{t('admin.users.detail.subscription.createNew', 'Создать подписку')}
</div>
<div className="space-y-3">
<select
value={selectedTariffId || ''}
@@ -1616,7 +1779,20 @@ export default function AdminUserDetail() {
className="input"
>
<option value="">{t('admin.users.detail.subscription.selectTariff')}</option>
{tariffs.map((tariffItem) => (
{tariffs
.filter((tariffItem) => {
// In multi-tariff: hide tariffs user already has
if (userSubscriptions.length > 0) {
const purchasedIds = new Set(
userSubscriptions
.filter((s) => s.is_active || s.status === 'trial')
.map((s) => s.tariff_id),
);
return !purchasedIds.has(tariffItem.id);
}
return true;
})
.map((tariffItem) => (
<option key={tariffItem.id} value={tariffItem.id}>
{tariffItem.name}
</option>
@@ -1642,15 +1818,11 @@ export default function AdminUserDetail() {
</button>
</div>
</div>
) : (
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="text-center text-dark-400">
{t('admin.users.detail.subscription.noActive')}
</div>
</div>
)}
{/* Panel Info */}
{/* Panel Info, Traffic, Devices — only inside subscription detail */}
{(subscriptionDetailView || userSubscriptions.length <= 1) && (
<>
{panelInfoLoading ? (
<div className="flex justify-center rounded-xl bg-dark-800/50 py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
@@ -1665,7 +1837,8 @@ export default function AdminUserDetail() {
{(panelInfo.subscription_url || panelInfo.happ_link) && (
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="mb-3 text-sm font-medium text-dark-200">
{t('admin.users.detail.subscriptionUrl')} / {t('admin.users.detail.happLink')}
{t('admin.users.detail.subscriptionUrl')} /{' '}
{t('admin.users.detail.happLink')}
</div>
<div className="space-y-2">
{panelInfo.subscription_url && (
@@ -1699,7 +1872,9 @@ export default function AdminUserDetail() {
)}
{/* Config */}
{(panelInfo.trojan_password || panelInfo.vless_uuid || panelInfo.ss_password) && (
{(panelInfo.trojan_password ||
panelInfo.vless_uuid ||
panelInfo.ss_password) && (
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="mb-3 text-sm font-medium text-dark-200">
{t('admin.users.detail.panelConfig')}
@@ -1763,7 +1938,9 @@ export default function AdminUserDetail() {
<div className="text-xs text-dark-500">
{t('admin.users.detail.lastOnline')}
</div>
<div className="text-sm text-dark-100">{formatDate(panelInfo.online_at)}</div>
<div className="text-sm text-dark-100">
{formatDate(panelInfo.online_at)}
</div>
</div>
{panelInfo.last_connected_node_name && (
<div className="col-span-2">
@@ -1853,11 +2030,15 @@ export default function AdminUserDetail() {
<div className="mb-1 flex justify-between text-xs">
<span className="text-dark-300">
{item.country_code && (
<span className="mr-1">{getCountryFlag(item.country_code)}</span>
<span className="mr-1">
{getCountryFlag(item.country_code)}
</span>
)}
{item.node_name}
</span>
<span className="text-dark-400">{formatBytes(item.total_bytes)}</span>
<span className="text-dark-400">
{formatBytes(item.total_bytes)}
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-dark-700">
<div
@@ -1928,7 +2109,9 @@ export default function AdminUserDetail() {
)}
<span className="font-mono">{device.hwid.slice(0, 8)}...</span>
{device.created_at && (
<span>{new Date(device.created_at).toLocaleDateString(locale)}</span>
<span>
{new Date(device.created_at).toLocaleDateString(locale)}
</span>
)}
</div>
</div>
@@ -1956,6 +2139,8 @@ export default function AdminUserDetail() {
</div>
)}
</div>
</>
)}
</div>
)}

View File

@@ -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<AppConfig>({
queryKey: ['appConfig'],
queryFn: () => subscriptionApi.getAppConfig(),
queryKey: ['appConfig', subId],
queryFn: () => subscriptionApi.getAppConfig(subId),
});
const handleGoBack = useCallback(() => {
@@ -41,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) => {

View File

@@ -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 (
<div className="animate-fade-in">
<div className="mb-6 flex items-center gap-3">
<AdminBackButton to="/connection" replace />
<AdminBackButton to={connectionPath} replace />
<h1 className="text-2xl font-bold text-dark-100">{t('subscription.connection.qrTitle')}</h1>
</div>

View File

@@ -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();
@@ -49,26 +51,35 @@ export default function Dashboard() {
refetchOnMount: 'always',
});
// 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 { data: subscriptionResponse, isLoading: subLoading } = useQuery({
queryKey: ['subscription'],
queryFn: subscriptionApi.getSubscription,
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,
queryFn: () => subscriptionApi.getTrialInfo(),
enabled: !subscription && !subLoading,
});
const { data: devicesData } = useQuery({
queryKey: ['devices'],
queryFn: subscriptionApi.getDevices,
enabled: !!subscription,
queryFn: () => subscriptionApi.getDevices(),
enabled: !!subscription && !isMultiTariff,
staleTime: API.BALANCE_STALE_TIME_MS,
});
@@ -99,10 +110,11 @@ export default function Dashboard() {
});
const activateTrialMutation = useMutation({
mutationFn: subscriptionApi.activateTrial,
mutationFn: () => subscriptionApi.activateTrial(),
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'] });
@@ -122,20 +134,23 @@ 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,
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 {
setTrafficRefreshCooldown(30);
}
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['subscription', subscription?.id] });
},
onError: (error: {
response?: { status?: number; headers?: { get?: (key: string) => string } };
@@ -164,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;
@@ -266,7 +281,44 @@ export default function Dashboard() {
{/* Pending Gift Activations */}
{pendingGifts && pendingGifts.length > 0 && <PendingGiftCard gifts={pendingGifts} />}
{/* Subscription Status Card */}
{/* Multi-tariff: show subscription cards (max 3) */}
{isMultiTariff && multiSubData?.subscriptions && (
<div className="space-y-3">
<div className="flex items-center justify-between px-1">
<span className="text-sm font-medium opacity-60">
{t('dashboard.subscriptions', 'Подписки')}
</span>
<Link to="/subscriptions" className="text-xs text-accent-400 hover:underline">
{t('dashboard.manageAll', 'Управление')}
</Link>
</div>
{multiSubData.subscriptions.slice(0, 3).map((sub) => (
<SubscriptionListCard
key={sub.id}
subscription={sub}
onClick={() => navigate(`/subscriptions/${sub.id}`)}
/>
))}
{multiSubData.subscriptions.length > 3 && (
<Link
to="/subscriptions"
className="flex w-full items-center justify-center rounded-2xl border border-dashed border-white/15 p-3 text-xs opacity-50 transition-opacity hover:opacity-80"
>
{t('dashboard.showAll', 'Показать все')} ({multiSubData.subscriptions.length})
</Link>
)}
<Link
to="/subscription/purchase"
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-500/15 p-3.5 text-sm font-medium text-accent-400 transition-all hover:bg-accent-500/25"
>
<span className="text-base">+</span> {t('subscriptions.buyAnother', 'Купить ещё тариф')}
</Link>
</div>
)}
{/* Subscription Status Card — hidden in multi-tariff (managed via /subscriptions) */}
{!isMultiTariff && (
<>
{subLoading ? (
<div className="bento-card">
<div className="mb-4 flex items-center justify-between">
@@ -297,6 +349,8 @@ export default function Dashboard() {
connectedDevices={devicesData?.total ?? 0}
/>
) : null}
</>
)}
{/* Trial Activation */}
{hasNoSubscription && !trialLoading && trialInfo?.is_available && (

View File

@@ -311,7 +311,7 @@ export default function DeepLinkRedirect() {
{/* Back to cabinet */}
<button
onClick={() => 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() {
</div>
<p className="mb-2 font-medium text-dark-200">{t('deepLink.errorTitle')}</p>
<p className="mb-6 text-sm text-dark-400">{t('deepLink.errorDesc')}</p>
<button onClick={() => navigate('/subscription')} className="btn-primary w-full">
<button onClick={() => navigate('/subscriptions')} className="btn-primary w-full">
{t('deepLink.goToSubscription')}
</button>
</div>

View File

@@ -0,0 +1,233 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Navigate, useNavigate, useParams } from 'react-router';
import { subscriptionApi } from '../api/subscription';
import { useTheme } from '../hooks/useTheme';
import { getGlassColors } from '../utils/glassTheme';
import { useCurrency } from '../hooks/useCurrency';
import { useHaptic } from '../platform';
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
import { WebBackButton } from '../components/WebBackButton';
export default function RenewSubscription() {
const { subscriptionId } = useParams<{ subscriptionId: string }>();
const subId = subscriptionId ? Number(subscriptionId) : undefined;
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { isDark } = useTheme();
const g = getGlassColors(isDark);
const { formatAmount, currencySymbol } = useCurrency();
const { impact } = useHaptic();
const [selectedPeriod, setSelectedPeriod] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
// Load subscription detail for tariff name
const { data: subscriptionResponse } = useQuery({
queryKey: ['subscription', subId],
queryFn: () => subscriptionApi.getSubscription(subId),
enabled: !!subId,
staleTime: 30_000,
});
const subscription = subscriptionResponse?.subscription ?? null;
// Load renewal options
const { data: options, isLoading } = useQuery({
queryKey: ['renewal-options', subId],
queryFn: () => subscriptionApi.getRenewalOptions(subId),
enabled: !!subId,
staleTime: 0,
refetchOnMount: 'always',
});
// Load balance
const { data: purchaseOptions } = useQuery({
queryKey: ['purchase-options', subId],
queryFn: () => subscriptionApi.getPurchaseOptions(subId),
staleTime: 0,
});
const balanceKopeks = purchaseOptions?.balance_kopeks ?? 0;
const renewMutation = useMutation({
mutationFn: (periodDays: number) => subscriptionApi.renewSubscription(periodDays, subId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscription', subId] });
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
queryClient.invalidateQueries({ queryKey: ['renewal-options', subId] });
navigate(`/subscriptions/${subId}`, { replace: true });
},
onError: (err: unknown) => {
const detail =
err && typeof err === 'object' && 'response' in err
? ((err as { response?: { data?: { detail?: unknown } } }).response?.data?.detail ?? null)
: null;
if (detail && typeof detail === 'object' && 'code' in (detail as Record<string, unknown>)) {
const typed = detail as { code: string; missing_amount?: number };
if (typed.code === 'insufficient_funds' && typed.missing_amount) {
setError(`insufficient:${typed.missing_amount}`);
return;
}
}
setError(typeof detail === 'string' ? detail : t('common.error'));
},
});
const handleRenew = (periodDays: number) => {
impact('medium');
setError(null);
renewMutation.mutate(periodDays);
};
if (!subId) {
return <Navigate to="/subscriptions" replace />;
}
if (isLoading) {
return (
<div className="flex min-h-64 items-center justify-center">
<div className="h-10 w-10 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
);
}
const insufficientMatch = error?.match(/^insufficient:(\d+)$/);
const missingAmount = insufficientMatch ? Number(insufficientMatch[1]) : null;
return (
<div className="space-y-5">
{/* Title */}
<div className="flex items-center gap-3">
<WebBackButton to={`/subscriptions/${subId}`} />
<div>
<h1 className="text-2xl font-bold" style={{ color: g.text }}>
{t('subscription.extend', 'Продлить подписку')}
</h1>
{subscription?.tariff_name && (
<p className="mt-1 text-sm" style={{ color: g.textSecondary }}>
{subscription.tariff_name}
</p>
)}
</div>
</div>
{/* Balance */}
<div
className="flex items-center justify-between rounded-2xl p-4"
style={{ background: g.cardBg, border: `1px solid ${g.cardBorder}` }}
>
<span className="text-sm" style={{ color: g.textSecondary }}>
{t('common.balance', 'Баланс')}
</span>
<span className="text-base font-semibold" style={{ color: g.text }}>
{formatAmount(balanceKopeks / 100)} {currencySymbol}
</span>
</div>
{/* Period options */}
{!options || options.length === 0 ? (
<div
className="rounded-2xl p-6 text-center"
style={{ background: g.cardBg, border: `1px solid ${g.cardBorder}` }}
>
<p style={{ color: g.textSecondary }}>
{t('subscription.noRenewalOptions', 'Нет доступных вариантов продления')}
</p>
</div>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{options.map((option) => {
const isSelected = selectedPeriod === option.period_days;
const canAfford = balanceKopeks >= option.price_kopeks;
const months = Math.max(1, Math.round(option.period_days / 30));
const perMonth = option.price_kopeks / months;
return (
<button
key={option.period_days}
onClick={() => {
impact('light');
setSelectedPeriod(option.period_days);
setError(null);
}}
className="w-full rounded-2xl border p-4 text-left transition-all duration-200"
style={{
background: isSelected
? isDark
? 'rgba(var(--color-accent-400), 0.08)'
: 'rgba(var(--color-accent-400), 0.05)'
: g.cardBg,
borderColor: isSelected ? 'rgb(var(--color-accent-400))' : g.cardBorder,
}}
>
<div className="flex items-center justify-between">
<div>
<span className="text-base font-semibold" style={{ color: g.text }}>
{option.period_days} {t('common.units.days', 'дней')}
</span>
{option.discount_percent > 0 && (
<span className="ml-2 rounded-full bg-emerald-400/15 px-2 py-0.5 text-[10px] font-semibold text-emerald-400">
-{option.discount_percent}%
</span>
)}
</div>
<div className="text-right">
<div className="text-base font-semibold" style={{ color: g.text }}>
{formatAmount(option.price_kopeks / 100)} {currencySymbol}
</div>
{months > 1 && (
<div className="text-[11px]" style={{ color: g.textSecondary }}>
{formatAmount(perMonth / 100)} {currencySymbol}/
{t('common.units.mo', 'мес')}
</div>
)}
{option.original_price_kopeks && (
<div className="text-[11px] line-through" style={{ color: g.textSecondary }}>
{formatAmount(option.original_price_kopeks / 100)} {currencySymbol}
</div>
)}
</div>
</div>
{!canAfford && (
<div className="mt-1 text-[11px] text-red-400">
{t(
'subscription.insufficientBalanceAmount',
'Недостаточно средств. Не хватает {{missing}}',
{
missing: `${formatAmount((option.price_kopeks - balanceKopeks) / 100)} ${currencySymbol}`,
},
)}
</div>
)}
</button>
);
})}
</div>
)}
{/* Insufficient balance prompt */}
{missingAmount && <InsufficientBalancePrompt missingAmountKopeks={missingAmount} compact />}
{/* Error */}
{error && !missingAmount && (
<div className="rounded-xl bg-red-400/10 p-3 text-center text-sm text-red-400">{error}</div>
)}
{/* Renew button */}
{selectedPeriod && (
<button
onClick={() => handleRenew(selectedPeriod)}
disabled={renewMutation.isPending}
className="w-full rounded-2xl bg-accent-500 py-3.5 text-base font-semibold text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
>
{renewMutation.isPending
? t('common.processing', 'Обработка...')
: t('subscription.extend', 'Продлить подписку')}
</button>
)}
</div>
);
}

View File

@@ -1,8 +1,11 @@
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 { Navigate, useNavigate, useParams } from 'react-router';
import { subscriptionApi } from '../api/subscription';
import { WebBackButton } from '../components/WebBackButton';
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';
@@ -168,10 +171,16 @@ 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();
const [copied, setCopied] = 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}`;
@@ -194,11 +203,19 @@ 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'],
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 +228,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 +237,43 @@ 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] });
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
},
});
// 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: ['subscriptions-list'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
},
});
@@ -269,17 +289,18 @@ 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: ['subscriptions-list'] });
queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] });
queryClient.invalidateQueries({ queryKey: ['device-price'] });
setShowDeviceTopup(false);
setDevicesToAdd(1);
@@ -288,8 +309,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 +328,29 @@ 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: ['subscriptions-list'] });
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] });
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
setShowTrafficTopup(false);
setSelectedTrafficPackage(null);
},
@@ -335,8 +358,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,30 +373,34 @@ 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: ['subscriptions-list'] });
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,
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 {
setTrafficRefreshCooldown(30);
}
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
},
onError: (error: {
response?: { status?: number; headers?: { get?: (key: string) => string } };
@@ -403,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;
@@ -417,7 +444,7 @@ export default function Subscription() {
}
refreshTrafficMutation.mutate();
}, [subscription, refreshTrafficMutation]);
}, [subscription, refreshTrafficMutation, subscriptionId]);
const copyUrl = () => {
if (subscription?.subscription_url) {
@@ -427,6 +454,11 @@ export default function Subscription() {
}
};
// In multi-tariff mode without a specific subscription ID, redirect to list
if (isMultiTariff && !subscriptionId && !isLoading) {
return <Navigate to="/subscriptions" replace />;
}
if (isLoading) {
return (
<div className="flex min-h-64 items-center justify-center">
@@ -435,9 +467,37 @@ export default function Subscription() {
);
}
if (!subscription && subscriptionId) {
return (
<div className="mx-auto max-w-lg p-4 text-center">
<div className="mb-4 text-4xl">😕</div>
<h2 className="mb-2 text-xl font-bold text-dark-50">
{t('subscription.notFound', 'Подписка не найдена')}
</h2>
<p className="mb-4 text-sm text-dark-50/60">
{t('subscription.notFoundDesc', 'Возможно, подписка была удалена или не существует')}
</p>
<button
onClick={() => navigate('/subscriptions')}
className="rounded-xl bg-accent-500 px-6 py-2.5 text-sm font-medium text-white"
>
{t('subscription.backToList', 'Мои подписки')}
</button>
</div>
);
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('subscription.title')}</h1>
{/* Page title */}
<div className="flex items-center gap-3">
<WebBackButton to={isMultiTariff ? '/subscriptions' : '/'} />
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">
{isMultiTariff && subscription?.tariff_name
? subscription.tariff_name
: t('subscription.title')}
</h1>
</div>
{/* 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 */}
<PurchaseCTAButton subscription={subscription} />
<PurchaseCTAButton subscription={subscription} isMultiTariff={isMultiTariff} />
{/* Delete expired subscription */}
{isMultiTariff && subscription && !subscription.is_active && !subscription.is_trial && (
<div className="space-y-3">
{!showDeleteSheet ? (
<button
onClick={async () => {
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"
>
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
{t('subscription.delete', 'Удалить подписку')}
</button>
) : (
<div
className="rounded-2xl border border-red-400/20 p-4"
style={{ background: 'rgba(255,59,92,0.04)' }}
>
<div className="mb-3 text-sm font-semibold text-red-400">
{t('subscription.deleteTitle', 'Удалить подписку?')}
</div>
<div className="mb-4 text-xs" style={{ color: g.textSecondary }}>
{t(
'subscription.deleteWarning',
'Подписка будет удалена безвозвратно. Все данные, устройства и настройки будут потеряны. Это действие нельзя отменить.',
)}
</div>
<div className="flex gap-2">
<button
onClick={async () => {
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', 'Да, удалить')}
</button>
<button
onClick={() => 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', 'Отмена')}
</button>
</div>
</div>
)}
</div>
)}
{/* 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,
);
}}
/>
)}

View File

@@ -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 (
<div className="space-y-6">
<div className="flex items-center gap-3">
<button
onClick={() => 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}`,
}}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-dark-50/60"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</button>
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">
{t('subscription.extend')}
</h1>
</div>
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('subscription.extend')}</h1>
<div
className="rounded-3xl p-6 text-center"
style={{
@@ -421,33 +408,15 @@ export default function SubscriptionPurchase() {
return (
<div className="space-y-6">
{/* Header with back link */}
{/* Header */}
<div className="flex items-center gap-3">
<button
onClick={() => 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}`,
}}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-dark-50/60"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</button>
<WebBackButton
to={subscriptionId ? `/subscriptions/${subscriptionId}` : '/subscriptions'}
/>
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">
{subscription?.is_daily && !subscription?.is_trial
{isMultiTariff && !subscriptionId
? t('subscription.newTariff', 'Новый тариф')
: !isMultiTariff && subscription?.is_daily && !subscription?.is_trial
? t('subscription.switchTariff.title')
: subscription && !subscription.is_trial
? t('subscription.extend')
@@ -748,9 +717,37 @@ export default function SubscriptionPurchase() {
)}
{/* Tariff Grid */}
{isMultiTariff &&
purchaseOptions &&
'all_tariffs_purchased' in purchaseOptions &&
purchaseOptions.all_tariffs_purchased && (
<div
className="rounded-2xl border p-6 text-center"
style={{ background: g.cardBg, borderColor: g.cardBorder }}
>
<div className="mb-2 text-3xl"></div>
<h3 className="mb-1 text-lg font-semibold" style={{ color: g.text }}>
{t('subscription.allTariffsPurchased', 'Все тарифы подключены')}
</h3>
<p className="mb-4 text-sm" style={{ color: g.textSecondary }}>
{t(
'subscription.allTariffsPurchasedDesc',
'Вы уже приобрели все доступные тарифы. Продлить подписку можно на странице тарифа.',
)}
</p>
<button
onClick={() => 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', 'Мои подписки')}
</button>
</div>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{[...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 &&

137
src/pages/Subscriptions.tsx Normal file
View File

@@ -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 (
<div
className="rounded-2xl border p-10 text-center"
style={{ background: g.cardBg, borderColor: g.cardBorder }}
>
<div
className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl"
style={{ background: g.innerBg }}
>
<svg
className="h-8 w-8 opacity-40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V19.5a2.25 2.25 0 002.25 2.25h.75"
/>
</svg>
</div>
<h3 className="mb-2 text-xl font-semibold" style={{ color: g.text }}>
{t('subscriptions.empty', 'Нет подписок')}
</h3>
<p className="mb-6 text-sm" style={{ color: g.textSecondary }}>
{t('subscriptions.emptyDesc', 'У вас пока нет активных подписок')}
</p>
<button
onClick={onBuy}
className="rounded-xl bg-accent-500 px-8 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-600"
>
{t('subscriptions.buy', 'Купить подписку')}
</button>
</div>
);
}
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 <Navigate to={`/subscriptions/${subscriptions[0].id}`} replace />;
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold" style={{ color: g.text }}>
{t('subscriptions.title', 'Мои подписки')}
</h1>
{!isLoading && subscriptions.length > 0 && (
<button
onClick={() => 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)',
}}
>
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
{t('subscriptions.buyAnother', 'Новый тариф')}
</button>
)}
</div>
{/* Loading */}
{isLoading && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{[1, 2].map((i) => (
<div
key={i}
className="h-36 animate-pulse rounded-2xl"
style={{ background: g.innerBg }}
/>
))}
</div>
)}
{/* Empty state */}
{!isLoading && subscriptions.length === 0 && (
<EmptyState onBuy={() => navigate('/subscription/purchase')} />
)}
{/* Subscription grid */}
{subscriptions.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{subscriptions.map((sub) => (
<SubscriptionListCard
key={sub.id}
subscription={sub}
onClick={() => navigate(`/subscriptions/${sub.id}`)}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -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) {

View File

@@ -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<number | null>(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() {
</div>
)}
{/* Subscription selector for days payment in multi-tariff */}
{paymentType === 'subscription_days' &&
config.eligible_subscriptions &&
config.eligible_subscriptions.length > 1 && (
<div className="rounded-xl border border-dark-700/30 bg-dark-800/30 p-3">
<p className="mb-2 text-center text-xs text-dark-400">
{t('wheel.selectSubscription', 'Выберите подписку')}
</p>
<div className="space-y-1.5">
{config.eligible_subscriptions.map((sub) => (
<button
key={sub.id}
onClick={() => 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'
}`}
>
<span className="font-medium">
{sub.tariff_name || t('subscription.defaultName', 'Подписка')}
</span>
<span className="text-xs opacity-60">
{sub.days_left} {t('common.units.days', 'дней')}
</span>
</button>
))}
</div>
</div>
)}
{/* Stars confirmation panel */}
{showStarsConfirm && !isSpinning && !isPayingStars ? (
<div className="space-y-3 rounded-xl border border-accent-500/30 bg-accent-500/5 p-4">
@@ -645,6 +690,14 @@ export default function Wheel() {
<p className="text-dark-400">{t('wheel.errors.dailyLimitReached')}</p>
</div>
)}
{/* Subscription selection required hint */}
{!isSpinning && needsSubscriptionPick && (
<div className="rounded-linear border border-warning-500/30 bg-warning-500/5 p-4 text-center">
<p className="text-warning-400">
{t('wheel.errors.selectSubscription', 'Выберите подписку для списания дней')}
</p>
</div>
)}
{/* Inline Result Card */}
{spinResult && !isSpinning && (

View File

@@ -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;

View File

@@ -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 {