Merge pull request #367 from BEDOLAGA-DEV/dev

Dev
This commit is contained in:
Egor
2026-03-29 07:57:47 +03:00
committed by GitHub
77 changed files with 6092 additions and 6531 deletions

4602
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,6 +1,25 @@
import { lazy, Suspense } from 'react';
import { Routes, Route, Navigate, useLocation } from 'react-router';
import { lazy, Suspense, type ComponentType } from 'react';
import { Routes, Route, Navigate, useLocation, useParams } from 'react-router';
import { useAuthStore } from './store/auth';
/**
* Wrapper around React.lazy that auto-reloads the page when a chunk fails to load
* (e.g. after a new deploy with different chunk hashes).
*/
function lazyWithRetry<T extends ComponentType<unknown>>(factory: () => Promise<{ default: T }>) {
return lazy(() =>
factory().catch(() => {
const key = 'chunk_reload_ts';
const last = Number(sessionStorage.getItem(key) || '0');
if (Date.now() - last > 30_000) {
sessionStorage.setItem(key, String(Date.now()));
window.location.reload();
}
// Re-throw so ErrorBoundary catches it if reload guard prevents loop
return factory();
}),
);
}
import { useBlockingStore } from './store/blocking';
import Layout from './components/layout/Layout';
import PageLoader from './components/common/PageLoader';
@@ -26,101 +45,107 @@ import OAuthCallback from './pages/OAuthCallback';
import Dashboard from './pages/Dashboard';
// User pages - lazy load
const Subscription = lazy(() => import('./pages/Subscription'));
const SubscriptionPurchase = lazy(() => import('./pages/SubscriptionPurchase'));
const Balance = lazy(() => import('./pages/Balance'));
const SavedCards = lazy(() => import('./pages/SavedCards'));
const Referral = lazy(() => import('./pages/Referral'));
const Support = lazy(() => import('./pages/Support'));
const Profile = lazy(() => import('./pages/Profile'));
const Contests = lazy(() => import('./pages/Contests'));
const Polls = lazy(() => import('./pages/Polls'));
const Info = lazy(() => import('./pages/Info'));
const Wheel = lazy(() => import('./pages/Wheel'));
const GiftSubscription = lazy(() => import('./pages/GiftSubscription'));
const GiftResult = lazy(() => import('./pages/GiftResult'));
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 AutoLogin = lazy(() => import('./pages/AutoLogin'));
const TopUpMethodSelect = lazy(() => import('./pages/TopUpMethodSelect'));
const TopUpAmount = lazy(() => import('./pages/TopUpAmount'));
const TopUpResult = lazy(() => import('./pages/TopUpResult'));
const ConnectedAccounts = lazy(() => import('./pages/ConnectedAccounts'));
const LinkTelegramCallback = lazy(() => import('./pages/LinkTelegramCallback'));
const MergeAccounts = lazy(() => import('./pages/MergeAccounts'));
const Subscriptions = lazyWithRetry(() => import('./pages/Subscriptions'));
const Subscription = lazyWithRetry(() => import('./pages/Subscription'));
const SubscriptionPurchase = lazyWithRetry(() => import('./pages/SubscriptionPurchase'));
const Balance = lazyWithRetry(() => import('./pages/Balance'));
const SavedCards = lazyWithRetry(() => import('./pages/SavedCards'));
const Referral = lazyWithRetry(() => import('./pages/Referral'));
const Support = lazyWithRetry(() => import('./pages/Support'));
const Profile = lazyWithRetry(() => import('./pages/Profile'));
const Contests = lazyWithRetry(() => import('./pages/Contests'));
const Polls = lazyWithRetry(() => import('./pages/Polls'));
const Info = lazyWithRetry(() => import('./pages/Info'));
const Wheel = lazyWithRetry(() => import('./pages/Wheel'));
const GiftSubscription = lazyWithRetry(() => import('./pages/GiftSubscription'));
const GiftResult = lazyWithRetry(() => import('./pages/GiftResult'));
const Connection = lazyWithRetry(() => import('./pages/Connection'));
const ConnectionQR = lazyWithRetry(() => import('./pages/ConnectionQR'));
const QuickPurchase = lazyWithRetry(() => import('./pages/QuickPurchase'));
const PurchaseSuccess = lazyWithRetry(() => import('./pages/PurchaseSuccess'));
const RenewSubscription = lazyWithRetry(() => import('./pages/RenewSubscription'));
const AutoLogin = lazyWithRetry(() => import('./pages/AutoLogin'));
const TopUpMethodSelect = lazyWithRetry(() => import('./pages/TopUpMethodSelect'));
const TopUpAmount = lazyWithRetry(() => import('./pages/TopUpAmount'));
const TopUpResult = lazyWithRetry(() => import('./pages/TopUpResult'));
const ConnectedAccounts = lazyWithRetry(() => import('./pages/ConnectedAccounts'));
const LinkTelegramCallback = lazyWithRetry(() => import('./pages/LinkTelegramCallback'));
const MergeAccounts = lazyWithRetry(() => import('./pages/MergeAccounts'));
// Admin pages - lazy load (only for admins)
const AdminPanel = lazy(() => import('./pages/AdminPanel'));
const AdminTickets = lazy(() => import('./pages/AdminTickets'));
const AdminTicketSettings = lazy(() => import('./pages/AdminTicketSettings'));
const AdminSettings = lazy(() => import('./pages/AdminSettings'));
const AdminApps = lazy(() => import('./pages/AdminApps'));
const AdminWheel = lazy(() => import('./pages/AdminWheel'));
const AdminTariffs = lazy(() => import('./pages/AdminTariffs'));
const AdminTariffCreate = lazy(() => import('./pages/AdminTariffCreate'));
const AdminServers = lazy(() => import('./pages/AdminServers'));
const AdminServerEdit = lazy(() => import('./pages/AdminServerEdit'));
const AdminDashboard = lazy(() => import('./pages/AdminDashboard'));
const AdminBanSystem = lazy(() => import('./pages/AdminBanSystem'));
const AdminBroadcasts = lazy(() => import('./pages/AdminBroadcasts'));
const AdminBroadcastCreate = lazy(() => import('./pages/AdminBroadcastCreate'));
const AdminPromocodes = lazy(() => import('./pages/AdminPromocodes'));
const AdminPromocodeCreate = lazy(() => import('./pages/AdminPromocodeCreate'));
const AdminPromocodeStats = lazy(() => import('./pages/AdminPromocodeStats'));
const AdminPromoGroups = lazy(() => import('./pages/AdminPromoGroups'));
const AdminPromoGroupCreate = lazy(() => import('./pages/AdminPromoGroupCreate'));
const AdminCampaigns = lazy(() => import('./pages/AdminCampaigns'));
const AdminCampaignCreate = lazy(() => import('./pages/AdminCampaignCreate'));
const AdminCampaignStats = lazy(() => import('./pages/AdminCampaignStats'));
const AdminCampaignEdit = lazy(() => import('./pages/AdminCampaignEdit'));
const AdminPartners = lazy(() => import('./pages/AdminPartners'));
const AdminPartnerSettings = lazy(() => import('./pages/AdminPartnerSettings'));
const AdminPartnerDetail = lazy(() => import('./pages/AdminPartnerDetail'));
const AdminApplicationReview = lazy(() => import('./pages/AdminApplicationReview'));
const AdminPartnerCommission = lazy(() => import('./pages/AdminPartnerCommission'));
const AdminPartnerRevoke = lazy(() => import('./pages/AdminPartnerRevoke'));
const AdminPartnerCampaignAssign = lazy(() => import('./pages/AdminPartnerCampaignAssign'));
const AdminWithdrawals = lazy(() => import('./pages/AdminWithdrawals'));
const AdminWithdrawalDetail = lazy(() => import('./pages/AdminWithdrawalDetail'));
const AdminWithdrawalReject = lazy(() => import('./pages/AdminWithdrawalReject'));
const ReferralPartnerApply = lazy(() => import('./pages/ReferralPartnerApply'));
const ReferralWithdrawalRequest = lazy(() => import('./pages/ReferralWithdrawalRequest'));
const AdminUsers = lazy(() => import('./pages/AdminUsers'));
const AdminPayments = lazy(() => import('./pages/AdminPayments'));
const AdminPaymentMethods = lazy(() => import('./pages/AdminPaymentMethods'));
const AdminPaymentMethodEdit = lazy(() => import('./pages/AdminPaymentMethodEdit'));
const AdminPromoOffers = lazy(() => import('./pages/AdminPromoOffers'));
const AdminPromoOfferTemplateEdit = lazy(() => import('./pages/AdminPromoOfferTemplateEdit'));
const AdminPromoOfferSend = lazy(() => import('./pages/AdminPromoOfferSend'));
const AdminRemnawave = lazy(() => import('./pages/AdminRemnawave'));
const AdminRemnawaveSquadDetail = lazy(() => import('./pages/AdminRemnawaveSquadDetail'));
const AdminEmailTemplates = lazy(() => import('./pages/AdminEmailTemplates'));
const AdminTrafficUsage = lazy(() => import('./pages/AdminTrafficUsage'));
const AdminSalesStats = lazy(() => import('./pages/AdminSalesStats'));
const AdminUpdates = lazy(() => import('./pages/AdminUpdates'));
const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail'));
const AdminBroadcastDetail = lazy(() => import('./pages/AdminBroadcastDetail'));
const AdminPinnedMessages = lazy(() => import('./pages/AdminPinnedMessages'));
const AdminPinnedMessageCreate = lazy(() => import('./pages/AdminPinnedMessageCreate'));
const AdminChannelSubscriptions = lazy(() => import('./pages/AdminChannelSubscriptions'));
const AdminEmailTemplatePreview = lazy(() => import('./pages/AdminEmailTemplatePreview'));
const AdminRoles = lazy(() => import('./pages/AdminRoles'));
const AdminRoleEdit = lazy(() => import('./pages/AdminRoleEdit'));
const AdminRoleAssign = lazy(() => import('./pages/AdminRoleAssign'));
const AdminPolicies = lazy(() => import('./pages/AdminPolicies'));
const AdminPolicyEdit = lazy(() => import('./pages/AdminPolicyEdit'));
const AdminAuditLog = lazy(() => import('./pages/AdminAuditLog'));
const AdminLandings = lazy(() => import('./pages/AdminLandings'));
const AdminLandingEditor = lazy(() => import('./pages/AdminLandingEditor'));
const AdminLandingStats = lazy(() => import('./pages/AdminLandingStats'));
const AdminReferralNetwork = lazy(() => import('./pages/ReferralNetwork'));
const AdminPanel = lazyWithRetry(() => import('./pages/AdminPanel'));
const AdminTickets = lazyWithRetry(() => import('./pages/AdminTickets'));
const AdminTicketSettings = lazyWithRetry(() => import('./pages/AdminTicketSettings'));
const AdminSettings = lazyWithRetry(() => import('./pages/AdminSettings'));
const AdminApps = lazyWithRetry(() => import('./pages/AdminApps'));
const AdminWheel = lazyWithRetry(() => import('./pages/AdminWheel'));
const AdminTariffs = lazyWithRetry(() => import('./pages/AdminTariffs'));
const AdminTariffCreate = lazyWithRetry(() => import('./pages/AdminTariffCreate'));
const AdminServers = lazyWithRetry(() => import('./pages/AdminServers'));
const AdminServerEdit = lazyWithRetry(() => import('./pages/AdminServerEdit'));
const AdminDashboard = lazyWithRetry(() => import('./pages/AdminDashboard'));
const AdminBanSystem = lazyWithRetry(() => import('./pages/AdminBanSystem'));
const AdminBroadcasts = lazyWithRetry(() => import('./pages/AdminBroadcasts'));
const AdminBroadcastCreate = lazyWithRetry(() => import('./pages/AdminBroadcastCreate'));
const AdminPromocodes = lazyWithRetry(() => import('./pages/AdminPromocodes'));
const AdminPromocodeCreate = lazyWithRetry(() => import('./pages/AdminPromocodeCreate'));
const AdminPromocodeStats = lazyWithRetry(() => import('./pages/AdminPromocodeStats'));
const AdminPromoGroups = lazyWithRetry(() => import('./pages/AdminPromoGroups'));
const AdminPromoGroupCreate = lazyWithRetry(() => import('./pages/AdminPromoGroupCreate'));
const AdminCampaigns = lazyWithRetry(() => import('./pages/AdminCampaigns'));
const AdminCampaignCreate = lazyWithRetry(() => import('./pages/AdminCampaignCreate'));
const AdminCampaignStats = lazyWithRetry(() => import('./pages/AdminCampaignStats'));
const AdminCampaignEdit = lazyWithRetry(() => import('./pages/AdminCampaignEdit'));
const AdminPartners = lazyWithRetry(() => import('./pages/AdminPartners'));
const AdminPartnerSettings = lazyWithRetry(() => import('./pages/AdminPartnerSettings'));
const AdminPartnerDetail = lazyWithRetry(() => import('./pages/AdminPartnerDetail'));
const AdminApplicationReview = lazyWithRetry(() => import('./pages/AdminApplicationReview'));
const AdminPartnerCommission = lazyWithRetry(() => import('./pages/AdminPartnerCommission'));
const AdminPartnerRevoke = lazyWithRetry(() => import('./pages/AdminPartnerRevoke'));
const AdminPartnerCampaignAssign = lazyWithRetry(
() => import('./pages/AdminPartnerCampaignAssign'),
);
const AdminWithdrawals = lazyWithRetry(() => import('./pages/AdminWithdrawals'));
const AdminWithdrawalDetail = lazyWithRetry(() => import('./pages/AdminWithdrawalDetail'));
const AdminWithdrawalReject = lazyWithRetry(() => import('./pages/AdminWithdrawalReject'));
const ReferralPartnerApply = lazyWithRetry(() => import('./pages/ReferralPartnerApply'));
const ReferralWithdrawalRequest = lazyWithRetry(() => import('./pages/ReferralWithdrawalRequest'));
const AdminUsers = lazyWithRetry(() => import('./pages/AdminUsers'));
const AdminPayments = lazyWithRetry(() => import('./pages/AdminPayments'));
const AdminPaymentMethods = lazyWithRetry(() => import('./pages/AdminPaymentMethods'));
const AdminPaymentMethodEdit = lazyWithRetry(() => import('./pages/AdminPaymentMethodEdit'));
const AdminPromoOffers = lazyWithRetry(() => import('./pages/AdminPromoOffers'));
const AdminPromoOfferTemplateEdit = lazyWithRetry(
() => import('./pages/AdminPromoOfferTemplateEdit'),
);
const AdminPromoOfferSend = lazyWithRetry(() => import('./pages/AdminPromoOfferSend'));
const AdminRemnawave = lazyWithRetry(() => import('./pages/AdminRemnawave'));
const AdminRemnawaveSquadDetail = lazyWithRetry(() => import('./pages/AdminRemnawaveSquadDetail'));
const AdminEmailTemplates = lazyWithRetry(() => import('./pages/AdminEmailTemplates'));
const AdminTrafficUsage = lazyWithRetry(() => import('./pages/AdminTrafficUsage'));
const AdminSalesStats = lazyWithRetry(() => import('./pages/AdminSalesStats'));
const AdminUpdates = lazyWithRetry(() => import('./pages/AdminUpdates'));
const AdminUserDetail = lazyWithRetry(() => import('./pages/AdminUserDetail'));
const AdminBroadcastDetail = lazyWithRetry(() => import('./pages/AdminBroadcastDetail'));
const AdminPinnedMessages = lazyWithRetry(() => import('./pages/AdminPinnedMessages'));
const AdminPinnedMessageCreate = lazyWithRetry(() => import('./pages/AdminPinnedMessageCreate'));
const AdminChannelSubscriptions = lazyWithRetry(() => import('./pages/AdminChannelSubscriptions'));
const AdminEmailTemplatePreview = lazyWithRetry(() => import('./pages/AdminEmailTemplatePreview'));
const AdminRoles = lazyWithRetry(() => import('./pages/AdminRoles'));
const AdminRoleEdit = lazyWithRetry(() => import('./pages/AdminRoleEdit'));
const AdminRoleAssign = lazyWithRetry(() => import('./pages/AdminRoleAssign'));
const AdminPolicies = lazyWithRetry(() => import('./pages/AdminPolicies'));
const AdminPolicyEdit = lazyWithRetry(() => import('./pages/AdminPolicyEdit'));
const AdminAuditLog = lazyWithRetry(() => import('./pages/AdminAuditLog'));
const AdminLandings = lazyWithRetry(() => import('./pages/AdminLandings'));
const AdminLandingEditor = lazyWithRetry(() => import('./pages/AdminLandingEditor'));
const AdminLandingStats = lazyWithRetry(() => import('./pages/AdminLandingStats'));
const AdminReferralNetwork = lazyWithRetry(() => import('./pages/ReferralNetwork'));
// News pages
const NewsArticlePage = lazy(() => import('./pages/NewsArticle'));
const AdminNews = lazy(() => import('./pages/AdminNews'));
const AdminNewsCreate = lazy(() => import('./pages/AdminNewsCreate'));
const NewsArticlePage = lazyWithRetry(() => import('./pages/NewsArticle'));
const AdminNews = lazyWithRetry(() => import('./pages/AdminNews'));
const AdminNewsCreate = lazyWithRetry(() => import('./pages/AdminNewsCreate'));
function ProtectedRoute({
children,
@@ -190,6 +215,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 +289,17 @@ function App() {
}
/>
<Route
path="/subscription"
path="/subscriptions"
element={
<ProtectedRoute>
<LazyPage>
<Subscriptions />
</LazyPage>
</ProtectedRoute>
}
/>
<Route
path="/subscriptions/:subscriptionId"
element={
<ProtectedRoute>
<LazyPage>
@@ -267,6 +308,26 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/subscriptions/:subscriptionId/renew"
element={
<ProtectedRoute>
<LazyPage>
<RenewSubscription />
</LazyPage>
</ProtectedRoute>
}
/>
{/* Legacy redirects for backward compatibility */}
<Route path="/subscription/:subscriptionId" element={<LegacySubscriptionRedirect />} />
<Route
path="/subscription"
element={
<ProtectedRoute>
<Navigate to="/subscriptions" replace />
</ProtectedRoute>
}
/>
<Route
path="/subscription/purchase"
element={

View File

@@ -23,7 +23,7 @@ const TWEMOJI_OPTIONS = { className: 'twemoji', folder: 'svg', ext: '.svg' } as
* Shows back button on non-root routes, hides on root.
*/
/** Pages reachable from bottom nav — treat as top-level (no back button). */
const BOTTOM_NAV_PATHS = ['/', '/subscription', '/balance', '/referral', '/support', '/wheel'];
const BOTTOM_NAV_PATHS = ['/', '/subscriptions', '/balance', '/referral', '/support', '/wheel'];
function TelegramBackButton() {
const location = useLocation();

View File

@@ -162,15 +162,11 @@ export interface NodeStatus {
is_disabled: boolean;
users_online: number;
traffic_used_bytes?: number;
uptime?: string;
xray_version?: string;
node_version?: string;
last_status_message?: string;
xray_uptime?: string;
xray_uptime: number;
is_xray_running?: boolean;
cpu_count?: number;
cpu_model?: string;
total_ram?: string;
versions?: { xray: string; node: string } | null;
system?: Record<string, unknown> | null;
country_code?: string;
}

View File

@@ -29,11 +29,9 @@ export interface SystemSummary {
export interface ServerInfo {
cpu_cores: number;
cpu_physical_cores: number;
memory_total: number;
memory_used: number;
memory_free: number;
memory_available: number;
uptime_seconds: number;
}
@@ -78,22 +76,60 @@ export interface NodeInfo {
is_disabled: boolean;
is_node_online: boolean;
is_xray_running: boolean;
users_online?: number;
users_online: number;
traffic_used_bytes?: number;
traffic_limit_bytes?: number;
last_status_change?: string;
last_status_message?: string;
xray_uptime?: string;
xray_uptime: number;
is_traffic_tracking_active: boolean;
traffic_reset_day?: number;
notify_percent?: number;
consumption_multiplier: number;
cpu_count?: number;
cpu_model?: string;
total_ram?: string;
created_at?: string;
updated_at?: string;
provider_uuid?: string;
versions?: { xray: string; node: string } | null;
system?: {
info: {
arch: string;
cpus: number;
cpuModel: string;
memoryTotal: number;
hostname: string;
platform: string;
release: string;
type: string;
version: string;
networkInterfaces: string[];
};
stats: {
memoryFree: number;
memoryUsed: number;
uptime: number;
loadAvg: number[];
interface?: {
interface: string;
rxBytesPerSec: number;
txBytesPerSec: number;
rxTotal: number;
txTotal: number;
} | null;
};
} | null;
active_plugin_uuid?: string;
config_profile?: {
active_config_profile_uuid: string | null;
active_inbounds: Array<{
uuid: string;
profile_uuid: string;
tag: string;
type: string;
network: string | null;
security: string | null;
port: number | null;
}>;
};
}
export interface NodesListResponse {
@@ -123,6 +159,27 @@ export interface NodeActionResponse {
is_disabled?: boolean;
}
// Realtime Traffic
export interface InboundTraffic {
tag: string;
downloadBytes: number;
uploadBytes: number;
totalBytes: number;
}
export interface NodeRealtimeStats {
nodeUuid: string;
nodeName: string;
countryEmoji?: string;
providerName?: string;
downloadBytes: number;
uploadBytes: number;
totalBytes: number;
usersOnline: number;
inbounds?: InboundTraffic[];
outbounds?: InboundTraffic[];
}
// Squads
export interface SquadWithLocalInfo {
uuid: string;
@@ -246,7 +303,7 @@ export const adminRemnawaveApi = {
return response.data;
},
getNodesRealtime: async (): Promise<Record<string, unknown>[]> => {
getNodesRealtime: async (): Promise<NodeRealtimeStats[]> => {
const response = await apiClient.get('/cabinet/admin/remnawave/nodes/realtime');
return response.data;
},

View File

@@ -103,6 +103,7 @@ export interface UserDetailResponse {
last_activity: string | null;
cabinet_last_login: string | null;
subscription: UserSubscriptionInfo | null;
subscriptions: UserSubscriptionInfo[];
promo_group: UserPromoGroupInfo | null;
referral: UserReferralInfo;
total_spent_kopeks: number;
@@ -250,6 +251,8 @@ export interface PanelSyncStatusResponse {
user_id: number;
telegram_id: number;
remnawave_uuid: string | null;
subscription_id: number | null;
subscription_tariff_name: string | null;
last_sync: string | null;
bot_subscription_status: string | null;
bot_subscription_end_date: string | null;
@@ -296,6 +299,7 @@ export interface UpdateSubscriptionRequest {
| 'remove_traffic'
| 'set_device_limit'
| 'shorten';
subscription_id?: number;
days?: number;
end_date?: string;
tariff_id?: number;
@@ -532,6 +536,34 @@ export const adminUsersApi = {
return response.data;
},
// Assign a referrer to this user
assignReferrer: async (
userId: number,
referrerId: number,
): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/assign-referrer`, {
referrer_id: referrerId,
});
return response.data;
},
// Remove this user's referrer
removeReferrer: async (userId: number): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.delete(`/cabinet/admin/users/${userId}/referrer`);
return response.data;
},
// Remove a specific referral from this user's list
removeReferral: async (
userId: number,
referralUserId: number,
): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.delete(
`/cabinet/admin/users/${userId}/referrals/${referralUserId}`,
);
return response.data;
},
// Get referrals
getReferrals: async (userId: number, offset = 0, limit = 50): Promise<UsersListResponse> => {
const response = await apiClient.get(`/cabinet/admin/users/${userId}/referrals`, {
@@ -559,8 +591,12 @@ export const adminUsersApi = {
},
// Sync status
getSyncStatus: async (userId: number): Promise<PanelSyncStatusResponse> => {
const response = await apiClient.get(`/cabinet/admin/users/${userId}/sync/status`);
getSyncStatus: async (
userId: number,
subscriptionId?: number,
): Promise<PanelSyncStatusResponse> => {
const params = subscriptionId != null ? { subscription_id: subscriptionId } : undefined;
const response = await apiClient.get(`/cabinet/admin/users/${userId}/sync/status`, { params });
return response.data;
},
@@ -568,8 +604,12 @@ export const adminUsersApi = {
syncFromPanel: async (
userId: number,
data: SyncFromPanelRequest = {},
subscriptionId?: number,
): Promise<SyncFromPanelResponse> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/sync/from-panel`, data);
const params = subscriptionId != null ? { subscription_id: subscriptionId } : undefined;
const response = await apiClient.post(`/cabinet/admin/users/${userId}/sync/from-panel`, data, {
params,
});
return response.data;
},
@@ -577,8 +617,12 @@ export const adminUsersApi = {
syncToPanel: async (
userId: number,
data: SyncToPanelRequest = {},
subscriptionId?: number,
): Promise<SyncToPanelResponse> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/sync/to-panel`, data);
const params = subscriptionId != null ? { subscription_id: subscriptionId } : undefined;
const response = await apiClient.post(`/cabinet/admin/users/${userId}/sync/to-panel`, data, {
params,
});
return response.data;
},
@@ -601,26 +645,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 +679,22 @@ export const adminUsersApi = {
deleteUserDevice: async (
userId: number,
hwid: string,
subscriptionId?: number,
): Promise<{ success: boolean; message: string; deleted_hwid: string | null }> => {
const response = await apiClient.delete(`/cabinet/admin/users/${userId}/devices/${hwid}`);
const response = await apiClient.delete(`/cabinet/admin/users/${userId}/devices/${hwid}`, {
params: subscriptionId != null ? { subscription_id: subscriptionId } : undefined,
});
return response.data;
},
// Reset all devices
resetUserDevices: async (
userId: number,
subscriptionId?: number,
): Promise<{ success: boolean; message: string; deleted_count: number }> => {
const response = await apiClient.delete(`/cabinet/admin/users/${userId}/devices`);
const response = await apiClient.delete(`/cabinet/admin/users/${userId}/devices`, {
params: subscriptionId != null ? { subscription_id: subscriptionId } : undefined,
});
return response.data;
},

View File

@@ -78,7 +78,12 @@ export const authApi = {
registerEmail: async (
email: string,
password: string,
): Promise<{ message: string; email: string }> => {
): Promise<{
message: string;
email?: string;
merge_required?: boolean;
merge_token?: string;
}> => {
const response = await apiClient.post('/cabinet/auth/email/register', {
email,
password,

View File

@@ -1,4 +1,5 @@
import apiClient from './client';
import i18n from '../i18n';
import type {
Balance,
Transaction,
@@ -54,6 +55,7 @@ export const balanceApi = {
amount_kopeks: number;
payment_method: string;
payment_option?: string;
language?: string;
} = {
amount_kopeks: amountKopeks,
payment_method: paymentMethod,
@@ -61,6 +63,7 @@ export const balanceApi = {
if (paymentOption) {
payload.payment_option = paymentOption;
}
payload.language = i18n.language || 'ru';
const response = await apiClient.post('/cabinet/balance/topup', payload);
return response.data;
},
@@ -68,14 +71,21 @@ export const balanceApi = {
// Activate promo code
activatePromocode: async (
code: string,
subscriptionId?: number,
): Promise<{
success: boolean;
message: string;
balance_before: number;
balance_after: number;
bonus_description: string | null;
message?: string;
balance_before?: number;
balance_after?: number;
bonus_description?: string | null;
error?: string;
eligible_subscriptions?: Array<{ id: number; tariff_name: string; days_left: number }>;
code?: string;
}> => {
const response = await apiClient.post('/cabinet/promocode/activate', { code });
const response = await apiClient.post('/cabinet/promocode/activate', {
code,
...(subscriptionId ? { subscription_id: subscriptionId } : {}),
});
return response.data;
},

View File

@@ -101,6 +101,7 @@ export interface PurchaseRequest {
gift_recipient_type?: 'email' | 'telegram';
gift_recipient_value?: string;
gift_message?: string;
language?: string;
}
export interface PurchaseResponse {

View File

@@ -25,6 +25,8 @@ export interface PromoCode {
valid_from: string;
valid_until: string | null;
promo_group_id: number | null;
tariff_id: number | null;
tariff_name: string | null;
created_by: number | null;
created_at: string;
updated_at: string;
@@ -63,6 +65,7 @@ export interface PromoCodeCreateRequest {
is_active?: boolean;
first_purchase_only?: boolean;
promo_group_id?: number | null;
tariff_id?: number | null;
}
export interface PromoCodeUpdateRequest {
@@ -76,6 +79,7 @@ export interface PromoCodeUpdateRequest {
is_active?: boolean;
first_purchase_only?: boolean;
promo_group_id?: number | null;
tariff_id?: number | null;
}
// ============== PromoGroup Types ==============

View File

@@ -2,6 +2,8 @@ import apiClient from './client';
import type {
Subscription,
SubscriptionStatusResponse,
SubscriptionListItem,
SubscriptionsListResponse,
RenewalOption,
TrafficPackage,
TrialInfo,
@@ -11,50 +13,162 @@ 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',
{},
withSubId(subscriptionId),
);
return response.data;
},
// ── Devices ─────────────────────────────────────────────────────────
purchaseDevices: async (
devices: number,
subscriptionId?: number,
): Promise<{
success: boolean;
message: string;
@@ -65,12 +179,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 +202,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 +233,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 +335,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 +393,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 +416,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 +433,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 +455,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 +477,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 +505,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 +530,29 @@ export const subscriptionApi = {
balance_kopeks: number;
balance_label: string;
}> => {
const response = await apiClient.post('/cabinet/subscription/tariff/switch', {
tariff_id: tariffId,
period_days: 30,
});
const response = await apiClient.post(
'/cabinet/subscription/tariff/switch',
...bodyWithSubId({ tariff_id: tariffId, period_days: 30 }, subscriptionId),
);
return response.data;
},
togglePause: async (): Promise<{
// ── Daily subscription ──────────────────────────────────────────────
togglePause: async (
subscriptionId?: number,
): Promise<{
success: boolean;
message: string;
is_paused: boolean;
balance_kopeks: number;
balance_label: string;
}> => {
const response = await apiClient.post('/cabinet/subscription/pause');
return response.data;
},
switchTraffic: async (
gb: number,
): Promise<{
success: boolean;
message: string;
old_traffic_gb: number;
new_traffic_gb: number;
charged_kopeks: number;
balance_kopeks: number;
balance_label: string;
}> => {
const response = await apiClient.put('/cabinet/subscription/traffic', { gb });
return response.data;
},
// Refresh traffic usage from RemnaWave (rate limited: 1 per 60 seconds)
refreshTraffic: async (): Promise<{
success: boolean;
cached: boolean;
rate_limited?: boolean;
retry_after_seconds?: number;
source?: string;
traffic_used_bytes: number;
traffic_used_gb: number;
traffic_limit_bytes: number;
traffic_limit_gb: number;
traffic_used_percent: number;
is_unlimited: boolean;
lifetime_used_bytes?: number;
lifetime_used_gb?: number;
}> => {
const response = await apiClient.post('/cabinet/subscription/refresh-traffic');
const response = await apiClient.post(
'/cabinet/subscription/pause',
undefined,
withSubId(subscriptionId),
);
return response.data;
},
};

View File

@@ -86,7 +86,7 @@ export interface TariffDetail {
is_daily: boolean;
daily_price_kopeks: number;
// Режим сброса трафика
traffic_reset_mode: string | null; // 'DAY', 'WEEK', 'MONTH', 'NO_RESET', null = глобальная настройка
traffic_reset_mode: string | null; // 'DAY', 'WEEK', 'MONTH', 'MONTH_ROLLING', 'NO_RESET', null = глобальная настройка
// Внешний сквад RemnaWave
external_squad_uuid: string | null;
created_at: string;

View File

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

View File

@@ -11,6 +11,18 @@ interface ErrorBoundaryState {
error: Error | null;
}
function isChunkLoadError(error: Error): boolean {
const msg = error.message || '';
return (
msg.includes('dynamically imported module') ||
msg.includes('Importing a module script failed') ||
msg.includes('Failed to fetch dynamically imported module') ||
msg.includes('Loading chunk') ||
msg.includes('ChunkLoadError') ||
error.name === 'ChunkLoadError'
);
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
@@ -23,6 +35,19 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('[ErrorBoundary]', error, errorInfo);
// Auto-reload on chunk load failures (stale deploy)
if (isChunkLoadError(error)) {
const reloadKey = 'chunk_reload_ts';
const lastReload = Number(sessionStorage.getItem(reloadKey) || '0');
const now = Date.now();
// Prevent reload loop — only auto-reload once per 30 seconds
if (now - lastReload > 30_000) {
sessionStorage.setItem(reloadKey, String(now));
window.location.reload();
return;
}
}
}
handleReset = () => {
@@ -36,6 +61,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
}
const { level = 'page' } = this.props;
const isChunk = this.state.error ? isChunkLoadError(this.state.error) : false;
if (level === 'app') {
return (
@@ -78,13 +104,15 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
<div className="mb-4 text-4xl"></div>
<h1 className="mb-2 text-xl font-bold text-dark-50">Something went wrong</h1>
<p className="mb-6 text-sm text-dark-400">
{this.state.error?.message || 'An unexpected error occurred'}
{isChunk
? 'App was updated. Reloading...'
: this.state.error?.message || 'An unexpected error occurred'}
</p>
<button
onClick={this.handleReset}
onClick={() => window.location.reload()}
className="rounded-xl bg-accent-500 px-6 py-3 font-medium text-white transition-colors hover:bg-accent-600"
>
Try again
{isChunk ? 'Reload' : 'Try again'}
</button>
</div>
</div>

View File

@@ -1,19 +1,27 @@
import { useTranslation } from 'react-i18next';
import { useState, useRef, useEffect } from 'react';
const languages = [
{ code: 'ru', name: 'RU', flag: '🇷🇺', fullName: 'Русский' },
{ code: 'en', name: 'EN', flag: '🇬🇧', fullName: 'English' },
{ code: 'zh', name: 'ZH', flag: '🇨🇳', fullName: '中文' },
{ code: 'fa', name: 'FA', flag: '🇮🇷', fullName: 'فارسی' },
];
import { infoApi, type LanguageInfo } from '@/api/info';
export default function LanguageSwitcher() {
const { i18n } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [availableLanguages, setAvailableLanguages] = useState<LanguageInfo[]>([]);
const dropdownRef = useRef<HTMLDivElement>(null);
const currentLang = languages.find((l) => l.code === i18n.language) || languages[0];
useEffect(() => {
const fetchLanguages = async () => {
try {
const data = await infoApi.getLanguages();
setAvailableLanguages(data.languages);
} catch {
// Silently fall back to empty list — component handles it gracefully
}
};
fetchLanguages();
}, []);
const currentLang = availableLanguages.find((l) => l.code === i18n.language) ||
availableLanguages[0] || { code: 'ru', name: 'RU', flag: '🇷🇺' };
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
@@ -27,16 +35,18 @@ export default function LanguageSwitcher() {
const changeLanguage = (code: string) => {
i18n.changeLanguage(code);
// Set document direction for RTL languages
document.documentElement.dir = code === 'fa' ? 'rtl' : 'ltr';
setIsOpen(false);
};
// Set initial direction on mount
useEffect(() => {
document.documentElement.dir = i18n.language === 'fa' ? 'rtl' : 'ltr';
}, [i18n.language]);
if (availableLanguages.length <= 1) {
return null;
}
return (
<div className="relative" ref={dropdownRef}>
<button
@@ -49,7 +59,7 @@ export default function LanguageSwitcher() {
aria-label="Change language"
>
<span>{currentLang.flag}</span>
<span className="font-medium text-dark-200">{currentLang.name}</span>
<span className="font-medium text-dark-200">{currentLang.code.toUpperCase()}</span>
<svg
className={`h-3.5 w-3.5 text-dark-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
@@ -62,7 +72,7 @@ export default function LanguageSwitcher() {
{isOpen && (
<div className="absolute right-0 z-50 mt-2 w-40 animate-fade-in rounded-xl border border-dark-700/50 bg-dark-800 py-1 shadow-lg">
{languages.map((lang) => (
{availableLanguages.map((lang) => (
<button
key={lang.code}
onClick={() => changeLanguage(lang.code)}
@@ -73,7 +83,7 @@ export default function LanguageSwitcher() {
}`}
>
<span>{lang.flag}</span>
<span>{lang.fullName}</span>
<span>{lang.name}</span>
</button>
))}
</div>

View File

@@ -114,6 +114,7 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
queryClient.invalidateQueries({ queryKey: ['promo-offers'] });
queryClient.invalidateQueries({ queryKey: ['active-discount'] });
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
setSuccessMessage(result.message);

View File

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

View File

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

View File

@@ -82,7 +82,11 @@ export default function WebSocketNotifications() {
expiresAt: message.expires_at,
tariffName: message.tariff_name,
});
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription',
});
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
refreshUser();
@@ -96,7 +100,11 @@ export default function WebSocketNotifications() {
amountKopeks: message.amount_kopeks,
expiresAt: message.new_expires_at,
});
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription',
});
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
refreshUser();
@@ -115,7 +123,7 @@ export default function WebSocketNotifications() {
},
),
icon: <span className="text-lg"></span>,
onClick: () => navigate('/subscription'),
onClick: () => navigate('/subscriptions'),
duration: 10000,
});
return;
@@ -130,10 +138,14 @@ export default function WebSocketNotifications() {
'Your subscription has expired. Renew to continue using the service.',
),
icon: <span className="text-lg">😢</span>,
onClick: () => navigate('/subscription'),
onClick: () => navigate('/subscriptions'),
duration: 10000,
});
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription',
});
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
refreshUser();
return;
}
@@ -171,7 +183,11 @@ export default function WebSocketNotifications() {
icon: <span className="text-lg">🔄</span>,
duration: 5000,
});
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription',
});
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
return;
}
@@ -183,7 +199,11 @@ export default function WebSocketNotifications() {
devicesAdded: message.devices_added,
newDeviceLimit: message.new_device_limit,
});
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription',
});
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
queryClient.invalidateQueries({ queryKey: ['transactions'] });
@@ -199,7 +219,11 @@ export default function WebSocketNotifications() {
trafficGbAdded: message.traffic_gb_added,
newTrafficLimitGb: message.new_traffic_limit_gb,
});
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription',
});
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
queryClient.invalidateQueries({ queryKey: ['transactions'] });
@@ -222,10 +246,14 @@ export default function WebSocketNotifications() {
},
),
icon: <span className="text-lg">🔁</span>,
onClick: () => navigate('/subscription'),
onClick: () => navigate('/subscriptions'),
duration: 8000,
});
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription',
});
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
refreshUser();
@@ -240,7 +268,7 @@ export default function WebSocketNotifications() {
message.reason ||
t('wsNotifications.autopay.failedMessage', 'Failed to auto-renew your subscription'),
icon: <span className="text-lg"></span>,
onClick: () => navigate('/subscription'),
onClick: () => navigate('/subscriptions'),
duration: 10000,
});
return;

View File

@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings';
import { StarIcon } from './icons';
import { SettingRow } from './SettingRow';
import { SettingsTableRow } from './SettingsTableRow';
interface FavoritesTabProps {
settings: SettingDefinition[];
@@ -42,9 +42,9 @@ export function FavoritesTab({ settings, isFavorite, toggleFavorite }: Favorites
}
return (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{settings.map((setting) => (
<SettingRow
<div className="overflow-hidden rounded-xl border border-dark-700/40">
{settings.map((setting, idx) => (
<SettingsTableRow
key={setting.key}
setting={setting}
isFavorite={isFavorite(setting.key)}
@@ -53,6 +53,7 @@ export function FavoritesTab({ settings, isFavorite, toggleFavorite }: Favorites
onReset={() => resetSettingMutation.mutate(setting.key)}
isUpdating={updateSettingMutation.isPending}
isResetting={resetSettingMutation.isPending}
isLast={idx === settings.length - 1}
/>
))}
</div>

View File

@@ -0,0 +1,70 @@
import { useTranslation } from 'react-i18next';
import { SettingDefinition } from '../../api/adminSettings';
import { cn } from '../../lib/utils';
import { formatSettingKey } from './utils';
interface QuickTogglesProps {
settings: SettingDefinition[];
onUpdate: (key: string, value: string) => void;
disabled?: boolean;
className?: string;
}
export function QuickToggles({ settings, onUpdate, disabled, className }: QuickTogglesProps) {
const { t } = useTranslation();
const booleanSettings = settings.filter((s) => s.type === 'bool' && !s.read_only);
if (booleanSettings.length === 0) {
return null;
}
return (
<div
className={cn('rounded-xl border border-dark-700/40 bg-dark-800/30 px-3 py-2.5', className)}
>
<span className="mb-2 block text-[10px] font-semibold uppercase tracking-wider text-dark-500">
{t('admin.settings.quickToggles')}
</span>
<div className="flex flex-wrap gap-2">
{booleanSettings.map((setting) => {
const isOn = setting.current === true || setting.current === 'true';
const formattedKey = formatSettingKey(setting.name || setting.key);
const label = t(`admin.settings.settingNames.${formattedKey}`, formattedKey);
return (
<button
key={setting.key}
type="button"
onClick={() => onUpdate(setting.key, isOn ? 'false' : 'true')}
disabled={disabled}
className={cn(
'flex min-h-[44px] items-center gap-2 rounded-lg border px-2.5 py-2.5 text-xs font-medium transition-all',
isOn
? 'border-success-500/20 bg-success-500/[0.08] text-dark-100'
: 'border-dark-600/50 bg-dark-700/20 text-dark-400',
disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:opacity-80',
)}
>
{/* Mini toggle indicator */}
<div
className={cn(
'relative h-3.5 w-6 flex-shrink-0 rounded-full transition-colors',
isOn ? 'bg-success-500' : 'bg-dark-600',
)}
>
<div
className={cn(
'absolute left-0.5 top-0.5 h-2.5 w-2.5 rounded-full bg-white transition-transform duration-200',
isOn ? 'translate-x-2.5' : 'translate-x-0',
)}
/>
</div>
<span className="max-w-[120px] truncate">{label}</span>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -184,7 +184,7 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
<button
onClick={handleStart}
disabled={disabled}
className="group flex min-w-[100px] max-w-[200px] items-center gap-2 truncate rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-left font-mono text-sm text-dark-200 transition-colors hover:border-dark-500 hover:bg-dark-600 disabled:opacity-50"
className="group flex min-w-[100px] max-w-[200px] items-center gap-2 truncate rounded-lg border border-dark-600 bg-dark-700 px-3 py-2.5 text-left font-mono text-sm text-dark-200 transition-colors hover:border-dark-500 hover:bg-dark-600 disabled:opacity-50"
>
<span className="flex-1 truncate">{currentValue || '-'}</span>
<span className="text-dark-500 opacity-0 transition-colors group-hover:text-accent-400 group-hover:opacity-100">

View File

@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next';
import { useRef, useEffect } from 'react';
import { MENU_SECTIONS } from './constants';
import { useRef, useEffect, useState } from 'react';
import { SETTINGS_TREE } from './constants';
import { StarIcon } from './icons';
interface SettingsMobileTabsProps {
@@ -17,6 +17,7 @@ export function SettingsMobileTabs({
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const activeRef = useRef<HTMLButtonElement>(null);
const [expandedGroup, setExpandedGroup] = useState<string | null>(null);
// Scroll active tab into view
useEffect(() => {
@@ -26,43 +27,75 @@ export function SettingsMobileTabs({
const containerRect = container.getBoundingClientRect();
const activeRect = activeEl.getBoundingClientRect();
// Check if active element is not fully visible
if (activeRect.left < containerRect.left || activeRect.right > containerRect.right) {
activeEl.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
}
}
}, [activeSection]);
// Flatten all items from all sections
const allItems = MENU_SECTIONS.flatMap((section) => section.items);
// Auto-expand the group containing the active section
useEffect(() => {
for (const group of SETTINGS_TREE.groups) {
if (group.children.some((child) => child.id === activeSection)) {
setExpandedGroup(group.id);
return;
}
}
}, [activeSection]);
const handleGroupTap = (groupId: string) => {
if (expandedGroup === groupId) {
setExpandedGroup(null);
} else {
setExpandedGroup(groupId);
// Auto-select first child when expanding
const group = SETTINGS_TREE.groups.find((g) => g.id === groupId);
if (group && group.children.length > 0) {
setActiveSection(group.children[0].id);
}
}
};
const isGroupActive = (groupId: string) => {
const group = SETTINGS_TREE.groups.find((g) => g.id === groupId);
return group?.children.some((child) => child.id === activeSection) ?? false;
};
const isFavoritesActive = activeSection === 'favorites';
// Check if active section is a special item
const isSpecialActive = (itemId: string) => activeSection === itemId;
// Special items excluding favorites
const specialItems = SETTINGS_TREE.specialItems.filter((item) => item.id !== 'favorites');
return (
<div>
{/* Level 1: Favorites + special items + group chips */}
<div
ref={scrollRef}
className="scrollbar-hide flex gap-2 overflow-x-auto px-3 py-3"
style={{ WebkitOverflowScrolling: 'touch' }}
>
{allItems.map((item) => {
const isActive = activeSection === item.id;
const hasIcon = item.iconType === 'star';
return (
{/* Favorites chip */}
<button
key={item.id}
ref={isActive ? activeRef : null}
onClick={() => setActiveSection(item.id)}
className={`flex shrink-0 items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium transition-all ${
isActive
ref={isFavoritesActive ? activeRef : null}
onClick={() => {
setActiveSection('favorites');
setExpandedGroup(null);
}}
className={`flex shrink-0 items-center gap-2 rounded-xl px-4 py-3 text-sm font-medium transition-all ${
isFavoritesActive
? 'bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/30'
: 'bg-dark-800/50 text-dark-400 active:bg-dark-700'
}`}
>
{hasIcon && <StarIcon filled={isActive && item.id === 'favorites'} />}
<span className="whitespace-nowrap">{t(`admin.settings.${item.id}`)}</span>
{item.id === 'favorites' && favoritesCount > 0 && (
<StarIcon filled={isFavoritesActive} />
<span className="whitespace-nowrap">{t('admin.settings.favorites')}</span>
{favoritesCount > 0 && (
<span
className={`rounded-full px-1.5 py-0.5 text-xs ${
isActive
isFavoritesActive
? 'bg-accent-500/20 text-accent-400'
: 'bg-warning-500/20 text-warning-400'
}`}
@@ -71,8 +104,80 @@ export function SettingsMobileTabs({
</span>
)}
</button>
{/* Special item chips (branding, theme, analytics, buttons) */}
{specialItems.map((item) => {
const isActive = isSpecialActive(item.id);
return (
<button
key={item.id}
ref={isActive ? activeRef : null}
onClick={() => {
setActiveSection(item.id);
setExpandedGroup(null);
}}
className={`flex shrink-0 items-center gap-2 rounded-xl px-4 py-3 text-sm font-medium transition-all ${
isActive
? 'bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/30'
: 'bg-dark-800/50 text-dark-400 active:bg-dark-700'
}`}
>
{item.icon && <span className="text-sm">{item.icon}</span>}
<span className="whitespace-nowrap">{t(`admin.settings.${item.id}`)}</span>
</button>
);
})}
{/* Group chips */}
{SETTINGS_TREE.groups.map((group) => {
const hasActiveChild = isGroupActive(group.id);
const isExpanded = expandedGroup === group.id;
return (
<button
key={group.id}
ref={hasActiveChild ? activeRef : null}
onClick={() => handleGroupTap(group.id)}
className={`flex shrink-0 items-center gap-2 rounded-xl px-4 py-3 text-sm font-medium transition-all ${
hasActiveChild || isExpanded
? 'bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/30'
: 'bg-dark-800/50 text-dark-400 active:bg-dark-700'
}`}
>
<span className="text-sm">{group.icon}</span>
<span className="whitespace-nowrap">{t(`admin.settings.groups.${group.id}`)}</span>
</button>
);
})}
<div className="w-3 shrink-0" aria-hidden="true" />
</div>
{/* Level 2: Sub-item chips (shown when a group is expanded) */}
{expandedGroup && (
<div
className="scrollbar-hide flex gap-2 overflow-x-auto px-3 pb-2"
style={{ WebkitOverflowScrolling: 'touch' }}
>
{SETTINGS_TREE.groups
.find((g) => g.id === expandedGroup)
?.children.map((child) => {
const isActive = activeSection === child.id;
return (
<button
key={child.id}
onClick={() => setActiveSection(child.id)}
className={`shrink-0 rounded-lg px-3 py-2.5 text-sm font-medium transition-all ${
isActive
? 'bg-accent-500/10 text-accent-400 ring-1 ring-accent-500/20'
: 'bg-dark-800/30 text-dark-500 active:bg-dark-700'
}`}
>
<span className="whitespace-nowrap">{t(`admin.settings.tree.${child.id}`)}</span>
</button>
);
})}
<div className="w-3 shrink-0" aria-hidden="true" />
</div>
)}
</div>
);
}

View File

@@ -208,7 +208,7 @@ export function SettingsSearchMobile({
};
return (
<div ref={containerRef} className="relative mt-3 sm:hidden">
<div ref={containerRef} className="relative mt-3 lg:hidden">
<input
type="text"
value={searchQuery}

View File

@@ -1,9 +1,8 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings';
import { ChevronDownIcon } from './icons';
import { SettingRow } from './SettingRow';
import { QuickToggles } from './QuickToggles';
import { SettingsTableRow } from './SettingsTableRow';
interface CategoryGroup {
key: string;
@@ -29,20 +28,6 @@ export function SettingsTab({
const { t } = useTranslation();
const queryClient = useQueryClient();
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
const toggleSection = (key: string) => {
setExpandedSections((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};
const updateSettingMutation = useMutation({
mutationFn: ({ key, value }: { key: string; value: string }) =>
adminSettingsApi.updateSetting(key, value),
@@ -58,18 +43,19 @@ export function SettingsTab({
},
});
// If searching, show flat list
if (searchQuery) {
// Search mode: flat list of filtered results
if (searchQuery.trim()) {
if (filteredSettings.length === 0) {
return (
<div className="space-y-4">
{filteredSettings.length === 0 ? (
<div className="rounded-2xl border border-dark-700/30 bg-dark-800/30 p-12 text-center">
<div className="rounded-xl border border-dark-700/30 bg-dark-800/30 p-12 text-center">
<p className="text-dark-400">{t('admin.settings.noSettings')}</p>
</div>
) : (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{filteredSettings.map((setting) => (
<SettingRow
);
}
return (
<div className="overflow-hidden rounded-xl border border-dark-700/40">
{filteredSettings.map((setting, idx) => (
<SettingsTableRow
key={setting.key}
setting={setting}
isFavorite={isFavorite(setting.key)}
@@ -78,72 +64,59 @@ export function SettingsTab({
onReset={() => resetSettingMutation.mutate(setting.key)}
isUpdating={updateSettingMutation.isPending}
isResetting={resetSettingMutation.isPending}
isLast={idx === filteredSettings.length - 1}
/>
))}
</div>
)}
</div>
);
}
// Show accordion for subcategories
return (
<div className="space-y-3">
{categories.map((cat) => {
const isExpanded = expandedSections.has(cat.key);
return (
<div
key={cat.key}
className="overflow-hidden rounded-2xl border border-dark-700/30 bg-dark-800/30"
>
{/* Accordion header */}
<button
onClick={() => toggleSection(cat.key)}
className="flex w-full items-center justify-between p-4 transition-colors hover:bg-dark-800/50"
>
<div className="flex items-center gap-3">
<span className="font-medium text-dark-100">{cat.label}</span>
<span className="rounded-full bg-dark-700 px-2 py-0.5 text-xs text-dark-400">
{cat.settings.length}
</span>
</div>
<div
className={`text-dark-400 transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`}
>
<ChevronDownIcon />
</div>
</button>
// Normal mode: QuickToggles + settings by category
const allCategorySettings = categories.flatMap((c) => c.settings);
{/* Accordion content */}
{isExpanded && (
<div className="border-t border-dark-700/30 p-4 pt-0">
<div className="grid grid-cols-1 gap-4 pt-4 lg:grid-cols-2">
{cat.settings.map((setting) => (
<SettingRow
if (allCategorySettings.length === 0) {
return (
<div className="rounded-xl border border-dark-700/30 bg-dark-800/30 p-12 text-center">
<p className="text-dark-400">{t('admin.settings.noSettings')}</p>
</div>
);
}
return (
<div>
<QuickToggles
settings={allCategorySettings}
onUpdate={(key, value) => updateSettingMutation.mutate({ key, value })}
disabled={updateSettingMutation.isPending}
/>
{categories.map((category) => {
if (category.settings.length === 0) return null;
return (
<div key={category.key} className="mb-4">
{categories.length > 1 && (
<div className="mb-2 flex items-center gap-2">
<h3 className="text-sm font-semibold text-dark-200">{category.label}</h3>
<span className="text-xs text-dark-500">{category.settings.length}</span>
</div>
)}
<div className="overflow-hidden rounded-xl border border-dark-700/40">
{category.settings.map((setting, idx) => (
<SettingsTableRow
key={setting.key}
setting={setting}
isFavorite={isFavorite(setting.key)}
onToggleFavorite={() => toggleFavorite(setting.key)}
onUpdate={(value) =>
updateSettingMutation.mutate({ key: setting.key, value })
}
onUpdate={(value) => updateSettingMutation.mutate({ key: setting.key, value })}
onReset={() => resetSettingMutation.mutate(setting.key)}
isUpdating={updateSettingMutation.isPending}
isResetting={resetSettingMutation.isPending}
isLast={idx === category.settings.length - 1}
/>
))}
</div>
</div>
)}
</div>
);
})}
{categories.length === 0 && (
<div className="rounded-2xl border border-dark-700/30 bg-dark-800/30 p-12 text-center">
<p className="text-dark-400">{t('admin.settings.noSettings')}</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,179 @@
import { useTranslation } from 'react-i18next';
import { SettingDefinition } from '../../api/adminSettings';
import { cn } from '../../lib/utils';
import { StarIcon, LockIcon, RefreshIcon } from './icons';
import { SettingInput } from './SettingInput';
import { Toggle } from './Toggle';
import { formatSettingKey, stripHtml } from './utils';
interface SettingsTableRowProps {
setting: SettingDefinition;
isFavorite: boolean;
onToggleFavorite: () => void;
onUpdate: (value: string) => void;
onReset: () => void;
isUpdating?: boolean;
isResetting?: boolean;
isLast?: boolean;
className?: string;
}
export function SettingsTableRow({
setting,
isFavorite,
onToggleFavorite,
onUpdate,
onReset,
isUpdating,
isResetting,
isLast,
className,
}: SettingsTableRowProps) {
const { t } = useTranslation();
const formattedKey = formatSettingKey(setting.name || setting.key);
const displayName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey);
const description = setting.hint?.description ? stripHtml(setting.hint.description) : null;
const isModified = setting.has_override;
const isBool = setting.type === 'bool';
const boolChecked = setting.current === true || setting.current === 'true';
const isLongValue = (() => {
const val = String(setting.current ?? '');
const key = setting.key.toLowerCase();
return (
val.length > 50 ||
val.includes('\n') ||
val.startsWith('[') ||
val.startsWith('{') ||
key.includes('_items') ||
key.includes('_config') ||
key.includes('_keywords') ||
key.includes('_template') ||
key.includes('_packages') ||
key.includes('_list') ||
key.includes('_json') ||
key.includes('_periods') ||
key.includes('_discounts')
);
})();
return (
<div
className={cn(
'group px-4 py-3 transition-colors hover:bg-dark-800/40',
isModified && 'bg-warning-500/[0.02]',
!isLast && 'border-b border-dark-700/30',
className,
)}
>
<div
className={cn(
isLongValue ? 'space-y-3' : 'flex flex-col gap-2 lg:flex-row lg:items-center lg:gap-4',
)}
>
{/* Left side: name, badges, key */}
<div className={cn('min-w-0', !isLongValue && 'lg:flex-1')}>
{/* Name + badges row */}
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-[13px] font-medium text-dark-100">{displayName}</span>
{isModified && (
<span className="rounded-full bg-warning-500/20 px-1.5 py-0.5 text-[10px] font-medium leading-none text-warning-400">
{t('admin.settings.modified')}
</span>
)}
{setting.has_override && !setting.read_only && (
<span className="rounded-full bg-sky-500/20 px-1.5 py-0.5 text-[10px] font-medium leading-none text-sky-400">
{t('admin.settings.badgeDb')}
</span>
)}
{setting.read_only && (
<span className="flex items-center gap-0.5 rounded-full bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-medium leading-none text-amber-400">
{t('admin.settings.badgeEnv')}
<LockIcon className="h-3 w-3" />
</span>
)}
</div>
{/* Setting key */}
<div className="mt-0.5">
<code className="font-mono text-[11px] text-dark-500">{setting.key}</code>
</div>
{/* Description for long values */}
{isLongValue && description && (
<p className="mt-1 text-xs leading-relaxed text-dark-400">{description}</p>
)}
</div>
{/* Right side: control + action buttons */}
<div
className={cn(
'flex items-center gap-2',
isLongValue ? 'w-full' : 'max-lg:self-end lg:flex-shrink-0',
)}
>
{setting.read_only ? (
<span className="max-w-[240px] truncate rounded bg-dark-700/30 px-3 py-1.5 font-mono text-xs text-dark-400">
{isBool
? boolChecked
? t('admin.settings.enabled')
: t('admin.settings.disabled')
: String(setting.current ?? '-')}
</span>
) : isBool ? (
<Toggle
checked={boolChecked}
onChange={() => onUpdate(boolChecked ? 'false' : 'true')}
disabled={isUpdating}
aria-label={displayName}
/>
) : (
<div className={cn(isLongValue && 'w-full')}>
<SettingInput setting={setting} onUpdate={onUpdate} disabled={isUpdating} />
</div>
)}
{/* Reset button -- hover-reveal when has_override */}
{isModified && !setting.read_only && (
<button
onClick={onReset}
disabled={isResetting}
className="flex-shrink-0 rounded-lg p-1.5 text-dark-500 opacity-0 transition-all hover:bg-dark-700 hover:text-dark-200 disabled:opacity-50 group-hover:opacity-100 max-lg:opacity-100"
title={t('admin.settings.reset')}
aria-label={t('admin.settings.reset')}
>
<RefreshIcon />
</button>
)}
{/* Favorite button -- visible if favorited, hover-reveal otherwise */}
<button
onClick={onToggleFavorite}
className={cn(
'flex-shrink-0 rounded-lg p-1.5 transition-all',
isFavorite
? 'text-warning-400 hover:bg-warning-500/15'
: 'text-dark-500 opacity-0 hover:bg-dark-700/50 hover:text-warning-400 group-hover:opacity-100 max-lg:opacity-100',
)}
title={
isFavorite
? t('admin.settings.removeFromFavorites')
: t('admin.settings.addToFavorites')
}
aria-label={
isFavorite
? t('admin.settings.removeFromFavorites')
: t('admin.settings.addToFavorites')
}
>
<StarIcon filled={isFavorite} />
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,324 @@
import { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { SETTINGS_TREE } from './constants';
import { StarIcon, SearchIcon, CloseIcon, ChevronDownIcon } from './icons';
import { SettingDefinition } from '../../api/adminSettings';
import { formatSettingKey } from './utils';
import { cn } from '../../lib/utils';
interface SettingsTreeSidebarProps {
activeSection: string;
onSectionChange: (sectionId: string) => void;
favoritesCount: number;
searchQuery: string;
onSearchChange: (query: string) => void;
allSettings?: SettingDefinition[];
onSelectSetting?: (setting: SettingDefinition) => void;
className?: string;
}
export function SettingsTreeSidebar({
activeSection,
onSectionChange,
favoritesCount,
searchQuery,
onSearchChange,
allSettings,
onSelectSetting,
className,
}: SettingsTreeSidebarProps) {
const { t } = useTranslation();
const [expandedGroup, setExpandedGroup] = useState<string | null>(null);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const searchContainerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Auto-expand the group containing the active section
useEffect(() => {
for (const group of SETTINGS_TREE.groups) {
if (group.children.some((child) => child.id === activeSection)) {
setExpandedGroup(group.id);
return;
}
}
}, [activeSection]);
// Filter settings for autocomplete
const suggestions =
searchQuery.trim() && allSettings
? allSettings
.filter((s) => {
const q = searchQuery.toLowerCase().trim();
if (s.key.toLowerCase().includes(q)) return true;
if (s.name?.toLowerCase().includes(q)) return true;
const formattedKey = formatSettingKey(s.name || s.key);
const translatedName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey);
if (translatedName.toLowerCase().includes(q)) return true;
if (s.hint?.description?.toLowerCase().includes(q)) return true;
const categoryLabel = t(`admin.settings.categories.${s.category.key}`, s.category.key);
if (categoryLabel.toLowerCase().includes(q)) return true;
return false;
})
.slice(0, 8)
: [];
// Close dropdown on click outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (searchContainerRef.current && !searchContainerRef.current.contains(e.target as Node)) {
setIsSearchOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Reset highlighted index when suggestions change
useEffect(() => {
setHighlightedIndex(0);
}, [suggestions.length, searchQuery]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isSearchOpen || suggestions.length === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setHighlightedIndex((i) => (i + 1) % suggestions.length);
break;
case 'ArrowUp':
e.preventDefault();
setHighlightedIndex((i) => (i - 1 + suggestions.length) % suggestions.length);
break;
case 'Enter':
e.preventDefault();
if (suggestions[highlightedIndex]) {
handleSelectSuggestion(suggestions[highlightedIndex]);
}
break;
case 'Escape':
setIsSearchOpen(false);
inputRef.current?.blur();
break;
}
};
const handleSelectSuggestion = (setting: SettingDefinition) => {
setIsSearchOpen(false);
onSearchChange(setting.name || setting.key);
onSelectSetting?.(setting);
};
const getSettingDisplayName = (setting: SettingDefinition) => {
const formattedKey = formatSettingKey(setting.name || setting.key);
return t(`admin.settings.settingNames.${formattedKey}`, formattedKey);
};
const handleGroupToggle = (groupId: string) => {
if (expandedGroup === groupId) {
setExpandedGroup(null);
} else {
setExpandedGroup(groupId);
// Auto-select first child when expanding
const group = SETTINGS_TREE.groups.find((g) => g.id === groupId);
if (group && group.children.length > 0) {
onSectionChange(group.children[0].id);
}
}
};
const isGroupActive = (groupId: string) => {
const group = SETTINGS_TREE.groups.find((g) => g.id === groupId);
return group?.children.some((child) => child.id === activeSection) ?? false;
};
// Special items excluding favorites (favorites is rendered separately)
const customizationItems = SETTINGS_TREE.specialItems.filter((item) => item.id !== 'favorites');
return (
<nav className={cn('flex flex-col', className)}>
{/* Search bar */}
<div ref={searchContainerRef} className="relative px-3 pb-2 pt-3">
<input
ref={inputRef}
type="text"
value={searchQuery}
onChange={(e) => {
onSearchChange(e.target.value);
setIsSearchOpen(true);
}}
onFocus={() => setIsSearchOpen(true)}
onKeyDown={handleKeyDown}
placeholder={t('admin.settings.searchPlaceholder')}
className="w-full rounded-lg border border-dark-700/50 bg-dark-800/50 py-2 pl-9 pr-8 text-sm text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none"
/>
<div className="absolute left-6 top-1/2 -translate-y-1/2 text-dark-500">
<SearchIcon className="h-4 w-4" />
</div>
{searchQuery && (
<button
onClick={() => {
onSearchChange('');
setIsSearchOpen(false);
}}
className="absolute right-6 top-1/2 -translate-y-1/2 text-dark-500 transition-colors hover:text-dark-300"
>
<CloseIcon className="h-4 w-4" />
</button>
)}
{/* Autocomplete dropdown */}
{isSearchOpen && suggestions.length > 0 && (
<div className="absolute left-3 right-3 top-full z-50 mt-1 max-h-72 overflow-y-auto rounded-lg border border-dark-700 bg-dark-800 py-1 shadow-xl">
{suggestions.map((setting, index) => (
<button
key={setting.key}
onClick={() => handleSelectSuggestion(setting)}
onMouseEnter={() => setHighlightedIndex(index)}
className={cn(
'flex w-full flex-col gap-0.5 px-3 py-2 text-left transition-colors',
index === highlightedIndex ? 'bg-accent-500/20' : 'hover:bg-dark-700/50',
)}
>
<span className="truncate text-sm font-medium text-dark-100">
{getSettingDisplayName(setting)}
</span>
<span className="truncate text-xs text-dark-500">
{t(`admin.settings.categories.${setting.category.key}`, setting.category.key)}
</span>
</button>
))}
</div>
)}
</div>
{/* Favorites button */}
<div className="px-3 pb-1">
<button
onClick={() => onSectionChange('favorites')}
className={cn(
'flex w-full items-center gap-3 rounded-lg px-3 py-2 transition-all',
activeSection === 'favorites'
? 'bg-accent-500/10 text-accent-400'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200',
)}
>
<StarIcon className="h-4 w-4" filled={activeSection === 'favorites'} />
<span className="text-sm font-medium">{t('admin.settings.favorites', 'Favorites')}</span>
{favoritesCount > 0 && (
<span
className={cn(
'ml-auto rounded-full px-2 py-0.5 text-xs',
activeSection === 'favorites'
? 'bg-accent-500/20 text-accent-400'
: 'bg-warning-500/20 text-warning-400',
)}
>
{favoritesCount}
</span>
)}
</button>
</div>
{/* Divider */}
<div className="mx-3 border-t border-dark-700/50" />
{/* Customization section label */}
<div className="px-6 pb-1 pt-3">
<span className="text-[10px] font-semibold uppercase tracking-wider text-dark-500">
{t('admin.settings.customization', 'Customization')}
</span>
</div>
{/* Special items (branding, theme, analytics, buttons) */}
<div className="space-y-0.5 px-3 pb-1">
{customizationItems.map((item) => {
const isActive = activeSection === item.id;
return (
<button
key={item.id}
onClick={() => onSectionChange(item.id)}
className={cn(
'flex w-full items-center gap-3 rounded-lg px-3 py-2 transition-all',
isActive
? 'bg-accent-500/10 text-accent-400'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200',
)}
>
{item.icon && <span className="text-sm">{item.icon}</span>}
<span className="text-sm font-medium">{t(`admin.settings.${item.id}`, item.id)}</span>
</button>
);
})}
</div>
{/* Divider */}
<div className="mx-3 border-t border-dark-700/50" />
{/* Settings section label */}
<div className="px-6 pb-1 pt-3">
<span className="text-[10px] font-semibold uppercase tracking-wider text-dark-500">
{t('admin.settings.settingsLabel', 'Settings')}
</span>
</div>
{/* Tree groups */}
<div className="space-y-0.5 px-3 pb-3">
{SETTINGS_TREE.groups.map((group) => {
const isExpanded = expandedGroup === group.id;
const hasActiveChild = isGroupActive(group.id);
return (
<div key={group.id}>
{/* Group header */}
<button
onClick={() => handleGroupToggle(group.id)}
className={cn(
'flex w-full items-center gap-3 rounded-lg px-3 py-2 transition-all',
hasActiveChild
? 'text-accent-300'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200',
)}
>
<span className="text-sm">{group.icon}</span>
<span className="flex-1 text-left text-sm font-medium">
{t(`admin.settings.groups.${group.id}`, group.id)}
</span>
<ChevronDownIcon
className={cn(
'h-4 w-4 transition-transform duration-200',
isExpanded && 'rotate-180',
)}
/>
</button>
{/* Children */}
{isExpanded && (
<div className="relative ml-5 mt-0.5 space-y-0.5 border-l border-dark-700/50 pl-3">
{group.children.map((child) => {
const isActive = activeSection === child.id;
return (
<button
key={child.id}
onClick={() => onSectionChange(child.id)}
className={cn(
'flex w-full items-center rounded-lg px-3 py-1.5 text-left text-sm transition-all',
isActive
? 'bg-accent-500/10 text-accent-400'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200',
)}
>
{t(`admin.settings.tree.${child.id}`, child.id)}
</button>
);
})}
</div>
)}
</div>
);
})}
</div>
</nav>
);
}

View File

@@ -1,111 +1,160 @@
import { ThemeColors, DEFAULT_THEME_COLORS } from '../../types/theme';
// Menu item types
export interface MenuItem {
// Tree sidebar types
export interface TreeSubItem {
id: string;
categories: string[];
}
export interface TreeGroup {
id: string;
icon: string;
children: TreeSubItem[];
}
export interface SpecialItem {
id: string;
icon?: string;
iconType?: 'star' | null;
categories?: string[];
}
export interface MenuSection {
id: string;
items: MenuItem[];
export interface SettingsTreeConfig {
specialItems: SpecialItem[];
groups: TreeGroup[];
}
// Sidebar menu configuration
export const MENU_SECTIONS: MenuSection[] = [
{
id: 'main',
items: [
// Hierarchical settings tree — all 61 backend category keys mapped into 7 groups
export const SETTINGS_TREE: SettingsTreeConfig = {
specialItems: [
{ id: 'favorites', iconType: 'star' },
{ id: 'branding', iconType: null },
{ id: 'theme', iconType: null },
{ id: 'analytics', iconType: null },
{ id: 'buttons', iconType: null },
{ id: 'branding', icon: '🎨' },
{ id: 'theme', icon: '🌈' },
{ id: 'analytics', icon: '📊' },
{ id: 'buttons', icon: '📱' },
],
},
{
id: 'settings',
items: [
groups: [
{
id: 'payments',
iconType: null,
categories: [
'PAYMENT',
'PAYMENT_VERIFICATION',
'YOOKASSA',
'CRYPTOBOT',
'HELEKET',
'PLATEGA',
'TRIBUTE',
'MULENPAY',
'PAL24',
'WATA',
'TELEGRAM',
icon: '💳',
children: [
{ id: 'payments_general', categories: ['PAYMENT', 'PAYMENT_VERIFICATION'] },
{ id: 'payments_stars', categories: ['TELEGRAM'] },
{ id: 'payments_yookassa', categories: ['YOOKASSA'] },
{ id: 'payments_cryptobot', categories: ['CRYPTOBOT'] },
{ id: 'payments_cloudpayments', categories: ['CLOUDPAYMENTS'] },
{ id: 'payments_freekassa', categories: ['FREEKASSA'] },
{ id: 'payments_kassa_ai', categories: ['KASSA_AI'] },
{ id: 'payments_platega', categories: ['PLATEGA'] },
{ id: 'payments_pal24', categories: ['PAL24'] },
{ id: 'payments_heleket', categories: ['HELEKET'] },
{ id: 'payments_mulenpay', categories: ['MULENPAY'] },
{ id: 'payments_tribute', categories: ['TRIBUTE'] },
{ id: 'payments_wata', categories: ['WATA'] },
{ id: 'payments_riopay', categories: ['RIOPAY'] },
{ id: 'payments_severpay', categories: ['SEVERPAY'] },
],
},
{
id: 'subscriptions',
iconType: null,
categories: [
'SUBSCRIPTIONS_CORE',
'SIMPLE_SUBSCRIPTION',
'PERIODS',
'SUBSCRIPTION_PRICES',
'TRAFFIC',
'TRAFFIC_PACKAGES',
'TRIAL',
'AUTOPAY',
icon: '📦',
children: [
{ id: 'subs_core', categories: ['SUBSCRIPTIONS_CORE'] },
{ id: 'subs_trial', categories: ['TRIAL'] },
{ id: 'subs_pricing', categories: ['SUBSCRIPTION_PRICES'] },
{ id: 'subs_periods', categories: ['PERIODS'] },
{ id: 'subs_traffic', categories: ['TRAFFIC', 'TRAFFIC_PACKAGES'] },
{ id: 'subs_simple', categories: ['SIMPLE_SUBSCRIPTION'] },
{ id: 'subs_autopay', categories: ['AUTOPAY'] },
],
},
{
id: 'interface',
iconType: null,
categories: [
'INTERFACE',
'INTERFACE_BRANDING',
'INTERFACE_SUBSCRIPTION',
'CONNECT_BUTTON',
'MINIAPP',
'TELEGRAM_WIDGET',
'TELEGRAM_OIDC',
'HAPP',
'SKIP',
'ADDITIONAL',
],
},
icon: '🖥️',
children: [
{
id: 'notifications',
iconType: null,
categories: ['NOTIFICATIONS', 'ADMIN_NOTIFICATIONS', 'ADMIN_REPORTS'],
id: 'iface_general',
categories: ['INTERFACE', 'INTERFACE_BRANDING', 'INTERFACE_SUBSCRIPTION'],
},
{ id: 'database', iconType: null, categories: ['DATABASE', 'POSTGRES', 'SQLITE', 'REDIS'] },
{
id: 'system',
iconType: null,
categories: [
'CORE',
'REMNAWAVE',
'SERVER_STATUS',
'MONITORING',
'MAINTENANCE',
'BACKUP',
'VERSION',
'WEB_API',
'WEBHOOK',
'LOG',
'DEBUG',
'EXTERNAL_ADMIN',
{ id: 'iface_connect', categories: ['CONNECT_BUTTON'] },
{ id: 'iface_miniapp', categories: ['MINIAPP'] },
{ id: 'iface_happ', categories: ['HAPP'] },
{ id: 'iface_widget', categories: ['TELEGRAM_WIDGET'] },
{ id: 'iface_oidc', categories: ['TELEGRAM_OIDC'] },
{ id: 'iface_skip', categories: ['SKIP'] },
{ id: 'iface_additional', categories: ['ADDITIONAL'] },
],
},
{
id: 'users',
iconType: null,
categories: ['SUPPORT', 'LOCALIZATION', 'CHANNEL', 'TIMEZONE', 'REFERRAL', 'MODERATION'],
},
icon: '👥',
children: [
{ id: 'users_support', categories: ['SUPPORT'] },
{ id: 'users_referral', categories: ['REFERRAL'] },
{ id: 'users_channel', categories: ['CHANNEL'] },
{ id: 'users_localization', categories: ['LOCALIZATION', 'TIMEZONE'] },
{ id: 'users_moderation', categories: ['MODERATION', 'BAN_NOTIFICATIONS'] },
],
},
];
{
id: 'notifications',
icon: '🔔',
children: [
{ id: 'notif_user', categories: ['NOTIFICATIONS', 'WEBHOOK_NOTIFICATIONS'] },
{ id: 'notif_admin', categories: ['ADMIN_NOTIFICATIONS'] },
{ id: 'notif_reports', categories: ['ADMIN_REPORTS'] },
],
},
{
id: 'database',
icon: '🗄️',
children: [
{ id: 'db_general', categories: ['DATABASE'] },
{ id: 'db_postgres', categories: ['POSTGRES'] },
{ id: 'db_sqlite', categories: ['SQLITE'] },
{ id: 'db_redis', categories: ['REDIS'] },
],
},
{
id: 'system',
icon: '⚙️',
children: [
{ id: 'sys_core', categories: ['CORE', 'DEBUG'] },
{ id: 'sys_remnawave', categories: ['REMNAWAVE'] },
{ id: 'sys_webapi', categories: ['WEB_API', 'EXTERNAL_ADMIN'] },
{ id: 'sys_webhook', categories: ['WEBHOOK'] },
{ id: 'sys_server', categories: ['SERVER_STATUS'] },
{ id: 'sys_monitoring', categories: ['MONITORING'] },
{ id: 'sys_maintenance', categories: ['MAINTENANCE'] },
{ id: 'sys_backup', categories: ['BACKUP'] },
{ id: 'sys_version', categories: ['VERSION'] },
{ id: 'sys_logging', categories: ['LOG'] },
],
},
],
};
// Helper: find which group and sub-item a backend category key belongs to
export function findTreeLocation(
categoryKey: string,
): { groupId: string; subItemId: string } | null {
for (const group of SETTINGS_TREE.groups) {
for (const child of group.children) {
if (child.categories.includes(categoryKey)) {
return { groupId: group.id, subItemId: child.id };
}
}
}
return null;
}
// Helper: get all backend category keys for a given sub-item id
export function getCategoriesForSubItem(subItemId: string): string[] {
for (const group of SETTINGS_TREE.groups) {
const child = group.children.find((c) => c.id === subItemId);
if (child) return child.categories;
}
return [];
}
// Theme preset type
export interface ThemePreset {

View File

@@ -5,6 +5,7 @@ export * from './icons';
export * from './Toggle';
export * from './SettingInput';
export * from './SettingRow';
export * from './SettingsTableRow';
export * from './AnalyticsTab';
export * from './BrandingTab';
export * from './ButtonsTab';
@@ -13,6 +14,8 @@ export * from './FavoritesTab';
export * from './SettingsTab';
export * from './SettingsMobileTabs';
export * from './SettingsSearch';
export * from './SettingsTreeSidebar';
export * from './QuickToggles';
export * from './LocaleTabs';
export * from './LocalizedInput';

View File

@@ -214,7 +214,7 @@ export default function SubscriptionCardActive({
haptic.notification('error');
return;
}
navigate('/connection');
navigate(`/connection?sub=${subscription.id}`);
}}
className={`mb-2.5 flex w-full items-center gap-3.5 rounded-[14px] p-3.5 text-left transition-shadow duration-300${isAtDeviceLimit ? 'cursor-not-allowed opacity-50' : ''}`}
data-onboarding="connect-devices"
@@ -312,7 +312,7 @@ export default function SubscriptionCardActive({
<div className="mb-5 flex gap-2.5">
{/* Tariff badge — clickable */}
<Link
to="/subscription"
to={`/subscriptions/${subscription.id}`}
className="flex-1 rounded-[14px] p-3.5 transition-all duration-500"
style={{
background: `linear-gradient(135deg, rgba(${zone.mainVarRaw}, 0.07), rgba(${zone.mainVarRaw}, 0.02))`,
@@ -396,7 +396,7 @@ export default function SubscriptionCardActive({
{trafficRefreshCooldown > 0 ? `${trafficRefreshCooldown}s` : t('common.refresh')}
</button>
<Link
to="/subscription"
to={`/subscriptions/${subscription.id}`}
className="text-[11px] font-medium text-dark-50/25 transition-colors hover:text-dark-50/40"
>
{t('dashboard.viewSubscription')} &rarr;

View File

@@ -57,15 +57,18 @@ export default function SubscriptionCardExpired({
try {
if (isDisabledDaily) {
// Resume daily subscription via toggle pause endpoint
await subscriptionApi.togglePause();
await subscriptionApi.togglePause(subscription.id);
} else if (isDaily && subscription.tariff_id) {
// Expired daily tariff — purchase for 1 day
await subscriptionApi.purchaseTariff(subscription.tariff_id, 1);
} else {
await subscriptionApi.renewSubscription(30);
await subscriptionApi.renewSubscription(30, subscription.id);
}
haptic.success();
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({
predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription',
});
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
} catch (err: unknown) {
@@ -257,7 +260,7 @@ export default function SubscriptionCardExpired({
<div className="flex gap-2.5">
{isLimited ? (
<Link
to="/subscription"
to={`/subscriptions/${subscription.id}`}
className="flex flex-1 items-center justify-center gap-2 rounded-[14px] py-3.5 text-[15px] font-semibold tracking-tight text-white transition-all duration-300"
style={{
background: accent.gradient,

View File

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

View File

@@ -221,6 +221,11 @@ export function AppShell({ children }: AppShellProps) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isKeyboardOpen, setIsKeyboardOpen] = useState(false);
// Reset keyboard state on route change — prevents bottom nav staying hidden after navigation
useEffect(() => {
setIsKeyboardOpen(false);
}, [location.pathname]);
// Keyboard detection for hiding bottom nav
useEffect(() => {
const handleFocusIn = (e: FocusEvent) => {
@@ -254,7 +259,7 @@ export function AppShell({ children }: AppShellProps) {
// Desktop navigation items
const desktopNavItems = [
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
{ path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon },
{ path: '/subscriptions', label: t('nav.subscription'), icon: SubscriptionIcon },
{ path: '/balance', label: t('nav.balance'), icon: CreditCardIcon },
{ path: '/support', label: t('nav.support'), icon: ChatIcon },
{ path: '/info', label: t('nav.info'), icon: InfoIcon },

View File

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

View File

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

View File

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

View File

@@ -369,7 +369,7 @@ export default function NewsSection() {
gcTime: 10 * 60_000,
});
const items = data?.items ?? [];
const items = useMemo(() => data?.items ?? [], [data?.items]);
const total = data?.total ?? 0;
const categories = data?.categories ?? [];

View File

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

View File

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

View File

@@ -46,6 +46,8 @@ i18n
react: {
useSuspense: false,
},
showSupportNotice: false,
});
// Load detected language + fallback on startup

View File

@@ -316,7 +316,8 @@
"trafficReset": {
"DAY": "Resets daily",
"WEEK": "Resets weekly",
"MONTH": "Resets monthly"
"MONTH": "Resets monthly",
"MONTH_ROLLING": "Resets every 30 days"
},
"traffic": "Traffic",
"unlimited": "Unlimited",
@@ -1116,6 +1117,17 @@
"panel": {
"title": "Admin Panel",
"subtitle": "System management",
"statsUptime": "Uptime",
"statsBot": "Bot",
"statsCabinet": "Cabinet",
"statsTrials": "Trials",
"statsPaid": "Paid",
"statsOnline": "Online",
"statsToday": "today",
"searchPlaceholder": "Search...",
"searchEmpty": "Nothing found",
"searchEmptyHint": "Try a different query",
"searchClear": "Clear search",
"dashboardDesc": "Statistics and system monitoring",
"ticketsDesc": "Handle user support tickets",
"settingsDesc": "System settings and parameters",
@@ -1481,16 +1493,26 @@
"tabs": {
"overview": "Overview",
"nodes": "Nodes",
"traffic": "Traffic",
"squads": "Squads",
"sync": "Sync"
},
"traffic": {
"noData": "No traffic data available",
"totalDownload": "Download",
"totalUpload": "Upload",
"totalTraffic": "Total",
"online": "online",
"inbounds": "Inbounds",
"outbounds": "Outbounds"
},
"overview": {
"system": "System",
"usersOnline": "Online",
"totalUsers": "Total Users",
"nodesOnline": "Nodes Online",
"connections": "Connections",
"bandwidth": "Realtime Bandwidth",
"bandwidth": "Inbound Traffic",
"download": "Download",
"upload": "Upload",
"total": "Total",
@@ -1814,8 +1836,11 @@
"title": "System Settings",
"allCategories": "All categories",
"noSettings": "No settings found",
"quickToggles": "Quick Toggles",
"modified": "Modified",
"readOnly": "Read only",
"badgeDb": "DB",
"badgeEnv": "ENV",
"reset": "Reset",
"categoriesCount": "categories",
"settingsCount": "settings",
@@ -1888,6 +1913,84 @@
"quickPresets": "Quick presets",
"resetAllColors": "Reset all colors",
"statusColors": "Status colors",
"favorites": "Favorites",
"branding": "Branding",
"theme": "Theme",
"payments": "Payments",
"subscriptions": "Subscriptions",
"interface": "Interface",
"notifications": "Notifications",
"database": "Database",
"system": "System",
"users": "Users",
"customization": "Customization",
"settingsLabel": "Settings",
"backToAdmin": "Back to admin",
"totalCount": "{{count}} total",
"modifiedCount": "{{count}} modified",
"groups": {
"payments": "Payments",
"subscriptions": "Subscriptions",
"interface": "Interface",
"users": "Users",
"notifications": "Notifications",
"database": "Database",
"system": "System"
},
"tree": {
"payments_general": "General",
"payments_stars": "Telegram Stars",
"payments_yookassa": "YooKassa",
"payments_cryptobot": "CryptoBot",
"payments_cloudpayments": "CloudPayments",
"payments_freekassa": "FreeKassa",
"payments_kassa_ai": "Kassa AI",
"payments_platega": "Platega",
"payments_pal24": "PAL24",
"payments_heleket": "Heleket",
"payments_mulenpay": "MulenPay",
"payments_tribute": "Tribute",
"payments_wata": "Wata",
"payments_riopay": "RioPay",
"payments_severpay": "SeverPay",
"subs_core": "Core",
"subs_trial": "Trial",
"subs_pricing": "Pricing",
"subs_periods": "Periods",
"subs_traffic": "Traffic",
"subs_simple": "Simple subscription",
"subs_autopay": "Auto-pay",
"iface_general": "General",
"iface_connect": "Connect button",
"iface_miniapp": "MiniApp",
"iface_happ": "HAPP",
"iface_widget": "Login Widget",
"iface_oidc": "OIDC",
"iface_skip": "Skip steps",
"iface_additional": "Additional",
"users_support": "Support",
"users_referral": "Referral",
"users_channel": "Channel",
"users_localization": "Localization",
"users_moderation": "Moderation",
"notif_user": "User notifications",
"notif_admin": "Admin notifications",
"notif_reports": "Reports",
"db_general": "General",
"db_postgres": "PostgreSQL",
"db_sqlite": "SQLite",
"db_redis": "Redis",
"sys_core": "Core & Debug",
"sys_remnawave": "RemnaWave",
"sys_webapi": "Web API",
"sys_webhook": "Webhook",
"sys_server": "Server status",
"sys_monitoring": "Monitoring",
"sys_maintenance": "Maintenance",
"sys_backup": "Backup",
"sys_version": "Version",
"sys_logging": "Logging"
},
"categories": {
"TELEGRAM_WIDGET": "Telegram Login Widget",
"TELEGRAM_OIDC": "Telegram Login (OIDC)"
@@ -2198,6 +2301,8 @@
"resetModeWeeklyDesc": "Reset every week",
"resetModeMonthly": "Monthly",
"resetModeMonthlyDesc": "Reset every month",
"resetModeMonthRolling": "Rolling month",
"resetModeMonthRollingDesc": "Reset every 30 days from first connection",
"resetModeNever": "Never",
"resetModeNeverDesc": "Traffic is not reset",
"cancelButton": "Cancel",
@@ -2835,7 +2940,8 @@
"balance": "Balance",
"sync": "Synchronization",
"tickets": "Tickets",
"gifts": "Gifts"
"gifts": "Gifts",
"referrals": "Referrals"
},
"noTickets": "No tickets from this user",
"ticketsCount": "tickets",
@@ -2947,6 +3053,7 @@
"recentTransactions": "Recent transactions"
},
"sync": {
"selectSubscription": "Select subscription",
"hasDifferences": "Differences found",
"synced": "Synchronized",
"bot": "Bot",
@@ -2994,6 +3101,28 @@
"unknownUser": "Unknown",
"totalSent": "Total sent",
"totalReceived": "Total received"
},
"referrals": {
"referredBy": "Referred By",
"noReferrer": "No referrer assigned",
"assignReferrer": "Assign Referrer",
"removeReferrer": "Remove",
"referrerAssigned": "Referrer assigned successfully",
"referrerRemoved": "Referrer removed successfully",
"totalReferrals": "Total Referrals",
"totalEarnings": "Total Earnings",
"commission": "Commission",
"referralCode": "Referral Code",
"default": "default",
"referralsList": "Referrals",
"noReferrals": "No referrals yet",
"removeReferral": "Remove referral",
"referralRemoved": "Referral removed successfully",
"addReferral": "Add Referral",
"referralAdded": "Referral added successfully",
"searchPlaceholder": "Search by name, email, or Telegram ID...",
"alreadyReferred": "already has referrer",
"noUsersFound": "No users found"
}
}
},

View File

@@ -277,7 +277,8 @@
"trafficReset": {
"DAY": "بازنشانی روزانه",
"WEEK": "بازنشانی هفتگی",
"MONTH": "بازنشانی ماهانه"
"MONTH": "بازنشانی ماهانه",
"MONTH_ROLLING": "بازنشانی هر ۳۰ روز"
},
"traffic": "ترافیک",
"unlimited": "نامحدود",
@@ -950,6 +951,17 @@
"panel": {
"title": "پنل مدیریت",
"subtitle": "مدیریت سیستم",
"statsUptime": "آپتایم",
"statsBot": "ربات",
"statsCabinet": "کابینت",
"statsTrials": "آزمایشی",
"statsPaid": "پولی",
"statsOnline": "آنلاین",
"statsToday": "امروز",
"searchPlaceholder": "جستجو...",
"searchEmpty": "چیزی پیدا نشد",
"searchEmptyHint": "عبارت دیگری امتحان کنید",
"searchClear": "پاک کردن جستجو",
"dashboardDesc": "آمار و مانیتورینگ سیستم",
"ticketsDesc": "مدیریت تیکت‌های کاربران",
"settingsDesc": "تنظیمات و پارامترهای سیستم",
@@ -1485,6 +1497,7 @@
"title": "تنظیمات سیستم",
"allCategories": "همه دسته‌ها",
"noSettings": "تنظیماتی یافت نشد",
"quickToggles": "تغییر سریع",
"modified": "تغییر یافته",
"readOnly": "فقط خواندنی",
"reset": "بازنشانی",
@@ -1559,6 +1572,86 @@
"quickPresets": "پیش‌تنظیم‌های سریع",
"resetAllColors": "بازنشانی همه رنگ‌ها",
"statusColors": "رنگ‌های وضعیت",
"favorites": "علاقه‌مندی‌ها",
"branding": "برندسازی",
"theme": "پوسته",
"payments": "پرداخت‌ها",
"subscriptions": "اشتراک‌ها",
"interface": "رابط کاربری",
"notifications": "اعلان‌ها",
"database": "پایگاه داده",
"system": "سیستم",
"users": "کاربران",
"customization": "سفارشی‌سازی",
"settingsLabel": "تنظیمات",
"backToAdmin": "بازگشت به مدیریت",
"totalCount": "{{count}} مورد",
"modifiedCount": "{{count}} تغییر یافته",
"badgeDb": "پایگاه داده",
"badgeEnv": "متغیر محیطی",
"groups": {
"payments": "پرداخت‌ها",
"subscriptions": "اشتراک‌ها",
"interface": "رابط کاربری",
"users": "کاربران",
"notifications": "اعلان‌ها",
"database": "پایگاه داده",
"system": "سیستم"
},
"tree": {
"payments_general": "عمومی",
"payments_stars": "Telegram Stars",
"payments_yookassa": "YooKassa",
"payments_cryptobot": "CryptoBot",
"payments_cloudpayments": "CloudPayments",
"payments_freekassa": "FreeKassa",
"payments_kassa_ai": "Kassa AI",
"payments_platega": "Platega",
"payments_pal24": "PAL24",
"payments_heleket": "Heleket",
"payments_mulenpay": "MulenPay",
"payments_tribute": "Tribute",
"payments_wata": "Wata",
"payments_riopay": "RioPay",
"payments_severpay": "SeverPay",
"subs_core": "هسته",
"subs_trial": "آزمایشی",
"subs_pricing": "قیمت‌گذاری",
"subs_periods": "دوره‌ها",
"subs_traffic": "ترافیک",
"subs_simple": "اشتراک ساده",
"subs_autopay": "پرداخت خودکار",
"iface_general": "عمومی",
"iface_connect": "دکمه اتصال",
"iface_miniapp": "MiniApp",
"iface_happ": "HAPP",
"iface_widget": "ویجت ورود",
"iface_oidc": "OIDC",
"iface_skip": "رد کردن مراحل",
"iface_additional": "اضافی",
"users_support": "پشتیبانی",
"users_referral": "معرفی",
"users_channel": "کانال",
"users_localization": "بومی‌سازی",
"users_moderation": "مدیریت محتوا",
"notif_user": "اعلان‌های کاربر",
"notif_admin": "اعلان‌های مدیر",
"notif_reports": "گزارش‌ها",
"db_general": "عمومی",
"db_postgres": "PostgreSQL",
"db_sqlite": "SQLite",
"db_redis": "Redis",
"sys_core": "هسته و اشکال‌زدایی",
"sys_remnawave": "RemnaWave",
"sys_webapi": "Web API",
"sys_webhook": "Webhook",
"sys_server": "وضعیت سرور",
"sys_monitoring": "نظارت",
"sys_maintenance": "نگهداری",
"sys_backup": "پشتیبان‌گیری",
"sys_version": "نسخه",
"sys_logging": "لاگ‌ها"
},
"categories": {
"TELEGRAM_WIDGET": "Telegram Login Widget",
"TELEGRAM_OIDC": "Telegram Login (OIDC)"
@@ -1853,6 +1946,8 @@
"resetModeWeeklyDesc": "بازنشانی هر هفته",
"resetModeMonthly": "ماهانه",
"resetModeMonthlyDesc": "بازنشانی هر ماه",
"resetModeMonthRolling": "ماه متحرک",
"resetModeMonthRollingDesc": "بازنشانی هر ۳۰ روز از اولین اتصال",
"resetModeNever": "هرگز",
"resetModeNeverDesc": "ترافیک بازنشانی نمی‌شود",
"cancelButton": "لغو",
@@ -2467,7 +2562,8 @@
"balance": "موجودی",
"sync": "همگام‌سازی",
"tickets": "تیکت‌ها",
"gifts": "هدایا"
"gifts": "هدایا",
"referrals": "معرفی‌ها"
},
"noTickets": "این کاربر تیکتی ندارد",
"ticketsCount": "تیکت",
@@ -2575,6 +2671,7 @@
"recentTransactions": "تراکنش‌های اخیر"
},
"sync": {
"selectSubscription": "انتخاب اشتراک",
"hasDifferences": "اختلاف وجود دارد",
"synced": "همگام‌سازی شده",
"bot": "ربات",
@@ -2622,6 +2719,28 @@
"unknownUser": "ناشناس",
"totalSent": "کل ارسال شده",
"totalReceived": "کل دریافت شده"
},
"referrals": {
"referredBy": "معرفی‌شده توسط",
"noReferrer": "معرفی‌کننده‌ای تعیین نشده",
"assignReferrer": "تعیین معرفی‌کننده",
"removeReferrer": "حذف",
"referrerAssigned": "معرفی‌کننده تعیین شد",
"referrerRemoved": "معرفی‌کننده حذف شد",
"totalReferrals": "کل معرفی‌ها",
"totalEarnings": "کل درآمد",
"commission": "کمیسیون",
"referralCode": "کد معرفی",
"default": "پیش‌فرض",
"referralsList": "لیست معرفی‌ها",
"noReferrals": "هنوز معرفی‌ای نیست",
"removeReferral": "حذف معرفی",
"referralRemoved": "معرفی حذف شد",
"addReferral": "افزودن معرفی",
"referralAdded": "معرفی اضافه شد",
"searchPlaceholder": "جستجو بر اساس نام، ایمیل یا شناسه تلگرام...",
"alreadyReferred": "قبلاً معرفی شده",
"noUsersFound": "کاربری یافت نشد"
}
}
},
@@ -2740,9 +2859,19 @@
"tabs": {
"overview": "نمای کلی",
"nodes": "گره‌ها",
"traffic": "ترافیک",
"squads": "گروه‌ها",
"sync": "همگام‌سازی"
},
"traffic": {
"noData": "داده ترافیکی موجود نیست",
"totalDownload": "دانلود",
"totalUpload": "آپلود",
"totalTraffic": "مجموع",
"online": "آنلاین",
"inbounds": "ورودی‌ها",
"outbounds": "خروجی‌ها"
},
"nodes": {
"confirmRestartAll": "آیا مطمئنید می‌خواهید همه گره‌ها را راه‌اندازی مجدد کنید؟",
"disable": "غیرفعال",

View File

@@ -331,7 +331,8 @@
"trafficReset": {
"DAY": "Сброс ежедневно",
"WEEK": "Сброс еженедельно",
"MONTH": "Сброс ежемесячно"
"MONTH": "Сброс ежемесячно",
"MONTH_ROLLING": "Сброс раз в 30 дней"
},
"traffic": "Трафик",
"unlimited": "Безлимит",
@@ -1137,6 +1138,17 @@
"panel": {
"title": "Панель администратора",
"subtitle": "Управление системой",
"statsUptime": "Аптайм",
"statsBot": "Бот",
"statsCabinet": "Кабинет",
"statsTrials": "Триалы",
"statsPaid": "Платные",
"statsOnline": "Онлайн",
"statsToday": "сегодня",
"searchPlaceholder": "Поиск...",
"searchEmpty": "Ничего не найдено",
"searchEmptyHint": "Попробуйте другой запрос",
"searchClear": "Очистить поиск",
"dashboardDesc": "Статистика и мониторинг системы",
"ticketsDesc": "Обработка обращений пользователей",
"settingsDesc": "Настройки системы и параметры",
@@ -1502,16 +1514,26 @@
"tabs": {
"overview": "Обзор",
"nodes": "Ноды",
"traffic": "Трафик",
"squads": "Сквады",
"sync": "Синхронизация"
},
"traffic": {
"noData": "Нет данных о трафике",
"totalDownload": "Загрузка",
"totalUpload": "Отдача",
"totalTraffic": "Всего",
"online": "онлайн",
"inbounds": "Inbounds",
"outbounds": "Outbounds"
},
"overview": {
"system": "Система",
"usersOnline": "Онлайн",
"totalUsers": "Всего пользователей",
"nodesOnline": "Нод онлайн",
"connections": "Подключений",
"bandwidth": "Пропускная способность",
"bandwidth": "Трафик по inbounds",
"download": "Загрузка",
"upload": "Отдача",
"total": "Всего",
@@ -1839,8 +1861,11 @@
"title": "Настройки системы",
"allCategories": "Все категории",
"noSettings": "Настройки не найдены",
"quickToggles": "Быстрые переключатели",
"modified": "Изменено",
"readOnly": "Только чтение",
"badgeDb": "БД",
"badgeEnv": "ENV",
"reset": "Сбросить",
"categoriesCount": "категорий",
"settingsCount": "настроек",
@@ -1914,6 +1939,74 @@
"googleLabelPlaceholder": "Например: AbCdEfGhIjKl",
"googleLabelHint": "Метка конверсии для отслеживания событий",
"analyticsHint": "Счётчики автоматически встраиваются на все страницы кабинета. После сохранения ID скрипты подключаются при следующей загрузке страницы. Для удаления счётчика очистите поле и сохраните.",
"customization": "Кастомизация",
"settingsLabel": "Настройки",
"backToAdmin": "Назад к админке",
"totalCount": "{{count}} всего",
"modifiedCount": "{{count}} изменено",
"groups": {
"payments": "Платежи",
"subscriptions": "Подписки",
"interface": "Интерфейс",
"users": "Пользователи",
"notifications": "Уведомления",
"database": "База данных",
"system": "Система"
},
"tree": {
"payments_general": "Основные",
"payments_stars": "Telegram Stars",
"payments_yookassa": "YooKassa",
"payments_cryptobot": "CryptoBot",
"payments_cloudpayments": "CloudPayments",
"payments_freekassa": "FreeKassa",
"payments_kassa_ai": "Kassa AI",
"payments_platega": "Platega",
"payments_pal24": "PAL24",
"payments_heleket": "Heleket",
"payments_mulenpay": "MulenPay",
"payments_tribute": "Tribute",
"payments_wata": "Wata",
"payments_riopay": "RioPay",
"payments_severpay": "SeverPay",
"subs_core": "Основные",
"subs_trial": "Пробный период",
"subs_pricing": "Цены",
"subs_periods": "Периоды",
"subs_traffic": "Трафик",
"subs_simple": "Простая подписка",
"subs_autopay": "Автоплатёж",
"iface_general": "Основные",
"iface_connect": "Кнопка подключения",
"iface_miniapp": "MiniApp",
"iface_happ": "HAPP",
"iface_widget": "Виджет входа",
"iface_oidc": "OIDC",
"iface_skip": "Пропуск шагов",
"iface_additional": "Дополнительно",
"users_support": "Поддержка",
"users_referral": "Реферальная система",
"users_channel": "Канал",
"users_localization": "Локализация",
"users_moderation": "Модерация",
"notif_user": "Уведомления пользователей",
"notif_admin": "Уведомления админов",
"notif_reports": "Отчёты",
"db_general": "Основные",
"db_postgres": "PostgreSQL",
"db_sqlite": "SQLite",
"db_redis": "Redis",
"sys_core": "Ядро и отладка",
"sys_remnawave": "RemnaWave",
"sys_webapi": "Web API",
"sys_webhook": "Webhook",
"sys_server": "Статус сервера",
"sys_monitoring": "Мониторинг",
"sys_maintenance": "Обслуживание",
"sys_backup": "Бэкап",
"sys_version": "Версия",
"sys_logging": "Логи"
},
"presets": {
"standard": "Стандарт",
"ocean": "Океан",
@@ -2162,6 +2255,8 @@
"Devices Selection Enabled": "Выбор устройств",
"Max Devices Limit": "Макс. устройств",
"Price Per Device": "Цена за устройство",
"Multi Tariff Enabled": "Мультитарифный режим",
"Max Active Subscriptions": "Макс. подписок",
"Sales Mode": "Режим продаж",
"Available Renewal Periods": "Периоды продления",
"Available Subscription Periods": "Периоды подписки",
@@ -2712,6 +2807,8 @@
"resetModeWeeklyDesc": "Сброс каждую неделю",
"resetModeMonthly": "Ежемесячно",
"resetModeMonthlyDesc": "Сброс каждый месяц",
"resetModeMonthRolling": "Скользящий месяц",
"resetModeMonthRollingDesc": "Сброс через 30 дней от первого подключения",
"resetModeNever": "Никогда",
"resetModeNeverDesc": "Трафик не сбрасывается",
"cancelButton": "Отмена",
@@ -3357,7 +3454,8 @@
"balance": "Баланс",
"sync": "Синхронизация",
"tickets": "Тикеты",
"gifts": "Подарки"
"gifts": "Подарки",
"referrals": "Рефералы"
},
"noTickets": "У пользователя нет тикетов",
"ticketsCount": "тикетов",
@@ -3468,6 +3566,7 @@
"recentTransactions": "Последние транзакции"
},
"sync": {
"selectSubscription": "Выберите подписку",
"hasDifferences": "Есть расхождения",
"synced": "Синхронизировано",
"bot": "Бот",
@@ -3515,6 +3614,28 @@
"unknownUser": "Неизвестный",
"totalSent": "Всего отправлено",
"totalReceived": "Всего получено"
},
"referrals": {
"referredBy": "Приглашён",
"noReferrer": "Реферер не назначен",
"assignReferrer": "Назначить реферера",
"removeReferrer": "Удалить",
"referrerAssigned": "Реферер назначен",
"referrerRemoved": "Реферер удалён",
"totalReferrals": "Всего рефералов",
"totalEarnings": "Всего заработано",
"commission": "Комиссия",
"referralCode": "Реферальный код",
"default": "стандарт",
"referralsList": "Рефералы",
"noReferrals": "Рефералов пока нет",
"removeReferral": "Удалить реферала",
"referralRemoved": "Реферал удалён",
"addReferral": "Добавить реферала",
"referralAdded": "Реферал добавлен",
"searchPlaceholder": "Поиск по имени, email или Telegram ID...",
"alreadyReferred": "уже чей-то реферал",
"noUsersFound": "Пользователи не найдены"
}
}
},

View File

@@ -277,7 +277,8 @@
"trafficReset": {
"DAY": "每日重置",
"WEEK": "每周重置",
"MONTH": "每月重置"
"MONTH": "每月重置",
"MONTH_ROLLING": "每30天重置"
},
"traffic": "流量",
"unlimited": "无限",
@@ -950,6 +951,17 @@
"panel": {
"title": "管理面板",
"subtitle": "系统管理",
"statsUptime": "运行时间",
"statsBot": "机器人",
"statsCabinet": "控制台",
"statsTrials": "试用",
"statsPaid": "付费",
"statsOnline": "在线",
"statsToday": "今日",
"searchPlaceholder": "搜索...",
"searchEmpty": "未找到结果",
"searchEmptyHint": "请尝试其他关键词",
"searchClear": "清除搜索",
"dashboardDesc": "统计和系统监控",
"ticketsDesc": "处理用户工单",
"settingsDesc": "系统设置和参数",
@@ -1523,6 +1535,7 @@
"title": "系统设置",
"allCategories": "所有分类",
"noSettings": "未找到设置",
"quickToggles": "快速开关",
"modified": "已修改",
"readOnly": "只读",
"reset": "重置",
@@ -1597,6 +1610,86 @@
"quickPresets": "快速预设",
"resetAllColors": "重置所有颜色",
"statusColors": "状态颜色",
"favorites": "收藏",
"branding": "品牌",
"theme": "主题",
"payments": "支付",
"subscriptions": "订阅",
"interface": "界面",
"notifications": "通知",
"database": "数据库",
"system": "系统",
"users": "用户",
"customization": "自定义",
"settingsLabel": "设置",
"backToAdmin": "返回管理面板",
"totalCount": "共 {{count}} 项",
"modifiedCount": "已修改 {{count}} 项",
"badgeDb": "数据库",
"badgeEnv": "环境变量",
"groups": {
"payments": "支付",
"subscriptions": "订阅",
"interface": "界面",
"users": "用户",
"notifications": "通知",
"database": "数据库",
"system": "系统"
},
"tree": {
"payments_general": "通用",
"payments_stars": "Telegram Stars",
"payments_yookassa": "YooKassa",
"payments_cryptobot": "CryptoBot",
"payments_cloudpayments": "CloudPayments",
"payments_freekassa": "FreeKassa",
"payments_kassa_ai": "Kassa AI",
"payments_platega": "Platega",
"payments_pal24": "PAL24",
"payments_heleket": "Heleket",
"payments_mulenpay": "MulenPay",
"payments_tribute": "Tribute",
"payments_wata": "Wata",
"payments_riopay": "RioPay",
"payments_severpay": "SeverPay",
"subs_core": "核心",
"subs_trial": "试用",
"subs_pricing": "定价",
"subs_periods": "周期",
"subs_traffic": "流量",
"subs_simple": "简单订阅",
"subs_autopay": "自动支付",
"iface_general": "通用",
"iface_connect": "连接按钮",
"iface_miniapp": "MiniApp",
"iface_happ": "HAPP",
"iface_widget": "登录小部件",
"iface_oidc": "OIDC",
"iface_skip": "跳过步骤",
"iface_additional": "附加",
"users_support": "支持",
"users_referral": "推荐",
"users_channel": "频道",
"users_localization": "本地化",
"users_moderation": "审核",
"notif_user": "用户通知",
"notif_admin": "管理员通知",
"notif_reports": "报告",
"db_general": "通用",
"db_postgres": "PostgreSQL",
"db_sqlite": "SQLite",
"db_redis": "Redis",
"sys_core": "核心与调试",
"sys_remnawave": "RemnaWave",
"sys_webapi": "Web API",
"sys_webhook": "Webhook",
"sys_server": "服务器状态",
"sys_monitoring": "监控",
"sys_maintenance": "维护",
"sys_backup": "备份",
"sys_version": "版本",
"sys_logging": "日志"
},
"categories": {
"TELEGRAM_WIDGET": "Telegram Login Widget",
"TELEGRAM_OIDC": "Telegram Login (OIDC)"
@@ -1890,6 +1983,8 @@
"resetModeWeeklyDesc": "每周重置",
"resetModeMonthly": "每月",
"resetModeMonthlyDesc": "每月重置",
"resetModeMonthRolling": "滚动月",
"resetModeMonthRollingDesc": "从首次连接起每30天重置",
"resetModeNever": "从不",
"resetModeNeverDesc": "流量不重置",
"cancelButton": "取消",
@@ -2466,7 +2561,8 @@
"balance": "余额",
"sync": "同步",
"tickets": "工单",
"gifts": "礼物"
"gifts": "礼物",
"referrals": "推荐"
},
"noTickets": "该用户没有工单",
"ticketsCount": "个工单",
@@ -2574,6 +2670,7 @@
"recentTransactions": "最近交易"
},
"sync": {
"selectSubscription": "选择订阅",
"hasDifferences": "存在差异",
"synced": "已同步",
"bot": "机器人",
@@ -2621,6 +2718,28 @@
"unknownUser": "未知",
"totalSent": "已发送总数",
"totalReceived": "已收到总数"
},
"referrals": {
"referredBy": "推荐人",
"noReferrer": "未分配推荐人",
"assignReferrer": "分配推荐人",
"removeReferrer": "移除",
"referrerAssigned": "推荐人已分配",
"referrerRemoved": "推荐人已移除",
"totalReferrals": "总推荐数",
"totalEarnings": "总收益",
"commission": "佣金",
"referralCode": "推荐码",
"default": "默认",
"referralsList": "推荐列表",
"noReferrals": "暂无推荐",
"removeReferral": "移除推荐",
"referralRemoved": "推荐已移除",
"addReferral": "添加推荐",
"referralAdded": "推荐已添加",
"searchPlaceholder": "按名称、邮箱或 Telegram ID 搜索...",
"alreadyReferred": "已有推荐人",
"noUsersFound": "未找到用户"
}
}
},
@@ -2748,9 +2867,19 @@
"tabs": {
"overview": "概览",
"nodes": "节点",
"traffic": "流量",
"squads": "小队",
"sync": "同步"
},
"traffic": {
"noData": "暂无流量数据",
"totalDownload": "下载",
"totalUpload": "上传",
"totalTraffic": "总计",
"online": "在线",
"inbounds": "入站",
"outbounds": "出站"
},
"nodes": {
"confirmRestartAll": "确定要重启所有节点吗?",
"disable": "禁用",

View File

@@ -10,6 +10,7 @@ import {
type TopCampaignsResponse,
type RecentPaymentsResponse,
} from '../api/admin';
import { formatUptime } from '../utils/format';
const CABINET_VERSION = __APP_VERSION__;
import { useCurrency } from '../hooks/useCurrency';
@@ -254,14 +255,16 @@ function NodeCard({ node, onRestart, onToggle, isLoading }: NodeCardProps) {
</div>
{/* Xray Version & Uptime */}
{(node.xray_version || node.xray_uptime) && (
{(node.versions?.xray || node.xray_uptime > 0) && (
<div className="mb-3 flex items-center gap-3 text-xs">
{node.xray_version && (
{node.versions?.xray && (
<span className="rounded bg-dark-700/50 px-2 py-1 text-dark-300">
Xray {node.xray_version}
Xray {node.versions.xray}
</span>
)}
{node.xray_uptime && <span className="text-dark-500">Uptime: {node.xray_uptime}</span>}
{node.xray_uptime > 0 && (
<span className="text-dark-500">Uptime: {formatUptime(node.xray_uptime)}</span>
)}
</div>
)}

View File

@@ -165,10 +165,49 @@ function isSafeUrl(url: string | null | undefined): boolean {
}
// --- Slug utility ---
const TRANSLIT_MAP: Record<string, string> = {
а: 'a',
б: 'b',
в: 'v',
г: 'g',
д: 'd',
е: 'e',
ё: 'yo',
ж: 'zh',
з: 'z',
и: 'i',
й: 'y',
к: 'k',
л: 'l',
м: 'm',
н: 'n',
о: 'o',
п: 'p',
р: 'r',
с: 's',
т: 't',
у: 'u',
ф: 'f',
х: 'kh',
ц: 'ts',
ч: 'ch',
ш: 'sh',
щ: 'shch',
ъ: '',
ы: 'y',
ь: '',
э: 'e',
ю: 'yu',
я: 'ya',
};
function generateSlug(title: string): string {
return title
.toLowerCase()
.replace(/[^\w\s\-а-яёА-ЯЁ]/g, '')
const lower = title.toLowerCase();
const transliterated = Array.from(lower)
.map((ch) => TRANSLIT_MAP[ch] ?? ch)
.join('');
return transliterated
.replace(/[^a-z0-9\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '')

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@ import {
OFFER_TYPE_CONFIG,
OfferType,
} from '../api/promoOffers';
import { serversApi } from '../api/servers';
import { AdminBackButton } from '../components/admin';
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
@@ -31,6 +32,14 @@ export default function AdminPromoOfferTemplateEdit() {
const [testDurationHours, setTestDurationHours] = useState<number | ''>(0);
const [isActive, setIsActive] = useState(true);
const [isTestAccess, setIsTestAccess] = useState(false);
const [selectedSquadUuids, setSelectedSquadUuids] = useState<string[]>([]);
// Fetch available servers for test_access squad selection
const { data: serversData } = useQuery({
queryKey: ['admin-servers-for-promo'],
queryFn: () => serversApi.getServers(true),
enabled: isTestAccess,
});
// Query template
const { data: templatesData, isLoading } = useQuery({
@@ -52,6 +61,7 @@ export default function AdminPromoOfferTemplateEdit() {
setTestDurationHours(template.test_duration_hours || 0);
setIsActive(template.is_active);
setIsTestAccess(template.offer_type === 'test_access');
setSelectedSquadUuids(template.test_squad_uuids || []);
}
}, [template]);
@@ -78,6 +88,7 @@ export default function AdminPromoOfferTemplateEdit() {
const discountHours = toNumber(activeDiscountHours);
if (isTestAccess) {
data.test_duration_hours = testHours > 0 ? testHours : undefined;
data.test_squad_uuids = selectedSquadUuids;
} else {
data.active_discount_hours = discountHours > 0 ? discountHours : undefined;
}
@@ -201,6 +212,7 @@ export default function AdminPromoOfferTemplateEdit() {
</div>
{isTestAccess ? (
<>
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.promoOffers.form.testDurationHours')}
@@ -217,6 +229,53 @@ export default function AdminPromoOfferTemplateEdit() {
{t('admin.promoOffers.form.defaultZero')}
</p>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.promoOffers.form.testSquads', 'Тестовые серверы')}
</label>
{serversData?.servers && serversData.servers.length > 0 ? (
<div className="space-y-1.5">
{serversData.servers.map((server) => (
<label
key={server.squad_uuid || server.id}
className="flex cursor-pointer items-center gap-2.5 rounded-lg border border-dark-600 bg-dark-700/50 px-3 py-2 text-sm transition-colors hover:border-accent-500/50"
>
<input
type="checkbox"
checked={selectedSquadUuids.includes(server.squad_uuid)}
onChange={(e) => {
if (e.target.checked) {
setSelectedSquadUuids([...selectedSquadUuids, server.squad_uuid]);
} else {
setSelectedSquadUuids(
selectedSquadUuids.filter((u) => u !== server.squad_uuid),
);
}
}}
className="accent-accent-500"
/>
<span className="text-dark-200">{server.display_name}</span>
{server.country_code && (
<span className="text-dark-500">{server.country_code}</span>
)}
</label>
))}
</div>
) : (
<p className="text-sm text-dark-500">
{t('admin.promoOffers.form.noServers', 'Нет доступных серверов')}
</p>
)}
{selectedSquadUuids.length === 0 && (
<p className="mt-1 text-xs text-warning-400">
{t(
'admin.promoOffers.form.selectSquadHint',
'Выберите хотя бы один сервер для тестового доступа',
)}
</p>
)}
</div>
</>
) : (
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">

View File

@@ -11,6 +11,7 @@ import {
PromoCodeUpdateRequest,
PromoGroup,
} from '../api/promocodes';
import { tariffsApi } from '../api/tariffs';
import { usePlatform } from '../platform/hooks/usePlatform';
// Icons
@@ -60,6 +61,7 @@ export default function AdminPromocodeCreate() {
const [firstPurchaseOnly, setFirstPurchaseOnly] = useState(false);
const [validUntil, setValidUntil] = useState('');
const [promoGroupId, setPromoGroupId] = useState<number | null>(null);
const [tariffId, setTariffId] = useState<number | null>(null);
// Fetch promo groups (for promo_group type)
const { data: promoGroupsData } = useQuery({
@@ -69,6 +71,15 @@ export default function AdminPromocodeCreate() {
const promoGroups: PromoGroup[] = promoGroupsData?.items || [];
// Fetch tariffs to show trial tariff info
const { data: tariffsData } = useQuery({
queryKey: ['admin-tariffs-for-promo'],
queryFn: () => tariffsApi.getTariffs(true),
enabled: type === 'trial_subscription',
});
const trialTariff = tariffsData?.tariffs?.find((t) => t.is_trial_available) || null;
// Fetch promocode for editing
const { isLoading: isLoadingPromocode } = useQuery({
queryKey: ['admin-promocode', id],
@@ -94,6 +105,7 @@ export default function AdminPromocodeCreate() {
setFirstPurchaseOnly(data.first_purchase_only || false);
setValidUntil(data.valid_until ? data.valid_until.split('T')[0] : '');
setPromoGroupId(data.promo_group_id || null);
setTariffId(data.tariff_id || null);
return data;
}, []),
});
@@ -137,6 +149,7 @@ export default function AdminPromocodeCreate() {
first_purchase_only: firstPurchaseOnly,
valid_until: validUntil ? new Date(validUntil).toISOString() : null,
promo_group_id: type === 'promo_group' ? promoGroupId : null,
...(type === 'trial_subscription' && tariffId ? { tariff_id: tariffId } : {}),
};
if (isEdit) {
@@ -301,6 +314,40 @@ export default function AdminPromocodeCreate() {
</div>
)}
{type === 'trial_subscription' && (
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.promocodes.form.tariff', 'Тариф')}
</label>
<select
value={tariffId || ''}
onChange={(e) => setTariffId(e.target.value ? parseInt(e.target.value) : null)}
className="input"
>
<option value="">
{trialTariff
? t('admin.promocodes.form.defaultTrialTariff', 'По умолчанию: {{name}}', {
name: trialTariff.name,
})
: t('admin.promocodes.form.selectTariff', '— Выберите тариф —')}
</option>
{tariffsData?.tariffs?.map((tariff) => (
<option key={tariff.id} value={tariff.id}>
{tariff.name} ({tariff.traffic_limit_gb} GB, {tariff.device_limit} устр.)
</option>
))}
</select>
{!tariffId && !trialTariff && (
<div className="mt-1 text-xs text-warning-400">
{t(
'admin.promocodes.form.noTrialTariffHint',
'Выберите тариф или отметьте тариф как «доступен для триала» в настройках.',
)}
</div>
)}
</div>
)}
{type === 'promo_group' && (
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">

View File

@@ -5,11 +5,13 @@ import { useTranslation } from 'react-i18next';
import {
adminRemnawaveApi,
NodeInfo,
NodeRealtimeStats,
SquadWithLocalInfo,
SystemStatsResponse,
AutoSyncStatus,
} from '../api/adminRemnawave';
import { usePlatform } from '../platform/hooks/usePlatform';
import { formatUptime } from '../utils/format';
import {
ServerIcon,
ChartIcon,
@@ -43,16 +45,6 @@ const formatBytes = (bytes: number): string => {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const formatUptime = (seconds: number): string => {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
};
const getCountryFlag = (code: string | null | undefined): string => {
if (!code) return '🌍';
const codeMap: Record<string, string> = {
@@ -175,11 +167,12 @@ function NodeCard({ node, onAction, isLoading }: NodeCardProps) {
{t('admin.remnawave.nodes.trafficUsed', 'used')}
</span>
)}
{node.xray_uptime && (
{node.xray_uptime > 0 && (
<span>
{t('admin.remnawave.nodes.uptimeLabel', 'Uptime')}: {node.xray_uptime}
{t('admin.remnawave.nodes.uptimeLabel', 'Uptime')}: {formatUptime(node.xray_uptime)}
</span>
)}
{node.versions?.xray && <span>Xray {node.versions.xray}</span>}
</div>
</div>
@@ -355,15 +348,9 @@ function OverviewTab({ stats, isLoading, onRefresh }: OverviewTabProps) {
);
}
// Use (total - available) instead of raw "used" to exclude disk cache/buffers
// This matches what htop/free show as actual application memory usage
const memoryActualUsed =
stats.server_info.memory_available > 0
? stats.server_info.memory_total - stats.server_info.memory_available
: stats.server_info.memory_used;
const memoryUsedPercent =
stats.server_info.memory_total > 0
? Math.round((memoryActualUsed / stats.server_info.memory_total) * 100)
? Math.round((stats.server_info.memory_used / stats.server_info.memory_total) * 100)
: 0;
return (
@@ -405,24 +392,24 @@ function OverviewTab({ stats, isLoading, onRefresh }: OverviewTabProps) {
{/* Bandwidth */}
<div>
<h3 className="mb-3 text-sm font-medium text-dark-300">
{t('admin.remnawave.overview.bandwidth', 'Realtime Bandwidth')}
{t('admin.remnawave.overview.bandwidth', 'Inbound Traffic')}
</h3>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<StatCard
label={t('admin.remnawave.overview.download', 'Download')}
value={formatBytes(stats.bandwidth.realtime_download) + '/s'}
value={formatBytes(stats.bandwidth.realtime_download)}
icon={<span className="text-lg"></span>}
color="green"
/>
<StatCard
label={t('admin.remnawave.overview.upload', 'Upload')}
value={formatBytes(stats.bandwidth.realtime_upload) + '/s'}
value={formatBytes(stats.bandwidth.realtime_upload)}
icon={<span className="text-lg"></span>}
color="blue"
/>
<StatCard
label={t('admin.remnawave.overview.total', 'Total')}
value={formatBytes(stats.bandwidth.realtime_total) + '/s'}
value={formatBytes(stats.bandwidth.realtime_total)}
icon={<span className="text-lg"></span>}
color="purple"
/>
@@ -437,14 +424,14 @@ function OverviewTab({ stats, isLoading, onRefresh }: OverviewTabProps) {
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3">
<StatCard
label={t('admin.remnawave.overview.cpu', 'CPU Cores')}
value={`${stats.server_info.cpu_cores} (${stats.server_info.cpu_physical_cores} physical)`}
value={stats.server_info.cpu_cores}
icon={<span className="text-lg"></span>}
color="accent"
/>
<StatCard
label={t('admin.remnawave.overview.memory', 'Memory')}
value={`${memoryUsedPercent}%`}
subValue={`${formatBytes(memoryActualUsed)} / ${formatBytes(stats.server_info.memory_total)}`}
subValue={`${formatBytes(stats.server_info.memory_used)} / ${formatBytes(stats.server_info.memory_total)}`}
icon={<span className="text-lg">💾</span>}
color={memoryUsedPercent > 80 ? 'red' : memoryUsedPercent > 60 ? 'orange' : 'green'}
/>
@@ -865,7 +852,138 @@ function SyncTab({
);
}
type TabType = 'overview' | 'nodes' | 'squads' | 'sync';
interface TrafficTabProps {
data: NodeRealtimeStats[] | undefined;
isLoading: boolean;
onRefresh: () => void;
}
function TrafficTab({ data, isLoading, onRefresh }: TrafficTabProps) {
const { t } = useTranslation();
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
);
}
if (!data || data.length === 0) {
return (
<div className="py-12 text-center">
<p className="text-dark-400">
{t('admin.remnawave.traffic.noData', 'No traffic data available')}
</p>
<button onClick={onRefresh} className="btn-primary mt-4">
{t('common.retry', 'Retry')}
</button>
</div>
);
}
const totalDownload = data.reduce((acc, n) => acc + n.downloadBytes, 0);
const totalUpload = data.reduce((acc, n) => acc + n.uploadBytes, 0);
return (
<div className="space-y-6">
{/* Totals */}
<div className="grid grid-cols-3 gap-3">
<StatCard
label={t('admin.remnawave.traffic.totalDownload', 'Download')}
value={formatBytes(totalDownload)}
icon={<span className="text-lg"></span>}
color="green"
/>
<StatCard
label={t('admin.remnawave.traffic.totalUpload', 'Upload')}
value={formatBytes(totalUpload)}
icon={<span className="text-lg"></span>}
color="blue"
/>
<StatCard
label={t('admin.remnawave.traffic.totalTraffic', 'Total')}
value={formatBytes(totalDownload + totalUpload)}
icon={<span className="text-lg"></span>}
color="purple"
/>
</div>
{/* Per-node inbound breakdown */}
{data.map((node) => (
<div key={node.nodeUuid} className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
{node.countryEmoji && <span className="text-lg">{node.countryEmoji}</span>}
<h3 className="font-medium text-dark-100">{node.nodeName}</h3>
{node.providerName && (
<span className="rounded bg-dark-700/50 px-1.5 py-0.5 text-xs text-dark-400">
{node.providerName}
</span>
)}
<span className="text-xs text-dark-500">
{node.usersOnline} {t('admin.remnawave.traffic.online', 'online')}
</span>
</div>
<span className="text-sm text-dark-300">{formatBytes(node.totalBytes)}</span>
</div>
{(node.inbounds?.length ?? 0) > 0 && (
<div className="space-y-1">
<p className="mb-2 text-xs font-medium text-dark-400">
{t('admin.remnawave.traffic.inbounds', 'Inbounds')}
</p>
{[...(node.inbounds ?? [])]
.sort((a, b) => b.totalBytes - a.totalBytes)
.map((ib) => (
<div
key={ib.tag}
className="flex items-center justify-between rounded-lg bg-dark-900/50 px-3 py-2"
>
<span className="truncate text-sm text-dark-200">{ib.tag}</span>
<div className="flex shrink-0 gap-4 text-xs text-dark-400">
<span> {formatBytes(ib.downloadBytes)}</span>
<span> {formatBytes(ib.uploadBytes)}</span>
<span className="font-medium text-dark-300">
{formatBytes(ib.totalBytes)}
</span>
</div>
</div>
))}
</div>
)}
{(node.outbounds?.length ?? 0) > 0 && (
<div className="mt-3 space-y-1">
<p className="mb-2 text-xs font-medium text-dark-400">
{t('admin.remnawave.traffic.outbounds', 'Outbounds')}
</p>
{[...(node.outbounds ?? [])]
.sort((a, b) => b.totalBytes - a.totalBytes)
.map((ob) => (
<div
key={ob.tag}
className="flex items-center justify-between rounded-lg bg-dark-900/50 px-3 py-2"
>
<span className="truncate text-sm text-dark-200">{ob.tag}</span>
<div className="flex shrink-0 gap-4 text-xs text-dark-400">
<span> {formatBytes(ob.downloadBytes)}</span>
<span> {formatBytes(ob.uploadBytes)}</span>
<span className="font-medium text-dark-300">
{formatBytes(ob.totalBytes)}
</span>
</div>
</div>
))}
</div>
)}
</div>
))}
</div>
);
}
type TabType = 'overview' | 'nodes' | 'traffic' | 'squads' | 'sync';
export default function AdminRemnawave() {
const { t } = useTranslation();
@@ -918,6 +1036,17 @@ export default function AdminRemnawave() {
enabled: activeTab === 'squads',
});
const {
data: realtimeData,
isLoading: isLoadingRealtime,
refetch: refetchRealtime,
} = useQuery({
queryKey: ['admin-remnawave-realtime'],
queryFn: adminRemnawaveApi.getNodesRealtime,
enabled: activeTab === 'traffic',
refetchInterval: 10000,
});
const { data: autoSyncStatus, isLoading: isLoadingAutoSync } = useQuery({
queryKey: ['admin-remnawave-autosync'],
queryFn: adminRemnawaveApi.getAutoSyncStatus,
@@ -992,6 +1121,11 @@ export default function AdminRemnawave() {
icon: <ChartIcon />,
},
{ id: 'nodes' as const, label: t('admin.remnawave.tabs.nodes', 'Nodes'), icon: <GlobeIcon /> },
{
id: 'traffic' as const,
label: t('admin.remnawave.tabs.traffic', 'Traffic'),
icon: <ChartIcon />,
},
{
id: 'squads' as const,
label: t('admin.remnawave.tabs.squads', 'Squads'),
@@ -1089,6 +1223,14 @@ export default function AdminRemnawave() {
/>
)}
{activeTab === 'traffic' && (
<TrafficTab
data={realtimeData}
isLoading={isLoadingRealtime}
onRefresh={() => refetchRealtime()}
/>
)}
{activeTab === 'squads' && (
<SquadsTab
squads={squadsData?.items || []}

View File

@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import { adminSettingsApi, SettingDefinition } from '../api/adminSettings';
import { themeColorsApi } from '../api/themeColors';
import { useFavoriteSettings } from '../hooks/useFavoriteSettings';
import { MENU_SECTIONS, MenuItem, formatSettingKey } from '../components/admin';
import { SETTINGS_TREE, findTreeLocation, formatSettingKey } from '../components/admin';
import { usePlatform } from '../platform/hooks/usePlatform';
import { AnalyticsTab } from '../components/admin/AnalyticsTab';
import { BrandingTab } from '../components/admin/BrandingTab';
@@ -13,12 +13,9 @@ import { MenuEditorTab } from '../components/admin/MenuEditorTab';
import { ThemeTab } from '../components/admin/ThemeTab';
import { FavoritesTab } from '../components/admin/FavoritesTab';
import { SettingsTab } from '../components/admin/SettingsTab';
import { SettingsTreeSidebar } from '../components/admin/SettingsTreeSidebar';
import { SettingsMobileTabs } from '../components/admin/SettingsMobileTabs';
import {
SettingsSearch,
SettingsSearchMobile,
SettingsSearchResults,
} from '../components/admin/SettingsSearch';
import { SettingsSearchMobile, SettingsSearchResults } from '../components/admin/SettingsSearch';
// BackIcon
const BackIcon = () => (
@@ -33,17 +30,18 @@ const BackIcon = () => (
</svg>
);
// Find section ID by category key
function findSectionByCategory(categoryKey: string): string | null {
for (const section of MENU_SECTIONS) {
for (const item of section.items) {
if (item.categories?.includes(categoryKey)) {
return item.id;
}
}
}
return null;
}
// ChevronRight for breadcrumbs
const ChevronRightIcon = () => (
<svg
className="h-3.5 w-3.5 text-dark-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
);
export default function AdminSettings() {
const { t } = useTranslation();
@@ -73,23 +71,42 @@ export default function AdminSettings() {
queryFn: () => adminSettingsApi.getSettings(),
});
// Get current menu item configuration
const currentMenuItem = useMemo(() => {
for (const section of MENU_SECTIONS) {
const item = section.items.find((i: MenuItem) => i.id === activeSection);
if (item) return item;
// Find active tree info (group + child for tree sub-items)
// No useMemo needed — SETTINGS_TREE is a static constant, iteration is trivial
let activeTreeInfo: {
group: (typeof SETTINGS_TREE.groups)[number];
child: (typeof SETTINGS_TREE.groups)[number]['children'][number];
} | null = null;
for (const group of SETTINGS_TREE.groups) {
const child = group.children.find((c) => c.id === activeSection);
if (child) {
activeTreeInfo = { group, child };
break;
}
}
return null;
}, [activeSection]);
// Get categories for current section
// Settings that require SALES_MODE=tariffs to be visible
const TARIFF_MODE_SETTINGS = ['MULTI_TARIFF_ENABLED', 'MAX_ACTIVE_SUBSCRIPTIONS'];
// Check if tariffs mode is active
const isTariffsMode = useMemo(() => {
if (!allSettings || !Array.isArray(allSettings)) return false;
const salesMode = allSettings.find((s: SettingDefinition) => s.key === 'SALES_MODE');
return salesMode?.current === 'tariffs';
}, [allSettings]);
// Get categories for current sub-item
const currentCategories = useMemo(() => {
if (!currentMenuItem?.categories || !allSettings || !Array.isArray(allSettings)) return [];
if (!activeTreeInfo || !allSettings || !Array.isArray(allSettings)) return [];
const categoryKeys = activeTreeInfo.child.categories;
const categoryMap = new Map<string, SettingDefinition[]>();
for (const setting of allSettings) {
if (currentMenuItem.categories.includes(setting.category.key)) {
if (categoryKeys.includes(setting.category.key)) {
// Hide tariff-dependent settings when not in tariffs mode
if (!isTariffsMode && TARIFF_MODE_SETTINGS.includes(setting.key)) continue;
if (!categoryMap.has(setting.category.key)) {
categoryMap.set(setting.category.key, []);
}
@@ -102,7 +119,8 @@ export default function AdminSettings() {
label: t(`admin.settings.categories.${key}`, key),
settings,
}));
}, [currentMenuItem, allSettings, t]);
// eslint-disable-next-line react-hooks/exhaustive-deps -- activeTreeInfo derived from activeSection
}, [activeSection, allSettings, isTariffsMode, t]);
// Filter settings for search - GLOBAL search across all settings
const filteredSettings = useMemo(() => {
@@ -112,27 +130,20 @@ export default function AdminSettings() {
if (!q) return [];
return allSettings.filter((s: SettingDefinition) => {
// Search by key
// Hide tariff-dependent settings when not in tariffs mode
if (!isTariffsMode && TARIFF_MODE_SETTINGS.includes(s.key)) return false;
if (s.key.toLowerCase().includes(q)) return true;
// Search by original name
if (s.name?.toLowerCase().includes(q)) return true;
// Search by translated name
const formattedKey = formatSettingKey(s.name || s.key);
const translatedName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey);
if (translatedName.toLowerCase().includes(q)) return true;
// Search by description
if (s.hint?.description?.toLowerCase().includes(q)) return true;
// Search by category
const categoryLabel = t(`admin.settings.categories.${s.category.key}`, s.category.key);
if (categoryLabel.toLowerCase().includes(q)) return true;
return false;
});
}, [allSettings, searchQuery, t]);
}, [allSettings, searchQuery, isTariffsMode, t]);
// Favorite settings
const favoriteSettings = useMemo(() => {
@@ -140,19 +151,43 @@ export default function AdminSettings() {
return allSettings.filter((s: SettingDefinition) => favorites.includes(s.key));
}, [allSettings, favorites]);
// Count of modified settings
const modifiedCount = useMemo(() => {
if (!allSettings || !Array.isArray(allSettings)) return 0;
return allSettings.filter((s: SettingDefinition) => s.has_override).length;
}, [allSettings]);
// Total settings count
const totalCount = useMemo(() => {
if (!allSettings || !Array.isArray(allSettings)) return 0;
return allSettings.length;
}, [allSettings]);
// Handle setting selection from autocomplete
const handleSelectSetting = useCallback(
(setting: SettingDefinition) => {
const sectionId = findSectionByCategory(setting.category.key);
if (sectionId) {
setActiveSection(sectionId);
const location = findTreeLocation(setting.category.key);
if (location) {
setActiveSection(location.subItemId);
}
// Set search to setting key to filter to just this setting
setSearchQuery(setting.key);
},
[setActiveSection, setSearchQuery],
);
// Get the display title for the current section
const sectionTitle = useMemo(() => {
// Special items
const specialItem = SETTINGS_TREE.specialItems.find((item) => item.id === activeSection);
if (specialItem) return t(`admin.settings.${specialItem.id}`);
// Tree sub-items
if (activeTreeInfo) return t(`admin.settings.tree.${activeTreeInfo.child.id}`);
return t('admin.settings.title');
// eslint-disable-next-line react-hooks/exhaustive-deps -- activeTreeInfo derived from activeSection
}, [activeSection, t]);
// Render content based on active section
const renderContent = () => {
// If searching, always show search results regardless of active section
@@ -186,17 +221,7 @@ export default function AdminSettings() {
/>
);
default:
if (
[
'payments',
'subscriptions',
'interface',
'notifications',
'database',
'system',
'users',
].includes(activeSection)
) {
if (activeTreeInfo) {
return (
<SettingsTab
categories={currentCategories}
@@ -207,6 +232,8 @@ export default function AdminSettings() {
/>
);
}
// Unknown section — fallback to branding
setActiveSection('branding');
return null;
}
};
@@ -214,7 +241,7 @@ export default function AdminSettings() {
return (
<>
{/* Mobile Layout */}
<div className="space-y-4 lg:hidden">
<div className="space-y-4 pb-4 lg:hidden">
<SettingsMobileTabs
activeSection={activeSection}
setActiveSection={setActiveSection}
@@ -233,7 +260,7 @@ export default function AdminSettings() {
{/* Desktop Layout - fixed sidebar, scrollable content */}
<div className="hidden h-[calc(100vh-120px)] lg:flex">
{/* Fixed Sidebar */}
<div className="w-64 shrink-0 overflow-y-auto border-r border-dark-700/50">
<div className="w-[264px] shrink-0 overflow-y-auto border-r border-dark-700/50">
<div className="border-b border-dark-700/50 p-4">
<div className="flex items-center gap-3">
{/* Show back button only on web, not in Telegram Mini App */}
@@ -241,6 +268,7 @@ export default function AdminSettings() {
<button
onClick={() => navigate('/admin')}
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"
aria-label={t('admin.settings.backToAdmin')}
>
<BackIcon />
</button>
@@ -248,67 +276,52 @@ export default function AdminSettings() {
<h1 className="text-lg font-bold text-dark-100">{t('admin.settings.title')}</h1>
</div>
</div>
<nav className="space-y-1 p-2">
{MENU_SECTIONS.map((section, sectionIdx) => (
<div key={section.id}>
{sectionIdx > 0 && <div className="my-3 border-t border-dark-700/50" />}
{section.items.map((item) => {
const isActive = activeSection === item.id;
const hasIcon = item.iconType === 'star';
return (
<button
key={item.id}
onClick={() => setActiveSection(item.id)}
className={`flex w-full items-center gap-3 rounded-xl px-3 py-2.5 transition-all ${
isActive
? 'bg-accent-500/10 text-accent-400'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200'
}`}
>
{hasIcon && (
<svg
className={`h-4 w-4 ${isActive ? 'fill-current' : ''}`}
fill={isActive ? 'currentColor' : 'none'}
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
/>
</svg>
)}
<span className="font-medium">{t(`admin.settings.${item.id}`)}</span>
{item.id === 'favorites' && favorites.length > 0 && (
<span className="ml-auto rounded-full bg-warning-500/20 px-2 py-0.5 text-xs text-warning-400">
{favorites.length}
</span>
)}
</button>
);
})}
</div>
))}
</nav>
</div>
{/* Scrollable Content */}
<div className="min-w-0 flex-1 overflow-y-auto p-6">
<div className="mb-4 flex items-center gap-3">
<h2 className="truncate text-xl font-semibold text-dark-100">
{t(`admin.settings.${activeSection}`)}
</h2>
<div className="flex-1" />
<SettingsSearch
<SettingsTreeSidebar
activeSection={activeSection}
onSectionChange={setActiveSection}
favoritesCount={favorites.length}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
resultsCount={filteredSettings.length}
onSearchChange={setSearchQuery}
allSettings={allSettings}
onSelectSetting={handleSelectSetting}
/>
</div>
{/* Scrollable Content */}
<div className="min-w-0 flex-1 overflow-y-auto p-6">
{/* Breadcrumb for tree sub-items */}
{activeTreeInfo && !searchQuery.trim() && (
<div className="mb-2 flex items-center gap-1.5 text-xs">
<button
onClick={() => setActiveSection(activeTreeInfo.group.children[0].id)}
className="text-dark-500 transition-colors hover:text-dark-300"
>
{t(`admin.settings.groups.${activeTreeInfo.group.id}`)}
</button>
<ChevronRightIcon />
<span className="text-dark-300">
{t(`admin.settings.tree.${activeTreeInfo.child.id}`)}
</span>
</div>
)}
{/* Title + count badges */}
<div className="mb-4 flex items-center gap-3">
<h2 className="truncate text-xl font-semibold text-dark-100">{sectionTitle}</h2>
{totalCount > 0 && !searchQuery.trim() && activeTreeInfo && (
<div className="flex items-center gap-2">
<span className="rounded-full bg-dark-700/50 px-2 py-0.5 text-xs text-dark-400">
{t('admin.settings.totalCount', { count: totalCount })}
</span>
{modifiedCount > 0 && (
<span className="rounded-full bg-warning-500/20 px-2 py-0.5 text-xs text-warning-400">
{t('admin.settings.modifiedCount', { count: modifiedCount })}
</span>
)}
</div>
)}
</div>
<SettingsSearchResults searchQuery={searchQuery} resultsCount={filteredSettings.length} />
{renderContent()}
</div>

View File

@@ -220,7 +220,7 @@ export default function AdminTariffCreate() {
traffic_limit_gb: toNumber(trafficLimitGb, 100),
device_limit: toNumber(deviceLimit, 1),
device_price_kopeks:
toNumber(devicePriceKopeks) > 0 ? toNumber(devicePriceKopeks) : undefined,
toNumber(devicePriceKopeks) >= 0 ? toNumber(devicePriceKopeks) : undefined,
max_device_limit: toNumber(maxDeviceLimit) > 0 ? toNumber(maxDeviceLimit) : undefined,
tier_level: toNumber(tierLevel, 1),
period_prices: isDaily ? [] : periodPrices.filter((p) => p.price_kopeks >= 0),
@@ -912,7 +912,7 @@ export default function AdminTariffCreate() {
onClick={() => {
const gb = toNumber(newPackageGb, 0);
const price = toNumber(newPackagePrice, 0);
if (gb > 0 && price > 0 && !trafficTopupPackages[String(gb)]) {
if (gb > 0 && price >= 0 && !trafficTopupPackages[String(gb)]) {
setTrafficTopupPackages((prev) => ({
...prev,
[String(gb)]: price * 100,
@@ -1029,6 +1029,11 @@ export default function AdminTariffCreate() {
{ value: 'DAY', labelKey: 'admin.tariffs.resetModeDaily', emoji: '📅' },
{ value: 'WEEK', labelKey: 'admin.tariffs.resetModeWeekly', emoji: '📆' },
{ value: 'MONTH', labelKey: 'admin.tariffs.resetModeMonthly', emoji: '🗓️' },
{
value: 'MONTH_ROLLING',
labelKey: 'admin.tariffs.resetModeMonthRolling',
emoji: '🔄',
},
{ value: 'NO_RESET', labelKey: 'admin.tariffs.resetModeNever', emoji: '🚫' },
].map((option) => (
<button

View File

@@ -181,10 +181,12 @@ export default function AdminTickets() {
// Cancel in-flight uploads and cleanup blob URL on unmount
useEffect(() => {
const uploadRef = uploadIdRef;
const prevRef = previewRef;
return () => {
uploadIdRef.current++;
if (previewRef.current) {
URL.revokeObjectURL(previewRef.current);
uploadRef.current++;
if (prevRef.current) {
URL.revokeObjectURL(prevRef.current);
}
};
}, []);

File diff suppressed because it is too large Load Diff

View File

@@ -80,6 +80,12 @@ export default function Balance() {
message: string;
amount: number;
} | null>(null);
const [promoSelectSubs, setPromoSelectSubs] = useState<Array<{
id: number;
tariff_name: string;
days_left: number;
}> | null>(null);
const [promoSelectCode, setPromoSelectCode] = useState<string | null>(null);
const [transactionsPage, setTransactionsPage] = useState(1);
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
@@ -135,27 +141,38 @@ export default function Balance() {
}
};
const handlePromocodeActivate = async () => {
if (!promocode.trim()) return;
const handlePromocodeActivate = async (subscriptionId?: number) => {
const code = subscriptionId ? promoSelectCode || '' : promocode.trim();
if (!code) return;
setPromocodeLoading(true);
setPromocodeError(null);
setPromocodeSuccess(null);
try {
const result = await balanceApi.activatePromocode(promocode.trim());
const result = await balanceApi.activatePromocode(code, subscriptionId);
if (result.error === 'select_subscription' && result.eligible_subscriptions) {
setPromoSelectSubs(result.eligible_subscriptions);
setPromoSelectCode(result.code || code);
return;
}
if (result.success) {
const bonusAmount = result.balance_after - result.balance_before;
const bonusAmount = (result.balance_after || 0) - (result.balance_before || 0);
setPromocodeSuccess({
message: result.bonus_description || t('balance.promocode.success'),
amount: bonusAmount,
});
setTransactionsPage(1);
setPromocode('');
setPromoSelectSubs(null);
setPromoSelectCode(null);
await refetchBalance();
await refreshUser();
queryClient.invalidateQueries({ queryKey: ['transactions'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
}
} catch (error: unknown) {
const axiosError = error as { response?: { data?: { detail?: string } } };
@@ -170,6 +187,8 @@ export default function Balance() {
? 'already_used_by_user'
: 'server_error';
setPromocodeError(t(`balance.promocode.errors.${errorKey}`));
setPromoSelectSubs(null);
setPromoSelectCode(null);
} finally {
setPromocodeLoading(false);
}
@@ -214,7 +233,7 @@ export default function Balance() {
disabled={promocodeLoading}
/>
<Button
onClick={handlePromocodeActivate}
onClick={() => handlePromocodeActivate()}
disabled={!promocode.trim()}
loading={promocodeLoading}
>
@@ -250,6 +269,39 @@ export default function Balance() {
</motion.div>
)}
</AnimatePresence>
{promoSelectSubs && promoSelectSubs.length > 0 && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mt-3 space-y-2 rounded-linear border border-accent-500/30 bg-accent-500/10 p-3"
>
<div className="text-sm font-medium text-dark-200">
{t('balance.promocode.selectSubscription', 'К какой подписке применить промокод?')}
</div>
{promoSelectSubs.map((sub) => (
<button
key={sub.id}
onClick={() => handlePromocodeActivate(sub.id)}
disabled={promocodeLoading}
className="flex w-full items-center justify-between rounded-linear border border-dark-600 bg-dark-700 px-3 py-2 text-sm text-dark-200 transition-colors hover:border-accent-500/50 hover:bg-dark-600"
>
<span>{sub.tariff_name}</span>
<span className="text-dark-400">
{t('balance.promocode.daysLeft', '{{count}} дн.', { count: sub.days_left })}
</span>
</button>
))}
<button
onClick={() => {
setPromoSelectSubs(null);
setPromoSelectCode(null);
}}
className="text-xs text-dark-400 hover:text-dark-200"
>
{t('common.cancel', 'Отмена')}
</button>
</motion.div>
)}
</Card>
</motion.div>

View File

@@ -1,5 +1,5 @@
import { useEffect, useCallback, useMemo, useRef } from 'react';
import { Link, useNavigate } from 'react-router';
import { Link, useNavigate, useSearchParams } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { openLink as sdkOpenLink } from '@telegram-apps/sdk-react';
@@ -14,6 +14,8 @@ import InstallationGuide from '../components/connection/InstallationGuide';
export default function Connection() {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const subId = searchParams.get('sub') ? Number(searchParams.get('sub')) : undefined;
const user = useAuthStore((state) => state.user);
const isAdmin = useAuthStore((state) => state.isAdmin);
const { isTelegramWebApp } = useTelegramSDK();
@@ -27,8 +29,8 @@ export default function Connection() {
isLoading,
error,
} = useQuery<AppConfig>({
queryKey: ['appConfig'],
queryFn: () => subscriptionApi.getAppConfig(),
queryKey: ['appConfig', subId],
queryFn: () => subscriptionApi.getAppConfig(subId),
});
const handleGoBack = useCallback(() => {
@@ -41,9 +43,10 @@ export default function Connection() {
state: {
url: appConfig?.subscriptionUrl,
hideLink: appConfig?.hideLink ?? false,
subscriptionId: subId,
},
});
}, [navigate, appConfig?.subscriptionUrl, appConfig?.hideLink, isTelegramWebApp]);
}, [navigate, appConfig?.subscriptionUrl, appConfig?.hideLink, isTelegramWebApp, subId]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {

View File

@@ -8,6 +8,7 @@ import { AdminBackButton } from '@/components/admin';
interface ConnectionQRState {
url: string;
hideLink: boolean;
subscriptionId?: number;
}
function isValidState(state: unknown): state is ConnectionQRState {
@@ -24,12 +25,14 @@ export default function ConnectionQR() {
const state = location.state as unknown;
const validState = isValidState(state) ? state : null;
const subId = validState?.subscriptionId;
const connectionPath = subId ? `/connection?sub=${subId}` : '/connection';
useEffect(() => {
if (!validState) {
navigate('/connection', { replace: true });
navigate(connectionPath, { replace: true });
}
}, [validState, navigate]);
}, [validState, navigate, connectionPath]);
if (!validState) {
return null;
@@ -38,7 +41,7 @@ export default function ConnectionQR() {
return (
<div className="animate-fade-in">
<div className="mb-6 flex items-center gap-3">
<AdminBackButton to="/connection" replace />
<AdminBackButton to={connectionPath} replace />
<h1 className="text-2xl font-bold text-dark-100">{t('subscription.connection.qrTitle')}</h1>
</div>

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router';
import { Link, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/auth';
import { useBlockingStore } from '../store/blocking';
@@ -18,6 +18,7 @@ import StatsGrid from '../components/dashboard/StatsGrid';
import { giftApi } from '../api/gift';
import { promoApi } from '../api/promo';
import PendingGiftCard from '../components/dashboard/PendingGiftCard';
import SubscriptionListCard from '../components/subscription/SubscriptionListCard';
import { API } from '../config/constants';
const ChevronRightIcon = () => (
@@ -28,6 +29,7 @@ const ChevronRightIcon = () => (
export default function Dashboard() {
const { t } = useTranslation();
const navigate = useNavigate();
const user = useAuthStore((state) => state.user);
const refreshUser = useAuthStore((state) => state.refreshUser);
const queryClient = useQueryClient();
@@ -49,26 +51,35 @@ export default function Dashboard() {
refetchOnMount: 'always',
});
// Multi-tariff: check if user has multiple subscriptions
const { data: multiSubData } = useQuery({
queryKey: ['subscriptions-list'],
queryFn: () => subscriptionApi.getSubscriptions(),
staleTime: 60_000,
});
const isMultiTariff = multiSubData?.multi_tariff_enabled ?? false;
const { data: subscriptionResponse, isLoading: subLoading } = useQuery({
queryKey: ['subscription'],
queryFn: subscriptionApi.getSubscription,
queryFn: () => subscriptionApi.getSubscription(),
retry: false,
staleTime: API.BALANCE_STALE_TIME_MS,
refetchOnMount: 'always',
enabled: !isMultiTariff,
});
const subscription = subscriptionResponse?.subscription ?? null;
const { data: trialInfo, isLoading: trialLoading } = useQuery({
queryKey: ['trial-info'],
queryFn: subscriptionApi.getTrialInfo,
queryFn: () => subscriptionApi.getTrialInfo(),
enabled: !subscription && !subLoading,
});
const { data: devicesData } = useQuery({
queryKey: ['devices'],
queryFn: subscriptionApi.getDevices,
enabled: !!subscription,
queryFn: () => subscriptionApi.getDevices(),
enabled: !!subscription && !isMultiTariff,
staleTime: API.BALANCE_STALE_TIME_MS,
});
@@ -99,10 +110,11 @@ export default function Dashboard() {
});
const activateTrialMutation = useMutation({
mutationFn: subscriptionApi.activateTrial,
mutationFn: () => subscriptionApi.activateTrial(),
onSuccess: () => {
setTrialError(null);
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
queryClient.invalidateQueries({ queryKey: ['trial-info'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
@@ -122,20 +134,23 @@ export default function Dashboard() {
} | null>(null);
const refreshTrafficMutation = useMutation({
mutationFn: subscriptionApi.refreshTraffic,
mutationFn: () => subscriptionApi.refreshTraffic(subscription?.id),
onSuccess: (data) => {
setTrafficData({
traffic_used_gb: data.traffic_used_gb,
traffic_used_percent: data.traffic_used_percent,
is_unlimited: data.is_unlimited,
});
localStorage.setItem('traffic_refresh_ts', Date.now().toString());
localStorage.setItem(
`traffic_refresh_ts_${subscription?.id ?? 'default'}`,
Date.now().toString(),
);
if (data.rate_limited && data.retry_after_seconds) {
setTrafficRefreshCooldown(data.retry_after_seconds);
} else {
setTrafficRefreshCooldown(30);
}
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['subscription', subscription?.id] });
},
onError: (error: {
response?: { status?: number; headers?: { get?: (key: string) => string } };
@@ -164,7 +179,7 @@ export default function Dashboard() {
if (hasAutoRefreshed.current) return;
hasAutoRefreshed.current = true;
const lastRefresh = localStorage.getItem('traffic_refresh_ts');
const lastRefresh = localStorage.getItem(`traffic_refresh_ts_${subscription?.id ?? 'default'}`);
const now = Date.now();
const cacheMs = API.TRAFFIC_CACHE_MS;
@@ -266,7 +281,44 @@ export default function Dashboard() {
{/* Pending Gift Activations */}
{pendingGifts && pendingGifts.length > 0 && <PendingGiftCard gifts={pendingGifts} />}
{/* Subscription Status Card */}
{/* Multi-tariff: show subscription cards (max 3) */}
{isMultiTariff && multiSubData?.subscriptions && (
<div className="space-y-3">
<div className="flex items-center justify-between px-1">
<span className="text-sm font-medium opacity-60">
{t('dashboard.subscriptions', 'Подписки')}
</span>
<Link to="/subscriptions" className="text-xs text-accent-400 hover:underline">
{t('dashboard.manageAll', 'Управление')}
</Link>
</div>
{multiSubData.subscriptions.slice(0, 3).map((sub) => (
<SubscriptionListCard
key={sub.id}
subscription={sub}
onClick={() => navigate(`/subscriptions/${sub.id}`)}
/>
))}
{multiSubData.subscriptions.length > 3 && (
<Link
to="/subscriptions"
className="flex w-full items-center justify-center rounded-2xl border border-dashed border-white/15 p-3 text-xs opacity-50 transition-opacity hover:opacity-80"
>
{t('dashboard.showAll', 'Показать все')} ({multiSubData.subscriptions.length})
</Link>
)}
<Link
to="/subscription/purchase"
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-500/15 p-3.5 text-sm font-medium text-accent-400 transition-all hover:bg-accent-500/25"
>
<span className="text-base">+</span> {t('subscriptions.buyAnother', 'Купить ещё тариф')}
</Link>
</div>
)}
{/* Subscription Status Card — hidden in multi-tariff (managed via /subscriptions) */}
{!isMultiTariff && (
<>
{subLoading ? (
<div className="bento-card">
<div className="mb-4 flex items-center justify-between">
@@ -297,6 +349,8 @@ export default function Dashboard() {
connectedDevices={devicesData?.total ?? 0}
/>
) : null}
</>
)}
{/* Trial Activation */}
{hasNoSubscription && !trialLoading && trialInfo?.is_available && (

View File

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

View File

@@ -55,8 +55,10 @@ function CodeOnlySuccessState({
const shortCode = purchaseToken.slice(0, 12);
const giftCode = `GIFT-${shortCode}`;
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME as string | undefined;
const botLink = botUsername ? `https://t.me/${botUsername}?start=GIFT_${shortCode}` : null;
const cabinetLink = `${window.location.origin}/gift?tab=activate&code=${shortCode}`;
// Encode underscores as %5F so Telegram auto-link detection doesn't strip them
const safeCode = shortCode.replace(/_/g, '%5F');
const botLink = botUsername ? `https://t.me/${botUsername}?start=GIFT%5F${safeCode}` : null;
const cabinetLink = `${window.location.origin}/gift?tab=activate&code=${safeCode}`;
const fullMessage = [
t('gift.shareText', 'I have a gift for you! Activate it here:'),

View File

@@ -1040,8 +1040,10 @@ function SentGiftCard({ gift }: { gift: SentGift }) {
const buildShareMessage = useCallback(() => {
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME as string | undefined;
const botLink = botUsername ? `https://t.me/${botUsername}?start=GIFT_${shortCode}` : null;
const cabinetLink = `${window.location.origin}/gift?tab=activate&code=${shortCode}`;
// Encode underscores as %5F so Telegram auto-link detection doesn't strip them
const safeCode = shortCode.replace(/_/g, '%5F');
const botLink = botUsername ? `https://t.me/${botUsername}?start=GIFT%5F${safeCode}` : null;
const cabinetLink = `${window.location.origin}/gift?tab=activate&code=${safeCode}`;
return [
t('gift.shareText'),
'',
@@ -1304,7 +1306,9 @@ export default function GiftSubscription() {
// URL params: ?tab=activate&code=TOKEN for auto-activation
const urlTab = searchParams.get('tab') as TabId | null;
const urlCode = searchParams.get('code');
const rawCode = searchParams.get('code');
// Strip GIFT- or GIFT_ prefix if user pasted the full display code
const urlCode = rawCode?.replace(/^GIFT[-_]/i, '') ?? rawCode;
const [activeTab, setActiveTab] = useState<TabId>(
urlTab === 'activate' || urlTab === 'myGifts' ? urlTab : 'buy',
);

View File

@@ -408,7 +408,7 @@ export default function NewsArticlePage() {
<img
src={article.featured_image_url}
alt={article.title}
className="h-auto w-full rounded-xl object-cover"
className="max-h-96 w-full rounded-xl object-cover"
loading="eager"
fetchPriority="high"
/>

View File

@@ -1,6 +1,7 @@
import { useState, useRef, useEffect } from 'react';
import { Link, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { usePlatform } from '@/platform';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { motion, AnimatePresence } from 'framer-motion';
import { useAuthStore } from '../store/auth';
@@ -147,7 +148,12 @@ export default function Profile() {
const registerEmailMutation = useMutation({
mutationFn: ({ email, password }: { email: string; password: string }) =>
authApi.registerEmail(email, password),
onSuccess: async () => {
onSuccess: async (response) => {
// Backend returns merge_required when email belongs to another user
if (response.merge_required && response.merge_token) {
navigate(`/merge/${response.merge_token}`, { replace: true });
return;
}
setSuccess(t('profile.emailSent'));
setError(null);
setEmail('');
@@ -250,14 +256,16 @@ export default function Profile() {
return () => clearInterval(timer);
}, [verificationResendCooldown]);
// Auto-focus inputs on step change
// Auto-focus inputs on step change (skip on Telegram — keyboard hides bottom nav)
const { platform: profilePlatform } = usePlatform();
useEffect(() => {
if (profilePlatform === 'telegram') return;
const timer = setTimeout(() => {
if (changeEmailStep === 'email') newEmailInputRef.current?.focus();
else if (changeEmailStep === 'code') codeInputRef.current?.focus();
}, 100);
return () => clearTimeout(timer);
}, [changeEmailStep]);
}, [changeEmailStep, profilePlatform]);
// Auto-close success after 3s
useEffect(() => {

View File

@@ -927,6 +927,7 @@ export default function QuickPurchase() {
contact_type: detectContactType(contactValue),
contact_value: contactValue.trim(),
payment_method: paymentMethod,
language: i18n.language,
is_gift: isGift,
};

View File

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

View File

@@ -1,8 +1,11 @@
import { useState, useEffect, useRef, useCallback, memo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { Navigate, useNavigate, useParams } from 'react-router';
import { subscriptionApi } from '../api/subscription';
import { WebBackButton } from '../components/WebBackButton';
import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog';
import { usePlatform } from '../platform';
import TrafficProgressBar from '../components/dashboard/TrafficProgressBar';
import { HoverBorderGradient } from '../components/ui/hover-border-gradient';
import { useTrafficZone } from '../hooks/useTrafficZone';
@@ -168,10 +171,16 @@ export default function Subscription() {
const queryClient = useQueryClient();
const { formatAmount, currencySymbol } = useCurrency();
const navigate = useNavigate();
const { subscriptionId: subIdParam } = useParams<{ subscriptionId?: string }>();
const subscriptionId = subIdParam ? parseInt(subIdParam, 10) : undefined;
const { isDark } = useTheme();
const g = getGlassColors(isDark);
const haptic = useHaptic();
const [copied, setCopied] = useState(false);
const [showDeleteSheet, setShowDeleteSheet] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const { platform } = usePlatform();
const destructiveConfirm = useDestructiveConfirm();
// Helper to format price from kopeks
const formatPrice = (kopeks: number) => `${formatAmount(kopeks / 100)} ${currencySymbol}`;
@@ -194,11 +203,19 @@ export default function Subscription() {
is_unlimited: boolean;
} | null>(null);
// Detect multi-tariff mode from cached subscriptions-list
const { data: multiSubData } = useQuery({
queryKey: ['subscriptions-list'],
queryFn: () => subscriptionApi.getSubscriptions(),
staleTime: 60_000,
});
const isMultiTariff = multiSubData?.multi_tariff_enabled ?? false;
const { data: subscriptionResponse, isLoading } = useQuery({
queryKey: ['subscription'],
queryFn: subscriptionApi.getSubscription,
queryKey: ['subscription', subscriptionId],
queryFn: () => subscriptionApi.getSubscription(subscriptionId),
retry: false,
staleTime: 0, // Always refetch to get latest data
staleTime: 0,
refetchOnMount: 'always',
});
@@ -211,8 +228,8 @@ export default function Subscription() {
// Purchase options (needed for balance_kopeks in device/traffic/server management)
const { data: purchaseOptions } = useQuery({
queryKey: ['purchase-options'],
queryFn: subscriptionApi.getPurchaseOptions,
queryKey: ['purchase-options', subscriptionId],
queryFn: () => subscriptionApi.getPurchaseOptions(subscriptionId),
staleTime: 0,
refetchOnMount: 'always',
});
@@ -220,40 +237,43 @@ export default function Subscription() {
const isTariffsMode = purchaseOptions?.sales_mode === 'tariffs';
const autopayMutation = useMutation({
mutationFn: (enabled: boolean) => subscriptionApi.updateAutopay(enabled),
mutationFn: (enabled: boolean) =>
subscriptionApi.updateAutopay(enabled, undefined, subscriptionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
},
});
// Devices query
const { data: devicesData, isLoading: devicesLoading } = useQuery({
queryKey: ['devices'],
queryFn: subscriptionApi.getDevices,
queryKey: ['devices', subscriptionId],
queryFn: () => subscriptionApi.getDevices(subscriptionId),
enabled: !!subscription,
});
// Delete device mutation
const deleteDeviceMutation = useMutation({
mutationFn: (hwid: string) => subscriptionApi.deleteDevice(hwid),
mutationFn: (hwid: string) => subscriptionApi.deleteDevice(hwid, subscriptionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['devices'] });
queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] });
},
});
// Delete all devices mutation
const deleteAllDevicesMutation = useMutation({
mutationFn: () => subscriptionApi.deleteAllDevices(),
mutationFn: () => subscriptionApi.deleteAllDevices(subscriptionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['devices'] });
queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] });
},
});
// Pause subscription mutation
const pauseMutation = useMutation({
mutationFn: () => subscriptionApi.togglePause(),
mutationFn: () => subscriptionApi.togglePause(subscriptionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
},
});
@@ -269,18 +289,20 @@ 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'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
setShowDeviceTopup(false);
setDevicesToAdd(1);
},
@@ -288,8 +310,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 +329,31 @@ 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'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['traffic-packages', subscriptionId] });
setShowTrafficTopup(false);
setSelectedTrafficPackage(null);
},
@@ -335,8 +361,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 +376,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 +433,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 +447,7 @@ export default function Subscription() {
}
refreshTrafficMutation.mutate();
}, [subscription, refreshTrafficMutation]);
}, [subscription, refreshTrafficMutation, subscriptionId]);
const copyUrl = () => {
if (subscription?.subscription_url) {
@@ -427,6 +457,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 +470,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 +799,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 +1273,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 +1537,7 @@ export default function Subscription() {
}
compact
onBeforeTopUp={async () => {
await subscriptionApi.saveDevicesCart(devicesToAdd);
await subscriptionApi.saveDevicesCart(devicesToAdd, subscriptionId);
}}
/>
)}
@@ -1721,7 +1877,10 @@ export default function Subscription() {
compact
className="mb-3"
onBeforeTopUp={async () => {
await subscriptionApi.saveTrafficCart(selectedTrafficPackage);
await subscriptionApi.saveTrafficCart(
selectedTrafficPackage,
subscriptionId,
);
}}
/>
)}

View File

@@ -1,10 +1,11 @@
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { useNavigate, useSearchParams } from 'react-router';
import { AxiosError } from 'axios';
import { subscriptionApi } from '../api/subscription';
import { promoApi } from '../api/promo';
import { WebBackButton } from '../components/WebBackButton';
import { getGlassColors } from '../utils/glassTheme';
import { useTheme } from '../hooks/useTheme';
import type {
@@ -28,6 +29,10 @@ export default function SubscriptionPurchase() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const subscriptionId = searchParams.get('subscriptionId')
? parseInt(searchParams.get('subscriptionId')!, 10)
: undefined;
const { formatAmount, currencySymbol } = useCurrency();
const { isDark } = useTheme();
const g = getGlassColors(isDark);
@@ -36,8 +41,8 @@ export default function SubscriptionPurchase() {
// Subscription query (shares cache with /subscription page)
const { data: subscriptionResponse, isLoading } = useQuery({
queryKey: ['subscription'],
queryFn: subscriptionApi.getSubscription,
queryKey: ['subscription', subscriptionId],
queryFn: () => subscriptionApi.getSubscription(subscriptionId),
retry: false,
staleTime: 0,
refetchOnMount: 'always',
@@ -51,8 +56,8 @@ export default function SubscriptionPurchase() {
isError: optionsError,
refetch: refetchOptions,
} = useQuery({
queryKey: ['purchase-options'],
queryFn: subscriptionApi.getPurchaseOptions,
queryKey: ['purchase-options', subscriptionId],
queryFn: () => subscriptionApi.getPurchaseOptions(subscriptionId),
staleTime: 0,
refetchOnMount: 'always',
});
@@ -70,6 +75,14 @@ export default function SubscriptionPurchase() {
const tariffs =
isTariffsMode && purchaseOptions && 'tariffs' in purchaseOptions ? purchaseOptions.tariffs : [];
// Multi-tariff: check via subscriptions list query
const { data: multiSubData } = useQuery({
queryKey: ['subscriptions-list'],
queryFn: () => subscriptionApi.getSubscriptions(),
staleTime: 60_000,
});
const isMultiTariff = multiSubData?.multi_tariff_enabled ?? false;
// Helper to apply promo discount
const applyPromoDiscount = (
priceKopeks: number,
@@ -217,36 +230,38 @@ 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] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
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 +278,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 +306,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 +386,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 +410,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 +719,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 +771,7 @@ export default function SubscriptionPurchase() {
'subscription_is_expired' in purchaseOptions &&
purchaseOptions.subscription_is_expired === true;
const canSwitch =
!isMultiTariff &&
subscription &&
subscription.tariff_id &&
!isCurrentTariff &&

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

@@ -0,0 +1,137 @@
import { useQuery } from '@tanstack/react-query';
import { Navigate, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { subscriptionApi } from '../api/subscription';
import { useTheme } from '../hooks/useTheme';
import { getGlassColors } from '../utils/glassTheme';
import SubscriptionListCard from '../components/subscription/SubscriptionListCard';
function EmptyState({ onBuy }: { onBuy: () => void }) {
const { t } = useTranslation();
const { isDark } = useTheme();
const g = getGlassColors(isDark);
return (
<div
className="rounded-2xl border p-10 text-center"
style={{ background: g.cardBg, borderColor: g.cardBorder }}
>
<div
className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl"
style={{ background: g.innerBg }}
>
<svg
className="h-8 w-8 opacity-40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V19.5a2.25 2.25 0 002.25 2.25h.75"
/>
</svg>
</div>
<h3 className="mb-2 text-xl font-semibold" style={{ color: g.text }}>
{t('subscriptions.empty', 'Нет подписок')}
</h3>
<p className="mb-6 text-sm" style={{ color: g.textSecondary }}>
{t('subscriptions.emptyDesc', 'У вас пока нет активных подписок')}
</p>
<button
onClick={onBuy}
className="rounded-xl bg-accent-500 px-8 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-600"
>
{t('subscriptions.buy', 'Купить подписку')}
</button>
</div>
);
}
export default function Subscriptions() {
const { t } = useTranslation();
const navigate = useNavigate();
const { isDark } = useTheme();
const g = getGlassColors(isDark);
const { data, isLoading } = useQuery({
queryKey: ['subscriptions-list'],
queryFn: () => subscriptionApi.getSubscriptions(),
staleTime: 30_000,
refetchOnMount: 'always',
});
const subscriptions = data?.subscriptions ?? [];
const isMultiTariff = data?.multi_tariff_enabled ?? false;
// Single-tariff mode with one subscription: skip list, go directly to detail
if (data && !isMultiTariff && subscriptions.length === 1) {
return <Navigate to={`/subscriptions/${subscriptions[0].id}`} replace />;
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold" style={{ color: g.text }}>
{t('subscriptions.title', 'Мои подписки')}
</h1>
{!isLoading && subscriptions.length > 0 && (
<button
onClick={() => navigate('/subscription/purchase')}
className="flex items-center gap-1.5 rounded-xl px-4 py-2 text-sm font-medium transition-colors"
style={{
background: 'rgba(var(--color-accent-400), 0.1)',
color: 'rgb(var(--color-accent-400))',
border: '1px solid rgba(var(--color-accent-400), 0.2)',
}}
>
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
{t('subscriptions.buyAnother', 'Новый тариф')}
</button>
)}
</div>
{/* Loading */}
{isLoading && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{[1, 2].map((i) => (
<div
key={i}
className="h-36 animate-pulse rounded-2xl"
style={{ background: g.innerBg }}
/>
))}
</div>
)}
{/* Empty state */}
{!isLoading && subscriptions.length === 0 && (
<EmptyState onBuy={() => navigate('/subscription/purchase')} />
)}
{/* Subscription grid */}
{subscriptions.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{subscriptions.map((sub) => (
<SubscriptionListCard
key={sub.id}
subscription={sub}
onClick={() => navigate(`/subscriptions/${sub.id}`)}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -88,7 +88,7 @@ export default function TopUpAmount() {
const queryClient = useQueryClient();
const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } =
useCurrency();
const { openInvoice, openTelegramLink, openLink } = usePlatform();
const { openInvoice, openTelegramLink, openLink, platform } = usePlatform();
const haptic = useHaptic();
const inputRef = useRef<HTMLInputElement>(null);
@@ -228,15 +228,16 @@ export default function TopUpAmount() {
},
});
// Auto-focus input
// Auto-focus input (only on desktop — mobile keyboard hides bottom nav)
useEffect(() => {
if (platform === 'telegram') return;
const timer = setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, 100);
return () => clearTimeout(timer);
}, []);
}, [platform]);
if (!method) {
return (
@@ -399,7 +400,6 @@ export default function TopUpAmount() {
placeholder="0"
className="h-14 w-full bg-transparent px-4 pr-12 text-xl font-bold text-dark-100 placeholder:text-dark-600 focus:outline-none"
autoComplete="off"
autoFocus
/>
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-base font-semibold text-dark-500">
{currencySymbol}

View File

@@ -305,7 +305,10 @@ export default function TopUpResult() {
clearTopUpPendingInfo();
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['transactions'] });
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({
predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription',
});
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
refreshUser();
} else if (resolvedFailed) {

View File

@@ -107,6 +107,7 @@ export default function Wheel() {
const [isPayingStars, setIsPayingStars] = useState(false);
const [historyExpanded, setHistoryExpanded] = useState(false);
const [showStarsConfirm, setShowStarsConfirm] = useState(false);
const [selectedSubscriptionId, setSelectedSubscriptionId] = useState<number | null>(null);
const paymentTypeInitialized = useRef(false);
const {
@@ -136,6 +137,11 @@ export default function Wheel() {
} else if (daysEnabled) {
setPaymentType('subscription_days');
}
// Auto-select subscription if only one eligible
if (config.eligible_subscriptions?.length === 1) {
setSelectedSubscriptionId(config.eligible_subscriptions[0].id);
}
}, [config]);
// Function to poll for new spin result after Stars payment
@@ -368,7 +374,7 @@ export default function Wheel() {
};
const spinMutation = useMutation({
mutationFn: () => wheelApi.spin(paymentType),
mutationFn: () => wheelApi.spin(paymentType, selectedSubscriptionId ?? undefined),
onSuccess: (result) => {
if (result.success) {
setTargetRotation(result.rotation_degrees);
@@ -506,11 +512,18 @@ export default function Wheel() {
// Stars via Telegram invoice don't require ruble balance, so only check daily limit
const dailyLimitReached = config.daily_limit > 0 && config.user_spins_today >= config.daily_limit;
const noSubscription = !config.has_subscription;
const needsSubscriptionPick =
paymentType === 'subscription_days' &&
config.eligible_subscriptions &&
config.eligible_subscriptions.length > 1 &&
!selectedSubscriptionId;
const spinDisabled =
isSpinning ||
isPayingStars ||
dailyLimitReached ||
noSubscription ||
needsSubscriptionPick ||
(paymentType === 'telegram_stars' ? !starsEnabled : !config.can_spin);
return (
@@ -582,6 +595,38 @@ export default function Wheel() {
</div>
)}
{/* Subscription selector for days payment in multi-tariff */}
{paymentType === 'subscription_days' &&
config.eligible_subscriptions &&
config.eligible_subscriptions.length > 1 && (
<div className="rounded-xl border border-dark-700/30 bg-dark-800/30 p-3">
<p className="mb-2 text-center text-xs text-dark-400">
{t('wheel.selectSubscription', 'Выберите подписку')}
</p>
<div className="space-y-1.5">
{config.eligible_subscriptions.map((sub) => (
<button
key={sub.id}
onClick={() => setSelectedSubscriptionId(sub.id)}
disabled={isSpinning}
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm transition-all ${
selectedSubscriptionId === sub.id
? 'bg-accent-500/15 text-accent-400'
: 'text-dark-400 hover:text-dark-200'
}`}
>
<span className="font-medium">
{sub.tariff_name || t('subscription.defaultName', 'Подписка')}
</span>
<span className="text-xs opacity-60">
{sub.days_left} {t('common.units.days', 'дней')}
</span>
</button>
))}
</div>
</div>
)}
{/* Stars confirmation panel */}
{showStarsConfirm && !isSpinning && !isPayingStars ? (
<div className="space-y-3 rounded-xl border border-accent-500/30 bg-accent-500/5 p-4">
@@ -645,6 +690,14 @@ export default function Wheel() {
<p className="text-dark-400">{t('wheel.errors.dailyLimitReached')}</p>
</div>
)}
{/* Subscription selection required hint */}
{!isSpinning && needsSubscriptionPick && (
<div className="rounded-linear border border-warning-500/30 bg-warning-500/5 p-4 text-center">
<p className="text-warning-400">
{t('wheel.errors.selectSubscription', 'Выберите подписку для списания дней')}
</p>
</div>
)}
{/* Inline Result Card */}
{spinResult && !isSpinning && (

View File

@@ -16,6 +16,7 @@ export interface WSMessage {
new_balance_rubles?: number;
description?: string;
// Subscription events
subscription_id?: number;
expires_at?: string;
new_expires_at?: string;
tariff_name?: string;

View File

@@ -1642,3 +1642,82 @@ input[type='checkbox']:hover:not(:checked) {
.light .sheet-handle {
@apply bg-champagne-400;
}
/* ── Admin Panel: Floating Orbs Background ── */
@keyframes adminOrbFloat {
0% {
transform: translate(0, 0) scale(1);
}
33% {
transform: translate(30px, -40px) scale(1.05);
}
66% {
transform: translate(-20px, 30px) scale(0.95);
}
100% {
transform: translate(10px, -20px) scale(1.02);
}
}
@keyframes adminPulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.4;
transform: scale(0.85);
}
}
@keyframes adminCardEnter {
from {
opacity: 0;
transform: translateY(20px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes adminItemEnter {
from {
opacity: 0;
transform: translateX(-8px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.admin-orb {
position: absolute;
border-radius: 50%;
filter: blur(120px);
pointer-events: none;
animation: adminOrbFloat 20s ease-in-out infinite alternate;
}
.light .admin-orb {
filter: blur(140px);
opacity: 0.15 !important;
}
/* Mobile: reduce orb count and blur for performance */
@media (max-width: 1023px) {
.admin-orb {
filter: blur(80px);
}
.admin-orb:nth-child(n + 4) {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.admin-orb {
animation: none;
}
}

View File

@@ -110,6 +110,31 @@ export interface SubscriptionStatusResponse {
subscription: Subscription | null;
}
// Multi-tariff subscription list item (from GET /cabinet/subscriptions)
export interface SubscriptionListItem {
id: number;
status: string;
tariff_id: number | null;
tariff_name: string | null;
traffic_limit_gb: number;
traffic_used_gb: number;
device_limit: number;
end_date: string | null;
subscription_url: string | null;
subscription_crypto_link: string | null;
is_trial: boolean;
is_daily?: boolean;
is_daily_paused?: boolean;
autopay_enabled: boolean;
connected_squads: string[] | null;
}
// Response from GET /cabinet/subscriptions (multi-tariff)
export interface SubscriptionsListResponse {
subscriptions: SubscriptionListItem[];
multi_tariff_enabled: boolean;
}
// Device types
export interface Device {
hwid: string;
@@ -320,6 +345,8 @@ export interface Tariff {
custom_days_discount_percent?: number;
// Traffic reset
traffic_reset_mode?: string;
// Multi-tariff: already purchased by user
is_purchased?: boolean;
}
export interface TariffsPurchaseOptions {
@@ -332,6 +359,8 @@ export interface TariffsPurchaseOptions {
subscription_status?: string;
subscription_is_expired?: boolean;
has_subscription?: boolean;
// Multi-tariff: all available tariffs already purchased
all_tariffs_purchased?: boolean;
}
export interface ClassicPurchaseOptions {

View File

@@ -1,3 +1,12 @@
export function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
export function formatPrice(kopeks: number): string {
const rubles = kopeks / 100;
return new Intl.NumberFormat('ru-RU', {

View File

@@ -282,5 +282,9 @@ export default {
},
},
},
plugins: [],
plugins: [
function ({ addVariant }) {
addVariant('light', '.light &');
},
],
};