mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
4602
package-lock.json
generated
4602
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -65,7 +65,6 @@
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-router": "^7.13.0",
|
||||
"react-twemoji": "^0.7.2",
|
||||
"reactjs-tiptap-editor": "^1.0.19",
|
||||
"recharts": "^3.7.0",
|
||||
"sigma": "^3.0.2",
|
||||
"simplex-noise": "^4.0.3",
|
||||
|
||||
249
src/App.tsx
249
src/App.tsx
@@ -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={
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ export interface PurchaseRequest {
|
||||
gift_recipient_type?: 'email' | 'telegram';
|
||||
gift_recipient_value?: string;
|
||||
gift_message?: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export interface PurchaseResponse {
|
||||
|
||||
@@ -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 ==============
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -114,6 +114,7 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
||||
queryClient.invalidateQueries({ queryKey: ['promo-offers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['active-discount'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['balance'] });
|
||||
setSuccessMessage(result.message);
|
||||
|
||||
@@ -190,7 +190,7 @@ export default function SuccessNotificationModal() {
|
||||
|
||||
const handleGoToSubscription = () => {
|
||||
hide();
|
||||
navigate('/subscription');
|
||||
navigate('/subscriptions');
|
||||
};
|
||||
|
||||
const handleGoToBalance = () => {
|
||||
|
||||
32
src/components/WebBackButton.tsx
Normal file
32
src/components/WebBackButton.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Link } from 'react-router';
|
||||
import { usePlatform } from '../platform';
|
||||
import { BackIcon } from './icons';
|
||||
|
||||
interface WebBackButtonProps {
|
||||
to: string;
|
||||
replace?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Back button visible only on web platform.
|
||||
* Hidden in Telegram Mini App — native back button handles navigation there.
|
||||
*/
|
||||
export function WebBackButton({ to, replace, className }: WebBackButtonProps) {
|
||||
const { platform } = usePlatform();
|
||||
|
||||
if (platform === 'telegram') return null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
replace={replace}
|
||||
className={
|
||||
className ||
|
||||
'flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600'
|
||||
}
|
||||
>
|
||||
<BackIcon />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -82,7 +82,11 @@ export default function WebSocketNotifications() {
|
||||
expiresAt: message.expires_at,
|
||||
tariffName: message.tariff_name,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription',
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['balance'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
||||
refreshUser();
|
||||
@@ -96,7 +100,11 @@ export default function WebSocketNotifications() {
|
||||
amountKopeks: message.amount_kopeks,
|
||||
expiresAt: message.new_expires_at,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription',
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['balance'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
||||
refreshUser();
|
||||
@@ -115,7 +123,7 @@ export default function WebSocketNotifications() {
|
||||
},
|
||||
),
|
||||
icon: <span className="text-lg">⏰</span>,
|
||||
onClick: () => navigate('/subscription'),
|
||||
onClick: () => navigate('/subscriptions'),
|
||||
duration: 10000,
|
||||
});
|
||||
return;
|
||||
@@ -130,10 +138,14 @@ export default function WebSocketNotifications() {
|
||||
'Your subscription has expired. Renew to continue using the service.',
|
||||
),
|
||||
icon: <span className="text-lg">😢</span>,
|
||||
onClick: () => navigate('/subscription'),
|
||||
onClick: () => navigate('/subscriptions'),
|
||||
duration: 10000,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription',
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||||
refreshUser();
|
||||
return;
|
||||
}
|
||||
@@ -171,7 +183,11 @@ export default function WebSocketNotifications() {
|
||||
icon: <span className="text-lg">🔄</span>,
|
||||
duration: 5000,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription',
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -183,7 +199,11 @@ export default function WebSocketNotifications() {
|
||||
devicesAdded: message.devices_added,
|
||||
newDeviceLimit: message.new_device_limit,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription',
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['balance'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['transactions'] });
|
||||
@@ -199,7 +219,11 @@ export default function WebSocketNotifications() {
|
||||
trafficGbAdded: message.traffic_gb_added,
|
||||
newTrafficLimitGb: message.new_traffic_limit_gb,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription',
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['balance'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['transactions'] });
|
||||
@@ -222,10 +246,14 @@ export default function WebSocketNotifications() {
|
||||
},
|
||||
),
|
||||
icon: <span className="text-lg">🔁</span>,
|
||||
onClick: () => navigate('/subscription'),
|
||||
onClick: () => navigate('/subscriptions'),
|
||||
duration: 8000,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription',
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['balance'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
||||
refreshUser();
|
||||
@@ -240,7 +268,7 @@ export default function WebSocketNotifications() {
|
||||
message.reason ||
|
||||
t('wsNotifications.autopay.failedMessage', 'Failed to auto-renew your subscription'),
|
||||
icon: <span className="text-lg">❌</span>,
|
||||
onClick: () => navigate('/subscription'),
|
||||
onClick: () => navigate('/subscriptions'),
|
||||
duration: 10000,
|
||||
});
|
||||
return;
|
||||
|
||||
@@ -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>
|
||||
|
||||
70
src/components/admin/QuickToggles.tsx
Normal file
70
src/components/admin/QuickToggles.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
179
src/components/admin/SettingsTableRow.tsx
Normal file
179
src/components/admin/SettingsTableRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
324
src/components/admin/SettingsTreeSidebar.tsx
Normal file
324
src/components/admin/SettingsTreeSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -214,7 +214,7 @@ export default function SubscriptionCardActive({
|
||||
haptic.notification('error');
|
||||
return;
|
||||
}
|
||||
navigate('/connection');
|
||||
navigate(`/connection?sub=${subscription.id}`);
|
||||
}}
|
||||
className={`mb-2.5 flex w-full items-center gap-3.5 rounded-[14px] p-3.5 text-left transition-shadow duration-300${isAtDeviceLimit ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
data-onboarding="connect-devices"
|
||||
@@ -312,7 +312,7 @@ export default function SubscriptionCardActive({
|
||||
<div className="mb-5 flex gap-2.5">
|
||||
{/* Tariff badge — clickable */}
|
||||
<Link
|
||||
to="/subscription"
|
||||
to={`/subscriptions/${subscription.id}`}
|
||||
className="flex-1 rounded-[14px] p-3.5 transition-all duration-500"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, rgba(${zone.mainVarRaw}, 0.07), rgba(${zone.mainVarRaw}, 0.02))`,
|
||||
@@ -396,7 +396,7 @@ export default function SubscriptionCardActive({
|
||||
{trafficRefreshCooldown > 0 ? `${trafficRefreshCooldown}s` : t('common.refresh')}
|
||||
</button>
|
||||
<Link
|
||||
to="/subscription"
|
||||
to={`/subscriptions/${subscription.id}`}
|
||||
className="text-[11px] font-medium text-dark-50/25 transition-colors hover:text-dark-50/40"
|
||||
>
|
||||
{t('dashboard.viewSubscription')} →
|
||||
|
||||
@@ -57,15 +57,18 @@ export default function SubscriptionCardExpired({
|
||||
try {
|
||||
if (isDisabledDaily) {
|
||||
// Resume daily subscription via toggle pause endpoint
|
||||
await subscriptionApi.togglePause();
|
||||
await subscriptionApi.togglePause(subscription.id);
|
||||
} else if (isDaily && subscription.tariff_id) {
|
||||
// Expired daily tariff — purchase for 1 day
|
||||
await subscriptionApi.purchaseTariff(subscription.tariff_id, 1);
|
||||
} else {
|
||||
await subscriptionApi.renewSubscription(30);
|
||||
await subscriptionApi.renewSubscription(30, subscription.id);
|
||||
}
|
||||
haptic.success();
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription',
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['balance'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
||||
} catch (err: unknown) {
|
||||
@@ -257,7 +260,7 @@ export default function SubscriptionCardExpired({
|
||||
<div className="flex gap-2.5">
|
||||
{isLimited ? (
|
||||
<Link
|
||||
to="/subscription"
|
||||
to={`/subscriptions/${subscription.id}`}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-[14px] py-3.5 text-[15px] font-semibold tracking-tight text-white transition-all duration-300"
|
||||
style={{
|
||||
background: accent.gradient,
|
||||
|
||||
@@ -160,7 +160,7 @@ export function AppHeader({
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
|
||||
{ path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon },
|
||||
{ path: '/subscriptions', label: t('nav.subscription'), icon: SubscriptionIcon },
|
||||
{ path: '/balance', label: t('nav.balance'), icon: WalletIcon },
|
||||
...(referralEnabled ? [{ path: '/referral', label: t('nav.referral'), icon: UsersIcon }] : []),
|
||||
{ path: '/support', label: t('nav.support'), icon: ChatIcon },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -79,7 +79,7 @@ export function DesktopSidebar({
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
|
||||
{ path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon },
|
||||
{ path: '/subscriptions', label: t('nav.subscription'), icon: SubscriptionIcon },
|
||||
{ path: '/balance', label: t('nav.balance'), icon: WalletIcon },
|
||||
...(referralEnabled ? [{ path: '/referral', label: t('nav.referral'), icon: UsersIcon }] : []),
|
||||
{ path: '/support', label: t('nav.support'), icon: ChatIcon },
|
||||
|
||||
@@ -29,7 +29,7 @@ export function MobileBottomNav({
|
||||
// When wheel is enabled, it replaces Support in the bottom nav (Support is still accessible via hamburger menu)
|
||||
const coreItems = [
|
||||
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
|
||||
{ path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon },
|
||||
{ path: '/subscriptions', label: t('nav.subscription'), icon: SubscriptionIcon },
|
||||
{ path: '/balance', label: t('nav.balance'), icon: WalletIcon },
|
||||
...(referralEnabled ? [{ path: '/referral', label: t('nav.referral'), icon: UsersIcon }] : []),
|
||||
...(wheelEnabled
|
||||
|
||||
@@ -78,7 +78,7 @@ export function CommandPalette({
|
||||
// Navigation items
|
||||
const navigationItems = [
|
||||
{ label: t('nav.dashboard'), icon: HomeIcon, path: '/' },
|
||||
{ label: t('nav.subscription'), icon: SubscriptionIcon, path: '/subscription' },
|
||||
{ label: t('nav.subscription'), icon: SubscriptionIcon, path: '/subscriptions' },
|
||||
{ label: t('nav.balance'), icon: WalletIcon, path: '/balance' },
|
||||
...(referralEnabled ? [{ label: t('nav.referral'), icon: UsersIcon, path: '/referral' }] : []),
|
||||
{ label: t('nav.support'), icon: ChatIcon, path: '/support' },
|
||||
@@ -100,7 +100,7 @@ export function CommandPalette({
|
||||
{
|
||||
label: t('subscription.get_config') || 'Get VPN config',
|
||||
icon: DownloadIcon,
|
||||
action: () => navigate('/subscription'),
|
||||
action: () => navigate('/subscriptions'),
|
||||
},
|
||||
{
|
||||
label: isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode',
|
||||
|
||||
@@ -369,7 +369,7 @@ export default function NewsSection() {
|
||||
gcTime: 10 * 60_000,
|
||||
});
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const items = useMemo(() => data?.items ?? [], [data?.items]);
|
||||
const total = data?.total ?? 0;
|
||||
const categories = data?.categories ?? [];
|
||||
|
||||
|
||||
@@ -5,13 +5,22 @@ import type { Subscription } from '../../types';
|
||||
|
||||
interface PurchaseCTAButtonProps {
|
||||
subscription: Subscription | null;
|
||||
/** In multi-tariff mode, link to /subscriptions/:id/renew instead of /subscription/purchase */
|
||||
isMultiTariff?: boolean;
|
||||
}
|
||||
|
||||
export default function PurchaseCTAButton({ subscription }: PurchaseCTAButtonProps) {
|
||||
export default function PurchaseCTAButton({
|
||||
subscription,
|
||||
isMultiTariff = false,
|
||||
}: PurchaseCTAButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isExpired = !subscription || (!subscription.is_active && !subscription.is_trial);
|
||||
const isTrial = subscription?.is_trial;
|
||||
const isDaily = subscription?.is_daily;
|
||||
|
||||
// Daily tariffs renew automatically — no manual renewal button needed in multi-tariff
|
||||
if (isMultiTariff && isDaily && !isExpired) return null;
|
||||
|
||||
const accentColor = isExpired ? '#FF3B5C' : 'rgb(var(--color-accent-400))';
|
||||
|
||||
@@ -25,10 +34,21 @@ export default function PurchaseCTAButton({ subscription }: PurchaseCTAButtonPro
|
||||
? t('subscription.cta.expiredHint')
|
||||
: isTrial
|
||||
? t('subscription.cta.trialHint')
|
||||
: isMultiTariff
|
||||
? t('subscription.cta.renewHint', 'Продление подписки')
|
||||
: t('subscription.cta.activeHint');
|
||||
|
||||
// Trial → purchase page (buy a real tariff, trial can't be renewed)
|
||||
// Multi-tariff active → per-subscription renew page
|
||||
// Otherwise → purchase page
|
||||
const linkTo = isTrial
|
||||
? '/subscription/purchase'
|
||||
: isMultiTariff && subscription?.id
|
||||
? `/subscriptions/${subscription.id}/renew`
|
||||
: '/subscription/purchase';
|
||||
|
||||
return (
|
||||
<Link to="/subscription/purchase" className="block">
|
||||
<Link to={linkTo} className="block">
|
||||
<HoverBorderGradient
|
||||
accentColor={accentColor}
|
||||
duration={4}
|
||||
|
||||
225
src/components/subscription/SubscriptionListCard.tsx
Normal file
225
src/components/subscription/SubscriptionListCard.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
import { getGlassColors } from '../../utils/glassTheme';
|
||||
import { useHaptic } from '../../platform';
|
||||
import type { SubscriptionListItem } from '../../types';
|
||||
|
||||
function formatDate(iso: string | null, locale?: string): string {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString(locale ?? undefined, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
function StatusBadge({
|
||||
status,
|
||||
isTrial,
|
||||
t,
|
||||
}: {
|
||||
status: string;
|
||||
isTrial: boolean;
|
||||
t: (key: string, fallback: string) => string;
|
||||
}) {
|
||||
const isActive = status === 'active' || status === 'trial';
|
||||
const isLimited = status === 'limited';
|
||||
const isExpired = status === 'expired' || status === 'disabled';
|
||||
|
||||
if (isTrial) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-amber-400/25 bg-amber-400/10 px-2 py-0.5 text-[10px] font-semibold text-amber-400">
|
||||
<svg className="h-2.5 w-2.5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z" />
|
||||
</svg>
|
||||
{t('subscription.statusTrial', 'Тестовая')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const color = isActive
|
||||
? 'bg-emerald-400/15 text-emerald-400 border-emerald-400/20'
|
||||
: isLimited
|
||||
? 'bg-amber-400/15 text-amber-400 border-amber-400/20'
|
||||
: 'bg-red-400/15 text-red-400 border-red-400/20';
|
||||
|
||||
const label = isActive
|
||||
? t('subscription.statusActive', 'Активна')
|
||||
: isLimited
|
||||
? t('subscription.statusLimited', 'Ограничена')
|
||||
: isExpired
|
||||
? t('subscription.statusExpired', 'Истекла')
|
||||
: status;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold ${color}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SubscriptionListCard({
|
||||
subscription,
|
||||
onClick,
|
||||
}: {
|
||||
subscription: SubscriptionListItem;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { isDark } = useTheme();
|
||||
const g = getGlassColors(isDark);
|
||||
const { impact } = useHaptic();
|
||||
|
||||
const handleClick = () => {
|
||||
impact('light');
|
||||
onClick();
|
||||
};
|
||||
|
||||
const isTrial = subscription.is_trial;
|
||||
const isActive = subscription.status === 'active' || subscription.status === 'trial';
|
||||
const isExpired = subscription.status === 'expired' || subscription.status === 'disabled';
|
||||
const trafficLimit = subscription.traffic_limit_gb;
|
||||
const trafficUsed = subscription.traffic_used_gb;
|
||||
const isUnlimited = trafficLimit === 0;
|
||||
const trafficPercent = isUnlimited
|
||||
? 0
|
||||
: trafficLimit > 0
|
||||
? Math.min(100, (trafficUsed / trafficLimit) * 100)
|
||||
: 0;
|
||||
const trafficColor =
|
||||
trafficPercent >= 90 ? 'bg-red-400' : trafficPercent >= 70 ? 'bg-amber-400' : 'bg-emerald-400';
|
||||
|
||||
const borderColor = isTrial
|
||||
? 'rgba(251,191,36,0.2)'
|
||||
: isExpired
|
||||
? 'rgba(255,59,92,0.15)'
|
||||
: g.cardBorder;
|
||||
|
||||
const bgColor = isTrial
|
||||
? isDark
|
||||
? 'rgba(251,191,36,0.04)'
|
||||
: 'rgba(251,191,36,0.03)'
|
||||
: isExpired
|
||||
? isDark
|
||||
? 'rgba(255,59,92,0.04)'
|
||||
: 'rgba(255,59,92,0.03)'
|
||||
: g.cardBg;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="w-full rounded-2xl border p-4 text-left transition-all duration-200 hover:scale-[1.01] active:scale-[0.99]"
|
||||
style={{ background: bgColor, borderColor }}
|
||||
>
|
||||
{/* Header: tariff name + status badge + chevron */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-base font-semibold" style={{ color: g.text }}>
|
||||
{subscription.tariff_name || t('subscription.defaultName', 'Подписка')}
|
||||
</span>
|
||||
<StatusBadge status={subscription.status} isTrial={isTrial} t={t} />
|
||||
</div>
|
||||
<svg
|
||||
className="h-4 w-4 shrink-0 opacity-30"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.5}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Traffic mini progress bar */}
|
||||
{isActive && (
|
||||
<div className="mt-3">
|
||||
<div className="mb-1 flex items-baseline justify-between">
|
||||
<span className="text-[11px] font-medium" style={{ color: g.textSecondary }}>
|
||||
{t('subscription.traffic', 'Трафик')}
|
||||
</span>
|
||||
<span className="text-[11px] tabular-nums" style={{ color: g.textSecondary }}>
|
||||
{isUnlimited
|
||||
? '∞'
|
||||
: `${trafficUsed.toFixed(1)} / ${trafficLimit} ${t('common.units.gb', 'ГБ')}`}
|
||||
</span>
|
||||
</div>
|
||||
{!isUnlimited && (
|
||||
<div className="h-1.5 overflow-hidden rounded-full" style={{ background: g.innerBg }}>
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${trafficColor}`}
|
||||
style={{ width: `${Math.max(1, trafficPercent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats row */}
|
||||
<div
|
||||
className="mt-2.5 flex items-center gap-4 text-[12px]"
|
||||
style={{ color: g.textSecondary }}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
<svg
|
||||
className="h-3.5 w-3.5 opacity-50"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<rect x="5" y="2" width="14" height="20" rx="2" />
|
||||
<path d="M12 18h.01" />
|
||||
</svg>
|
||||
{subscription.device_limit}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<svg
|
||||
className="h-3.5 w-3.5 opacity-50"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||
<path d="M16 2v4M8 2v4M3 10h18" />
|
||||
</svg>
|
||||
{formatDate(subscription.end_date, i18n.language)}
|
||||
</span>
|
||||
{!isTrial &&
|
||||
(() => {
|
||||
const isDaily = subscription.is_daily;
|
||||
const enabled = isDaily ? !subscription.is_daily_paused : subscription.autopay_enabled;
|
||||
const label = isDaily
|
||||
? t('subscription.dailyAutoCharge', 'Автосписание')
|
||||
: t('subscription.autopay', 'Автопродление');
|
||||
return (
|
||||
<span
|
||||
className={`flex items-center gap-1 ${enabled ? 'text-emerald-400/70' : 'text-red-400/50'}`}
|
||||
>
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.5}
|
||||
>
|
||||
{enabled ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
)}
|
||||
</svg>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -46,6 +46,8 @@ i18n
|
||||
react: {
|
||||
useSuspense: false,
|
||||
},
|
||||
|
||||
showSupportNotice: false,
|
||||
});
|
||||
|
||||
// Load detected language + fallback on startup
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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": "غیرفعال",
|
||||
|
||||
@@ -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": "Пользователи не найдены"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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": "禁用",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 || []}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { openLink as sdkOpenLink } from '@telegram-apps/sdk-react';
|
||||
@@ -14,6 +14,8 @@ import InstallationGuide from '../components/connection/InstallationGuide';
|
||||
export default function Connection() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const subId = searchParams.get('sub') ? Number(searchParams.get('sub')) : undefined;
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const isAdmin = useAuthStore((state) => state.isAdmin);
|
||||
const { isTelegramWebApp } = useTelegramSDK();
|
||||
@@ -27,8 +29,8 @@ export default function Connection() {
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery<AppConfig>({
|
||||
queryKey: ['appConfig'],
|
||||
queryFn: () => subscriptionApi.getAppConfig(),
|
||||
queryKey: ['appConfig', subId],
|
||||
queryFn: () => subscriptionApi.getAppConfig(subId),
|
||||
});
|
||||
|
||||
const handleGoBack = useCallback(() => {
|
||||
@@ -41,9 +43,10 @@ export default function Connection() {
|
||||
state: {
|
||||
url: appConfig?.subscriptionUrl,
|
||||
hideLink: appConfig?.hideLink ?? false,
|
||||
subscriptionId: subId,
|
||||
},
|
||||
});
|
||||
}, [navigate, appConfig?.subscriptionUrl, appConfig?.hideLink, isTelegramWebApp]);
|
||||
}, [navigate, appConfig?.subscriptionUrl, appConfig?.hideLink, isTelegramWebApp, subId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AdminBackButton } from '@/components/admin';
|
||||
interface ConnectionQRState {
|
||||
url: string;
|
||||
hideLink: boolean;
|
||||
subscriptionId?: number;
|
||||
}
|
||||
|
||||
function isValidState(state: unknown): state is ConnectionQRState {
|
||||
@@ -24,12 +25,14 @@ export default function ConnectionQR() {
|
||||
|
||||
const state = location.state as unknown;
|
||||
const validState = isValidState(state) ? state : null;
|
||||
const subId = validState?.subscriptionId;
|
||||
const connectionPath = subId ? `/connection?sub=${subId}` : '/connection';
|
||||
|
||||
useEffect(() => {
|
||||
if (!validState) {
|
||||
navigate('/connection', { replace: true });
|
||||
navigate(connectionPath, { replace: true });
|
||||
}
|
||||
}, [validState, navigate]);
|
||||
}, [validState, navigate, connectionPath]);
|
||||
|
||||
if (!validState) {
|
||||
return null;
|
||||
@@ -38,7 +41,7 @@ export default function ConnectionQR() {
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<AdminBackButton to="/connection" replace />
|
||||
<AdminBackButton to={connectionPath} replace />
|
||||
<h1 className="text-2xl font-bold text-dark-100">{t('subscription.connection.qrTitle')}</h1>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { useBlockingStore } from '../store/blocking';
|
||||
@@ -18,6 +18,7 @@ import StatsGrid from '../components/dashboard/StatsGrid';
|
||||
import { giftApi } from '../api/gift';
|
||||
import { promoApi } from '../api/promo';
|
||||
import PendingGiftCard from '../components/dashboard/PendingGiftCard';
|
||||
import SubscriptionListCard from '../components/subscription/SubscriptionListCard';
|
||||
import { API } from '../config/constants';
|
||||
|
||||
const ChevronRightIcon = () => (
|
||||
@@ -28,6 +29,7 @@ const ChevronRightIcon = () => (
|
||||
|
||||
export default function Dashboard() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const refreshUser = useAuthStore((state) => state.refreshUser);
|
||||
const queryClient = useQueryClient();
|
||||
@@ -49,26 +51,35 @@ export default function Dashboard() {
|
||||
refetchOnMount: 'always',
|
||||
});
|
||||
|
||||
// Multi-tariff: check if user has multiple subscriptions
|
||||
const { data: multiSubData } = useQuery({
|
||||
queryKey: ['subscriptions-list'],
|
||||
queryFn: () => subscriptionApi.getSubscriptions(),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const isMultiTariff = multiSubData?.multi_tariff_enabled ?? false;
|
||||
|
||||
const { data: subscriptionResponse, isLoading: subLoading } = useQuery({
|
||||
queryKey: ['subscription'],
|
||||
queryFn: subscriptionApi.getSubscription,
|
||||
queryFn: () => subscriptionApi.getSubscription(),
|
||||
retry: false,
|
||||
staleTime: API.BALANCE_STALE_TIME_MS,
|
||||
refetchOnMount: 'always',
|
||||
enabled: !isMultiTariff,
|
||||
});
|
||||
|
||||
const subscription = subscriptionResponse?.subscription ?? null;
|
||||
|
||||
const { data: trialInfo, isLoading: trialLoading } = useQuery({
|
||||
queryKey: ['trial-info'],
|
||||
queryFn: subscriptionApi.getTrialInfo,
|
||||
queryFn: () => subscriptionApi.getTrialInfo(),
|
||||
enabled: !subscription && !subLoading,
|
||||
});
|
||||
|
||||
const { data: devicesData } = useQuery({
|
||||
queryKey: ['devices'],
|
||||
queryFn: subscriptionApi.getDevices,
|
||||
enabled: !!subscription,
|
||||
queryFn: () => subscriptionApi.getDevices(),
|
||||
enabled: !!subscription && !isMultiTariff,
|
||||
staleTime: API.BALANCE_STALE_TIME_MS,
|
||||
});
|
||||
|
||||
@@ -99,10 +110,11 @@ export default function Dashboard() {
|
||||
});
|
||||
|
||||
const activateTrialMutation = useMutation({
|
||||
mutationFn: subscriptionApi.activateTrial,
|
||||
mutationFn: () => subscriptionApi.activateTrial(),
|
||||
onSuccess: () => {
|
||||
setTrialError(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['trial-info'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['balance'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
||||
@@ -122,20 +134,23 @@ export default function Dashboard() {
|
||||
} | null>(null);
|
||||
|
||||
const refreshTrafficMutation = useMutation({
|
||||
mutationFn: subscriptionApi.refreshTraffic,
|
||||
mutationFn: () => subscriptionApi.refreshTraffic(subscription?.id),
|
||||
onSuccess: (data) => {
|
||||
setTrafficData({
|
||||
traffic_used_gb: data.traffic_used_gb,
|
||||
traffic_used_percent: data.traffic_used_percent,
|
||||
is_unlimited: data.is_unlimited,
|
||||
});
|
||||
localStorage.setItem('traffic_refresh_ts', Date.now().toString());
|
||||
localStorage.setItem(
|
||||
`traffic_refresh_ts_${subscription?.id ?? 'default'}`,
|
||||
Date.now().toString(),
|
||||
);
|
||||
if (data.rate_limited && data.retry_after_seconds) {
|
||||
setTrafficRefreshCooldown(data.retry_after_seconds);
|
||||
} else {
|
||||
setTrafficRefreshCooldown(30);
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription', subscription?.id] });
|
||||
},
|
||||
onError: (error: {
|
||||
response?: { status?: number; headers?: { get?: (key: string) => string } };
|
||||
@@ -164,7 +179,7 @@ export default function Dashboard() {
|
||||
if (hasAutoRefreshed.current) return;
|
||||
hasAutoRefreshed.current = true;
|
||||
|
||||
const lastRefresh = localStorage.getItem('traffic_refresh_ts');
|
||||
const lastRefresh = localStorage.getItem(`traffic_refresh_ts_${subscription?.id ?? 'default'}`);
|
||||
const now = Date.now();
|
||||
const cacheMs = API.TRAFFIC_CACHE_MS;
|
||||
|
||||
@@ -266,7 +281,44 @@ export default function Dashboard() {
|
||||
{/* Pending Gift Activations */}
|
||||
{pendingGifts && pendingGifts.length > 0 && <PendingGiftCard gifts={pendingGifts} />}
|
||||
|
||||
{/* Subscription Status Card */}
|
||||
{/* Multi-tariff: show subscription cards (max 3) */}
|
||||
{isMultiTariff && multiSubData?.subscriptions && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<span className="text-sm font-medium opacity-60">
|
||||
{t('dashboard.subscriptions', 'Подписки')}
|
||||
</span>
|
||||
<Link to="/subscriptions" className="text-xs text-accent-400 hover:underline">
|
||||
{t('dashboard.manageAll', 'Управление')} →
|
||||
</Link>
|
||||
</div>
|
||||
{multiSubData.subscriptions.slice(0, 3).map((sub) => (
|
||||
<SubscriptionListCard
|
||||
key={sub.id}
|
||||
subscription={sub}
|
||||
onClick={() => navigate(`/subscriptions/${sub.id}`)}
|
||||
/>
|
||||
))}
|
||||
{multiSubData.subscriptions.length > 3 && (
|
||||
<Link
|
||||
to="/subscriptions"
|
||||
className="flex w-full items-center justify-center rounded-2xl border border-dashed border-white/15 p-3 text-xs opacity-50 transition-opacity hover:opacity-80"
|
||||
>
|
||||
{t('dashboard.showAll', 'Показать все')} ({multiSubData.subscriptions.length})
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
to="/subscription/purchase"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-500/15 p-3.5 text-sm font-medium text-accent-400 transition-all hover:bg-accent-500/25"
|
||||
>
|
||||
<span className="text-base">+</span> {t('subscriptions.buyAnother', 'Купить ещё тариф')}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subscription Status Card — hidden in multi-tariff (managed via /subscriptions) */}
|
||||
{!isMultiTariff && (
|
||||
<>
|
||||
{subLoading ? (
|
||||
<div className="bento-card">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
@@ -297,6 +349,8 @@ export default function Dashboard() {
|
||||
connectedDevices={devicesData?.total ?? 0}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Trial Activation */}
|
||||
{hasNoSubscription && !trialLoading && trialInfo?.is_available && (
|
||||
|
||||
@@ -311,7 +311,7 @@ export default function DeepLinkRedirect() {
|
||||
|
||||
{/* Back to cabinet */}
|
||||
<button
|
||||
onClick={() => navigate('/subscription')}
|
||||
onClick={() => navigate('/subscriptions')}
|
||||
className="w-full py-2 text-sm text-dark-500 transition-colors hover:text-dark-300"
|
||||
>
|
||||
{t('deepLink.backToCabinet')}
|
||||
@@ -353,7 +353,7 @@ export default function DeepLinkRedirect() {
|
||||
</div>
|
||||
<p className="mb-2 font-medium text-dark-200">{t('deepLink.errorTitle')}</p>
|
||||
<p className="mb-6 text-sm text-dark-400">{t('deepLink.errorDesc')}</p>
|
||||
<button onClick={() => navigate('/subscription')} className="btn-primary w-full">
|
||||
<button onClick={() => navigate('/subscriptions')} className="btn-primary w-full">
|
||||
{t('deepLink.goToSubscription')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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:'),
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
234
src/pages/RenewSubscription.tsx
Normal file
234
src/pages/RenewSubscription.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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
137
src/pages/Subscriptions.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Navigate, useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { subscriptionApi } from '../api/subscription';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
import { getGlassColors } from '../utils/glassTheme';
|
||||
import SubscriptionListCard from '../components/subscription/SubscriptionListCard';
|
||||
|
||||
function EmptyState({ onBuy }: { onBuy: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const { isDark } = useTheme();
|
||||
const g = getGlassColors(isDark);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-2xl border p-10 text-center"
|
||||
style={{ background: g.cardBg, borderColor: g.cardBorder }}
|
||||
>
|
||||
<div
|
||||
className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl"
|
||||
style={{ background: g.innerBg }}
|
||||
>
|
||||
<svg
|
||||
className="h-8 w-8 opacity-40"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V19.5a2.25 2.25 0 002.25 2.25h.75"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="mb-2 text-xl font-semibold" style={{ color: g.text }}>
|
||||
{t('subscriptions.empty', 'Нет подписок')}
|
||||
</h3>
|
||||
<p className="mb-6 text-sm" style={{ color: g.textSecondary }}>
|
||||
{t('subscriptions.emptyDesc', 'У вас пока нет активных подписок')}
|
||||
</p>
|
||||
<button
|
||||
onClick={onBuy}
|
||||
className="rounded-xl bg-accent-500 px-8 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-600"
|
||||
>
|
||||
{t('subscriptions.buy', 'Купить подписку')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Subscriptions() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { isDark } = useTheme();
|
||||
const g = getGlassColors(isDark);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['subscriptions-list'],
|
||||
queryFn: () => subscriptionApi.getSubscriptions(),
|
||||
staleTime: 30_000,
|
||||
refetchOnMount: 'always',
|
||||
});
|
||||
|
||||
const subscriptions = data?.subscriptions ?? [];
|
||||
const isMultiTariff = data?.multi_tariff_enabled ?? false;
|
||||
|
||||
// Single-tariff mode with one subscription: skip list, go directly to detail
|
||||
if (data && !isMultiTariff && subscriptions.length === 1) {
|
||||
return <Navigate to={`/subscriptions/${subscriptions[0].id}`} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold" style={{ color: g.text }}>
|
||||
{t('subscriptions.title', 'Мои подписки')}
|
||||
</h1>
|
||||
{!isLoading && subscriptions.length > 0 && (
|
||||
<button
|
||||
onClick={() => navigate('/subscription/purchase')}
|
||||
className="flex items-center gap-1.5 rounded-xl px-4 py-2 text-sm font-medium transition-colors"
|
||||
style={{
|
||||
background: 'rgba(var(--color-accent-400), 0.1)',
|
||||
color: 'rgb(var(--color-accent-400))',
|
||||
border: '1px solid rgba(var(--color-accent-400), 0.2)',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.5}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
{t('subscriptions.buyAnother', 'Новый тариф')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{[1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-36 animate-pulse rounded-2xl"
|
||||
style={{ background: g.innerBg }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && subscriptions.length === 0 && (
|
||||
<EmptyState onBuy={() => navigate('/subscription/purchase')} />
|
||||
)}
|
||||
|
||||
{/* Subscription grid */}
|
||||
{subscriptions.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{subscriptions.map((sub) => (
|
||||
<SubscriptionListCard
|
||||
key={sub.id}
|
||||
subscription={sub}
|
||||
onClick={() => navigate(`/subscriptions/${sub.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -305,7 +305,10 @@ export default function TopUpResult() {
|
||||
clearTopUpPendingInfo();
|
||||
queryClient.invalidateQueries({ queryKey: ['balance'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['transactions'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription',
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
||||
refreshUser();
|
||||
} else if (resolvedFailed) {
|
||||
|
||||
@@ -107,6 +107,7 @@ export default function Wheel() {
|
||||
const [isPayingStars, setIsPayingStars] = useState(false);
|
||||
const [historyExpanded, setHistoryExpanded] = useState(false);
|
||||
const [showStarsConfirm, setShowStarsConfirm] = useState(false);
|
||||
const [selectedSubscriptionId, setSelectedSubscriptionId] = useState<number | null>(null);
|
||||
const paymentTypeInitialized = useRef(false);
|
||||
|
||||
const {
|
||||
@@ -136,6 +137,11 @@ export default function Wheel() {
|
||||
} else if (daysEnabled) {
|
||||
setPaymentType('subscription_days');
|
||||
}
|
||||
|
||||
// Auto-select subscription if only one eligible
|
||||
if (config.eligible_subscriptions?.length === 1) {
|
||||
setSelectedSubscriptionId(config.eligible_subscriptions[0].id);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
// Function to poll for new spin result after Stars payment
|
||||
@@ -368,7 +374,7 @@ export default function Wheel() {
|
||||
};
|
||||
|
||||
const spinMutation = useMutation({
|
||||
mutationFn: () => wheelApi.spin(paymentType),
|
||||
mutationFn: () => wheelApi.spin(paymentType, selectedSubscriptionId ?? undefined),
|
||||
onSuccess: (result) => {
|
||||
if (result.success) {
|
||||
setTargetRotation(result.rotation_degrees);
|
||||
@@ -506,11 +512,18 @@ export default function Wheel() {
|
||||
// Stars via Telegram invoice don't require ruble balance, so only check daily limit
|
||||
const dailyLimitReached = config.daily_limit > 0 && config.user_spins_today >= config.daily_limit;
|
||||
const noSubscription = !config.has_subscription;
|
||||
const needsSubscriptionPick =
|
||||
paymentType === 'subscription_days' &&
|
||||
config.eligible_subscriptions &&
|
||||
config.eligible_subscriptions.length > 1 &&
|
||||
!selectedSubscriptionId;
|
||||
|
||||
const spinDisabled =
|
||||
isSpinning ||
|
||||
isPayingStars ||
|
||||
dailyLimitReached ||
|
||||
noSubscription ||
|
||||
needsSubscriptionPick ||
|
||||
(paymentType === 'telegram_stars' ? !starsEnabled : !config.can_spin);
|
||||
|
||||
return (
|
||||
@@ -582,6 +595,38 @@ export default function Wheel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subscription selector for days payment in multi-tariff */}
|
||||
{paymentType === 'subscription_days' &&
|
||||
config.eligible_subscriptions &&
|
||||
config.eligible_subscriptions.length > 1 && (
|
||||
<div className="rounded-xl border border-dark-700/30 bg-dark-800/30 p-3">
|
||||
<p className="mb-2 text-center text-xs text-dark-400">
|
||||
{t('wheel.selectSubscription', 'Выберите подписку')}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{config.eligible_subscriptions.map((sub) => (
|
||||
<button
|
||||
key={sub.id}
|
||||
onClick={() => setSelectedSubscriptionId(sub.id)}
|
||||
disabled={isSpinning}
|
||||
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm transition-all ${
|
||||
selectedSubscriptionId === sub.id
|
||||
? 'bg-accent-500/15 text-accent-400'
|
||||
: 'text-dark-400 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{sub.tariff_name || t('subscription.defaultName', 'Подписка')}
|
||||
</span>
|
||||
<span className="text-xs opacity-60">
|
||||
{sub.days_left} {t('common.units.days', 'дней')}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stars confirmation panel */}
|
||||
{showStarsConfirm && !isSpinning && !isPayingStars ? (
|
||||
<div className="space-y-3 rounded-xl border border-accent-500/30 bg-accent-500/5 p-4">
|
||||
@@ -645,6 +690,14 @@ export default function Wheel() {
|
||||
<p className="text-dark-400">{t('wheel.errors.dailyLimitReached')}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Subscription selection required hint */}
|
||||
{!isSpinning && needsSubscriptionPick && (
|
||||
<div className="rounded-linear border border-warning-500/30 bg-warning-500/5 p-4 text-center">
|
||||
<p className="text-warning-400">
|
||||
{t('wheel.errors.selectSubscription', 'Выберите подписку для списания дней')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline Result Card */}
|
||||
{spinResult && !isSpinning && (
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface WSMessage {
|
||||
new_balance_rubles?: number;
|
||||
description?: string;
|
||||
// Subscription events
|
||||
subscription_id?: number;
|
||||
expires_at?: string;
|
||||
new_expires_at?: string;
|
||||
tariff_name?: string;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -282,5 +282,9 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
plugins: [
|
||||
function ({ addVariant }) {
|
||||
addVariant('light', '.light &');
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user