mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
Merge pull request #359 from BEDOLAGA-DEV/feat/multi-subscription
feat: multi-subscription frontend support
This commit is contained in:
4602
package-lock.json
generated
4602
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
42
src/App.tsx
42
src/App.tsx
@@ -1,5 +1,5 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router';
|
||||
import { Routes, Route, Navigate, useLocation, useParams } from 'react-router';
|
||||
import { useAuthStore } from './store/auth';
|
||||
import { useBlockingStore } from './store/blocking';
|
||||
import Layout from './components/layout/Layout';
|
||||
@@ -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={
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -190,7 +190,7 @@ export default function SuccessNotificationModal() {
|
||||
|
||||
const handleGoToSubscription = () => {
|
||||
hide();
|
||||
navigate('/subscription');
|
||||
navigate('/subscriptions');
|
||||
};
|
||||
|
||||
const handleGoToBalance = () => {
|
||||
|
||||
32
src/components/WebBackButton.tsx
Normal file
32
src/components/WebBackButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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')} →
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
225
src/components/subscription/SubscriptionListCard.tsx
Normal file
225
src/components/subscription/SubscriptionListCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
233
src/pages/RenewSubscription.tsx
Normal file
233
src/pages/RenewSubscription.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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
137
src/pages/Subscriptions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user