mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 10:27:49 +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-i18next": "^16.5.4",
|
||||||
"react-router": "^7.13.0",
|
"react-router": "^7.13.0",
|
||||||
"react-twemoji": "^0.7.2",
|
"react-twemoji": "^0.7.2",
|
||||||
"reactjs-tiptap-editor": "^1.0.19",
|
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
"sigma": "^3.0.2",
|
"sigma": "^3.0.2",
|
||||||
"simplex-noise": "^4.0.3",
|
"simplex-noise": "^4.0.3",
|
||||||
|
|||||||
249
src/App.tsx
249
src/App.tsx
@@ -1,6 +1,25 @@
|
|||||||
import { lazy, Suspense } from 'react';
|
import { lazy, Suspense, type ComponentType } from 'react';
|
||||||
import { Routes, Route, Navigate, useLocation } from 'react-router';
|
import { Routes, Route, Navigate, useLocation, useParams } from 'react-router';
|
||||||
import { useAuthStore } from './store/auth';
|
import { 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 { useBlockingStore } from './store/blocking';
|
||||||
import Layout from './components/layout/Layout';
|
import Layout from './components/layout/Layout';
|
||||||
import PageLoader from './components/common/PageLoader';
|
import PageLoader from './components/common/PageLoader';
|
||||||
@@ -26,101 +45,107 @@ import OAuthCallback from './pages/OAuthCallback';
|
|||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
|
|
||||||
// User pages - lazy load
|
// User pages - lazy load
|
||||||
const Subscription = lazy(() => import('./pages/Subscription'));
|
const Subscriptions = lazyWithRetry(() => import('./pages/Subscriptions'));
|
||||||
const SubscriptionPurchase = lazy(() => import('./pages/SubscriptionPurchase'));
|
const Subscription = lazyWithRetry(() => import('./pages/Subscription'));
|
||||||
const Balance = lazy(() => import('./pages/Balance'));
|
const SubscriptionPurchase = lazyWithRetry(() => import('./pages/SubscriptionPurchase'));
|
||||||
const SavedCards = lazy(() => import('./pages/SavedCards'));
|
const Balance = lazyWithRetry(() => import('./pages/Balance'));
|
||||||
const Referral = lazy(() => import('./pages/Referral'));
|
const SavedCards = lazyWithRetry(() => import('./pages/SavedCards'));
|
||||||
const Support = lazy(() => import('./pages/Support'));
|
const Referral = lazyWithRetry(() => import('./pages/Referral'));
|
||||||
const Profile = lazy(() => import('./pages/Profile'));
|
const Support = lazyWithRetry(() => import('./pages/Support'));
|
||||||
const Contests = lazy(() => import('./pages/Contests'));
|
const Profile = lazyWithRetry(() => import('./pages/Profile'));
|
||||||
const Polls = lazy(() => import('./pages/Polls'));
|
const Contests = lazyWithRetry(() => import('./pages/Contests'));
|
||||||
const Info = lazy(() => import('./pages/Info'));
|
const Polls = lazyWithRetry(() => import('./pages/Polls'));
|
||||||
const Wheel = lazy(() => import('./pages/Wheel'));
|
const Info = lazyWithRetry(() => import('./pages/Info'));
|
||||||
const GiftSubscription = lazy(() => import('./pages/GiftSubscription'));
|
const Wheel = lazyWithRetry(() => import('./pages/Wheel'));
|
||||||
const GiftResult = lazy(() => import('./pages/GiftResult'));
|
const GiftSubscription = lazyWithRetry(() => import('./pages/GiftSubscription'));
|
||||||
const Connection = lazy(() => import('./pages/Connection'));
|
const GiftResult = lazyWithRetry(() => import('./pages/GiftResult'));
|
||||||
const ConnectionQR = lazy(() => import('./pages/ConnectionQR'));
|
const Connection = lazyWithRetry(() => import('./pages/Connection'));
|
||||||
const QuickPurchase = lazy(() => import('./pages/QuickPurchase'));
|
const ConnectionQR = lazyWithRetry(() => import('./pages/ConnectionQR'));
|
||||||
const PurchaseSuccess = lazy(() => import('./pages/PurchaseSuccess'));
|
const QuickPurchase = lazyWithRetry(() => import('./pages/QuickPurchase'));
|
||||||
const AutoLogin = lazy(() => import('./pages/AutoLogin'));
|
const PurchaseSuccess = lazyWithRetry(() => import('./pages/PurchaseSuccess'));
|
||||||
const TopUpMethodSelect = lazy(() => import('./pages/TopUpMethodSelect'));
|
const RenewSubscription = lazyWithRetry(() => import('./pages/RenewSubscription'));
|
||||||
const TopUpAmount = lazy(() => import('./pages/TopUpAmount'));
|
const AutoLogin = lazyWithRetry(() => import('./pages/AutoLogin'));
|
||||||
const TopUpResult = lazy(() => import('./pages/TopUpResult'));
|
const TopUpMethodSelect = lazyWithRetry(() => import('./pages/TopUpMethodSelect'));
|
||||||
const ConnectedAccounts = lazy(() => import('./pages/ConnectedAccounts'));
|
const TopUpAmount = lazyWithRetry(() => import('./pages/TopUpAmount'));
|
||||||
const LinkTelegramCallback = lazy(() => import('./pages/LinkTelegramCallback'));
|
const TopUpResult = lazyWithRetry(() => import('./pages/TopUpResult'));
|
||||||
const MergeAccounts = lazy(() => import('./pages/MergeAccounts'));
|
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)
|
// Admin pages - lazy load (only for admins)
|
||||||
const AdminPanel = lazy(() => import('./pages/AdminPanel'));
|
const AdminPanel = lazyWithRetry(() => import('./pages/AdminPanel'));
|
||||||
const AdminTickets = lazy(() => import('./pages/AdminTickets'));
|
const AdminTickets = lazyWithRetry(() => import('./pages/AdminTickets'));
|
||||||
const AdminTicketSettings = lazy(() => import('./pages/AdminTicketSettings'));
|
const AdminTicketSettings = lazyWithRetry(() => import('./pages/AdminTicketSettings'));
|
||||||
const AdminSettings = lazy(() => import('./pages/AdminSettings'));
|
const AdminSettings = lazyWithRetry(() => import('./pages/AdminSettings'));
|
||||||
const AdminApps = lazy(() => import('./pages/AdminApps'));
|
const AdminApps = lazyWithRetry(() => import('./pages/AdminApps'));
|
||||||
const AdminWheel = lazy(() => import('./pages/AdminWheel'));
|
const AdminWheel = lazyWithRetry(() => import('./pages/AdminWheel'));
|
||||||
const AdminTariffs = lazy(() => import('./pages/AdminTariffs'));
|
const AdminTariffs = lazyWithRetry(() => import('./pages/AdminTariffs'));
|
||||||
const AdminTariffCreate = lazy(() => import('./pages/AdminTariffCreate'));
|
const AdminTariffCreate = lazyWithRetry(() => import('./pages/AdminTariffCreate'));
|
||||||
const AdminServers = lazy(() => import('./pages/AdminServers'));
|
const AdminServers = lazyWithRetry(() => import('./pages/AdminServers'));
|
||||||
const AdminServerEdit = lazy(() => import('./pages/AdminServerEdit'));
|
const AdminServerEdit = lazyWithRetry(() => import('./pages/AdminServerEdit'));
|
||||||
const AdminDashboard = lazy(() => import('./pages/AdminDashboard'));
|
const AdminDashboard = lazyWithRetry(() => import('./pages/AdminDashboard'));
|
||||||
const AdminBanSystem = lazy(() => import('./pages/AdminBanSystem'));
|
const AdminBanSystem = lazyWithRetry(() => import('./pages/AdminBanSystem'));
|
||||||
const AdminBroadcasts = lazy(() => import('./pages/AdminBroadcasts'));
|
const AdminBroadcasts = lazyWithRetry(() => import('./pages/AdminBroadcasts'));
|
||||||
const AdminBroadcastCreate = lazy(() => import('./pages/AdminBroadcastCreate'));
|
const AdminBroadcastCreate = lazyWithRetry(() => import('./pages/AdminBroadcastCreate'));
|
||||||
const AdminPromocodes = lazy(() => import('./pages/AdminPromocodes'));
|
const AdminPromocodes = lazyWithRetry(() => import('./pages/AdminPromocodes'));
|
||||||
const AdminPromocodeCreate = lazy(() => import('./pages/AdminPromocodeCreate'));
|
const AdminPromocodeCreate = lazyWithRetry(() => import('./pages/AdminPromocodeCreate'));
|
||||||
const AdminPromocodeStats = lazy(() => import('./pages/AdminPromocodeStats'));
|
const AdminPromocodeStats = lazyWithRetry(() => import('./pages/AdminPromocodeStats'));
|
||||||
const AdminPromoGroups = lazy(() => import('./pages/AdminPromoGroups'));
|
const AdminPromoGroups = lazyWithRetry(() => import('./pages/AdminPromoGroups'));
|
||||||
const AdminPromoGroupCreate = lazy(() => import('./pages/AdminPromoGroupCreate'));
|
const AdminPromoGroupCreate = lazyWithRetry(() => import('./pages/AdminPromoGroupCreate'));
|
||||||
const AdminCampaigns = lazy(() => import('./pages/AdminCampaigns'));
|
const AdminCampaigns = lazyWithRetry(() => import('./pages/AdminCampaigns'));
|
||||||
const AdminCampaignCreate = lazy(() => import('./pages/AdminCampaignCreate'));
|
const AdminCampaignCreate = lazyWithRetry(() => import('./pages/AdminCampaignCreate'));
|
||||||
const AdminCampaignStats = lazy(() => import('./pages/AdminCampaignStats'));
|
const AdminCampaignStats = lazyWithRetry(() => import('./pages/AdminCampaignStats'));
|
||||||
const AdminCampaignEdit = lazy(() => import('./pages/AdminCampaignEdit'));
|
const AdminCampaignEdit = lazyWithRetry(() => import('./pages/AdminCampaignEdit'));
|
||||||
const AdminPartners = lazy(() => import('./pages/AdminPartners'));
|
const AdminPartners = lazyWithRetry(() => import('./pages/AdminPartners'));
|
||||||
const AdminPartnerSettings = lazy(() => import('./pages/AdminPartnerSettings'));
|
const AdminPartnerSettings = lazyWithRetry(() => import('./pages/AdminPartnerSettings'));
|
||||||
const AdminPartnerDetail = lazy(() => import('./pages/AdminPartnerDetail'));
|
const AdminPartnerDetail = lazyWithRetry(() => import('./pages/AdminPartnerDetail'));
|
||||||
const AdminApplicationReview = lazy(() => import('./pages/AdminApplicationReview'));
|
const AdminApplicationReview = lazyWithRetry(() => import('./pages/AdminApplicationReview'));
|
||||||
const AdminPartnerCommission = lazy(() => import('./pages/AdminPartnerCommission'));
|
const AdminPartnerCommission = lazyWithRetry(() => import('./pages/AdminPartnerCommission'));
|
||||||
const AdminPartnerRevoke = lazy(() => import('./pages/AdminPartnerRevoke'));
|
const AdminPartnerRevoke = lazyWithRetry(() => import('./pages/AdminPartnerRevoke'));
|
||||||
const AdminPartnerCampaignAssign = lazy(() => import('./pages/AdminPartnerCampaignAssign'));
|
const AdminPartnerCampaignAssign = lazyWithRetry(
|
||||||
const AdminWithdrawals = lazy(() => import('./pages/AdminWithdrawals'));
|
() => import('./pages/AdminPartnerCampaignAssign'),
|
||||||
const AdminWithdrawalDetail = lazy(() => import('./pages/AdminWithdrawalDetail'));
|
);
|
||||||
const AdminWithdrawalReject = lazy(() => import('./pages/AdminWithdrawalReject'));
|
const AdminWithdrawals = lazyWithRetry(() => import('./pages/AdminWithdrawals'));
|
||||||
const ReferralPartnerApply = lazy(() => import('./pages/ReferralPartnerApply'));
|
const AdminWithdrawalDetail = lazyWithRetry(() => import('./pages/AdminWithdrawalDetail'));
|
||||||
const ReferralWithdrawalRequest = lazy(() => import('./pages/ReferralWithdrawalRequest'));
|
const AdminWithdrawalReject = lazyWithRetry(() => import('./pages/AdminWithdrawalReject'));
|
||||||
const AdminUsers = lazy(() => import('./pages/AdminUsers'));
|
const ReferralPartnerApply = lazyWithRetry(() => import('./pages/ReferralPartnerApply'));
|
||||||
const AdminPayments = lazy(() => import('./pages/AdminPayments'));
|
const ReferralWithdrawalRequest = lazyWithRetry(() => import('./pages/ReferralWithdrawalRequest'));
|
||||||
const AdminPaymentMethods = lazy(() => import('./pages/AdminPaymentMethods'));
|
const AdminUsers = lazyWithRetry(() => import('./pages/AdminUsers'));
|
||||||
const AdminPaymentMethodEdit = lazy(() => import('./pages/AdminPaymentMethodEdit'));
|
const AdminPayments = lazyWithRetry(() => import('./pages/AdminPayments'));
|
||||||
const AdminPromoOffers = lazy(() => import('./pages/AdminPromoOffers'));
|
const AdminPaymentMethods = lazyWithRetry(() => import('./pages/AdminPaymentMethods'));
|
||||||
const AdminPromoOfferTemplateEdit = lazy(() => import('./pages/AdminPromoOfferTemplateEdit'));
|
const AdminPaymentMethodEdit = lazyWithRetry(() => import('./pages/AdminPaymentMethodEdit'));
|
||||||
const AdminPromoOfferSend = lazy(() => import('./pages/AdminPromoOfferSend'));
|
const AdminPromoOffers = lazyWithRetry(() => import('./pages/AdminPromoOffers'));
|
||||||
const AdminRemnawave = lazy(() => import('./pages/AdminRemnawave'));
|
const AdminPromoOfferTemplateEdit = lazyWithRetry(
|
||||||
const AdminRemnawaveSquadDetail = lazy(() => import('./pages/AdminRemnawaveSquadDetail'));
|
() => import('./pages/AdminPromoOfferTemplateEdit'),
|
||||||
const AdminEmailTemplates = lazy(() => import('./pages/AdminEmailTemplates'));
|
);
|
||||||
const AdminTrafficUsage = lazy(() => import('./pages/AdminTrafficUsage'));
|
const AdminPromoOfferSend = lazyWithRetry(() => import('./pages/AdminPromoOfferSend'));
|
||||||
const AdminSalesStats = lazy(() => import('./pages/AdminSalesStats'));
|
const AdminRemnawave = lazyWithRetry(() => import('./pages/AdminRemnawave'));
|
||||||
const AdminUpdates = lazy(() => import('./pages/AdminUpdates'));
|
const AdminRemnawaveSquadDetail = lazyWithRetry(() => import('./pages/AdminRemnawaveSquadDetail'));
|
||||||
const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail'));
|
const AdminEmailTemplates = lazyWithRetry(() => import('./pages/AdminEmailTemplates'));
|
||||||
const AdminBroadcastDetail = lazy(() => import('./pages/AdminBroadcastDetail'));
|
const AdminTrafficUsage = lazyWithRetry(() => import('./pages/AdminTrafficUsage'));
|
||||||
const AdminPinnedMessages = lazy(() => import('./pages/AdminPinnedMessages'));
|
const AdminSalesStats = lazyWithRetry(() => import('./pages/AdminSalesStats'));
|
||||||
const AdminPinnedMessageCreate = lazy(() => import('./pages/AdminPinnedMessageCreate'));
|
const AdminUpdates = lazyWithRetry(() => import('./pages/AdminUpdates'));
|
||||||
const AdminChannelSubscriptions = lazy(() => import('./pages/AdminChannelSubscriptions'));
|
const AdminUserDetail = lazyWithRetry(() => import('./pages/AdminUserDetail'));
|
||||||
const AdminEmailTemplatePreview = lazy(() => import('./pages/AdminEmailTemplatePreview'));
|
const AdminBroadcastDetail = lazyWithRetry(() => import('./pages/AdminBroadcastDetail'));
|
||||||
const AdminRoles = lazy(() => import('./pages/AdminRoles'));
|
const AdminPinnedMessages = lazyWithRetry(() => import('./pages/AdminPinnedMessages'));
|
||||||
const AdminRoleEdit = lazy(() => import('./pages/AdminRoleEdit'));
|
const AdminPinnedMessageCreate = lazyWithRetry(() => import('./pages/AdminPinnedMessageCreate'));
|
||||||
const AdminRoleAssign = lazy(() => import('./pages/AdminRoleAssign'));
|
const AdminChannelSubscriptions = lazyWithRetry(() => import('./pages/AdminChannelSubscriptions'));
|
||||||
const AdminPolicies = lazy(() => import('./pages/AdminPolicies'));
|
const AdminEmailTemplatePreview = lazyWithRetry(() => import('./pages/AdminEmailTemplatePreview'));
|
||||||
const AdminPolicyEdit = lazy(() => import('./pages/AdminPolicyEdit'));
|
const AdminRoles = lazyWithRetry(() => import('./pages/AdminRoles'));
|
||||||
const AdminAuditLog = lazy(() => import('./pages/AdminAuditLog'));
|
const AdminRoleEdit = lazyWithRetry(() => import('./pages/AdminRoleEdit'));
|
||||||
const AdminLandings = lazy(() => import('./pages/AdminLandings'));
|
const AdminRoleAssign = lazyWithRetry(() => import('./pages/AdminRoleAssign'));
|
||||||
const AdminLandingEditor = lazy(() => import('./pages/AdminLandingEditor'));
|
const AdminPolicies = lazyWithRetry(() => import('./pages/AdminPolicies'));
|
||||||
const AdminLandingStats = lazy(() => import('./pages/AdminLandingStats'));
|
const AdminPolicyEdit = lazyWithRetry(() => import('./pages/AdminPolicyEdit'));
|
||||||
const AdminReferralNetwork = lazy(() => import('./pages/ReferralNetwork'));
|
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
|
// News pages
|
||||||
const NewsArticlePage = lazy(() => import('./pages/NewsArticle'));
|
const NewsArticlePage = lazyWithRetry(() => import('./pages/NewsArticle'));
|
||||||
const AdminNews = lazy(() => import('./pages/AdminNews'));
|
const AdminNews = lazyWithRetry(() => import('./pages/AdminNews'));
|
||||||
const AdminNewsCreate = lazy(() => import('./pages/AdminNewsCreate'));
|
const AdminNewsCreate = lazyWithRetry(() => import('./pages/AdminNewsCreate'));
|
||||||
|
|
||||||
function ProtectedRoute({
|
function ProtectedRoute({
|
||||||
children,
|
children,
|
||||||
@@ -190,6 +215,12 @@ function BlockingOverlay() {
|
|||||||
return null;
|
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() {
|
function App() {
|
||||||
useAnalyticsCounters();
|
useAnalyticsCounters();
|
||||||
|
|
||||||
@@ -258,7 +289,17 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/subscription"
|
path="/subscriptions"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<LazyPage>
|
||||||
|
<Subscriptions />
|
||||||
|
</LazyPage>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/subscriptions/:subscriptionId"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<LazyPage>
|
<LazyPage>
|
||||||
@@ -267,6 +308,26 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</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
|
<Route
|
||||||
path="/subscription/purchase"
|
path="/subscription/purchase"
|
||||||
element={
|
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.
|
* Shows back button on non-root routes, hides on root.
|
||||||
*/
|
*/
|
||||||
/** Pages reachable from bottom nav — treat as top-level (no back button). */
|
/** 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() {
|
function TelegramBackButton() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|||||||
@@ -162,15 +162,11 @@ export interface NodeStatus {
|
|||||||
is_disabled: boolean;
|
is_disabled: boolean;
|
||||||
users_online: number;
|
users_online: number;
|
||||||
traffic_used_bytes?: number;
|
traffic_used_bytes?: number;
|
||||||
uptime?: string;
|
|
||||||
xray_version?: string;
|
|
||||||
node_version?: string;
|
|
||||||
last_status_message?: string;
|
last_status_message?: string;
|
||||||
xray_uptime?: string;
|
xray_uptime: number;
|
||||||
is_xray_running?: boolean;
|
is_xray_running?: boolean;
|
||||||
cpu_count?: number;
|
versions?: { xray: string; node: string } | null;
|
||||||
cpu_model?: string;
|
system?: Record<string, unknown> | null;
|
||||||
total_ram?: string;
|
|
||||||
country_code?: string;
|
country_code?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,11 +29,9 @@ export interface SystemSummary {
|
|||||||
|
|
||||||
export interface ServerInfo {
|
export interface ServerInfo {
|
||||||
cpu_cores: number;
|
cpu_cores: number;
|
||||||
cpu_physical_cores: number;
|
|
||||||
memory_total: number;
|
memory_total: number;
|
||||||
memory_used: number;
|
memory_used: number;
|
||||||
memory_free: number;
|
memory_free: number;
|
||||||
memory_available: number;
|
|
||||||
uptime_seconds: number;
|
uptime_seconds: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,22 +76,60 @@ export interface NodeInfo {
|
|||||||
is_disabled: boolean;
|
is_disabled: boolean;
|
||||||
is_node_online: boolean;
|
is_node_online: boolean;
|
||||||
is_xray_running: boolean;
|
is_xray_running: boolean;
|
||||||
users_online?: number;
|
users_online: number;
|
||||||
traffic_used_bytes?: number;
|
traffic_used_bytes?: number;
|
||||||
traffic_limit_bytes?: number;
|
traffic_limit_bytes?: number;
|
||||||
last_status_change?: string;
|
last_status_change?: string;
|
||||||
last_status_message?: string;
|
last_status_message?: string;
|
||||||
xray_uptime?: string;
|
xray_uptime: number;
|
||||||
is_traffic_tracking_active: boolean;
|
is_traffic_tracking_active: boolean;
|
||||||
traffic_reset_day?: number;
|
traffic_reset_day?: number;
|
||||||
notify_percent?: number;
|
notify_percent?: number;
|
||||||
consumption_multiplier: number;
|
consumption_multiplier: number;
|
||||||
cpu_count?: number;
|
|
||||||
cpu_model?: string;
|
|
||||||
total_ram?: string;
|
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
provider_uuid?: 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 {
|
export interface NodesListResponse {
|
||||||
@@ -123,6 +159,27 @@ export interface NodeActionResponse {
|
|||||||
is_disabled?: boolean;
|
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
|
// Squads
|
||||||
export interface SquadWithLocalInfo {
|
export interface SquadWithLocalInfo {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
@@ -246,7 +303,7 @@ export const adminRemnawaveApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getNodesRealtime: async (): Promise<Record<string, unknown>[]> => {
|
getNodesRealtime: async (): Promise<NodeRealtimeStats[]> => {
|
||||||
const response = await apiClient.get('/cabinet/admin/remnawave/nodes/realtime');
|
const response = await apiClient.get('/cabinet/admin/remnawave/nodes/realtime');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ export interface UserDetailResponse {
|
|||||||
last_activity: string | null;
|
last_activity: string | null;
|
||||||
cabinet_last_login: string | null;
|
cabinet_last_login: string | null;
|
||||||
subscription: UserSubscriptionInfo | null;
|
subscription: UserSubscriptionInfo | null;
|
||||||
|
subscriptions: UserSubscriptionInfo[];
|
||||||
promo_group: UserPromoGroupInfo | null;
|
promo_group: UserPromoGroupInfo | null;
|
||||||
referral: UserReferralInfo;
|
referral: UserReferralInfo;
|
||||||
total_spent_kopeks: number;
|
total_spent_kopeks: number;
|
||||||
@@ -250,6 +251,8 @@ export interface PanelSyncStatusResponse {
|
|||||||
user_id: number;
|
user_id: number;
|
||||||
telegram_id: number;
|
telegram_id: number;
|
||||||
remnawave_uuid: string | null;
|
remnawave_uuid: string | null;
|
||||||
|
subscription_id: number | null;
|
||||||
|
subscription_tariff_name: string | null;
|
||||||
last_sync: string | null;
|
last_sync: string | null;
|
||||||
bot_subscription_status: string | null;
|
bot_subscription_status: string | null;
|
||||||
bot_subscription_end_date: string | null;
|
bot_subscription_end_date: string | null;
|
||||||
@@ -296,6 +299,7 @@ export interface UpdateSubscriptionRequest {
|
|||||||
| 'remove_traffic'
|
| 'remove_traffic'
|
||||||
| 'set_device_limit'
|
| 'set_device_limit'
|
||||||
| 'shorten';
|
| 'shorten';
|
||||||
|
subscription_id?: number;
|
||||||
days?: number;
|
days?: number;
|
||||||
end_date?: string;
|
end_date?: string;
|
||||||
tariff_id?: number;
|
tariff_id?: number;
|
||||||
@@ -532,6 +536,34 @@ export const adminUsersApi = {
|
|||||||
return response.data;
|
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
|
// Get referrals
|
||||||
getReferrals: async (userId: number, offset = 0, limit = 50): Promise<UsersListResponse> => {
|
getReferrals: async (userId: number, offset = 0, limit = 50): Promise<UsersListResponse> => {
|
||||||
const response = await apiClient.get(`/cabinet/admin/users/${userId}/referrals`, {
|
const response = await apiClient.get(`/cabinet/admin/users/${userId}/referrals`, {
|
||||||
@@ -559,8 +591,12 @@ export const adminUsersApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Sync status
|
// Sync status
|
||||||
getSyncStatus: async (userId: number): Promise<PanelSyncStatusResponse> => {
|
getSyncStatus: async (
|
||||||
const response = await apiClient.get(`/cabinet/admin/users/${userId}/sync/status`);
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -568,8 +604,12 @@ export const adminUsersApi = {
|
|||||||
syncFromPanel: async (
|
syncFromPanel: async (
|
||||||
userId: number,
|
userId: number,
|
||||||
data: SyncFromPanelRequest = {},
|
data: SyncFromPanelRequest = {},
|
||||||
|
subscriptionId?: number,
|
||||||
): Promise<SyncFromPanelResponse> => {
|
): 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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -577,8 +617,12 @@ export const adminUsersApi = {
|
|||||||
syncToPanel: async (
|
syncToPanel: async (
|
||||||
userId: number,
|
userId: number,
|
||||||
data: SyncToPanelRequest = {},
|
data: SyncToPanelRequest = {},
|
||||||
|
subscriptionId?: number,
|
||||||
): Promise<SyncToPanelResponse> => {
|
): 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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -601,26 +645,33 @@ export const adminUsersApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Get panel info
|
// Get panel info
|
||||||
getPanelInfo: async (userId: number): Promise<UserPanelInfo> => {
|
getPanelInfo: async (userId: number, subscriptionId?: number): Promise<UserPanelInfo> => {
|
||||||
const response = await apiClient.get(`/cabinet/admin/users/${userId}/panel-info`);
|
const response = await apiClient.get(`/cabinet/admin/users/${userId}/panel-info`, {
|
||||||
|
params: subscriptionId != null ? { subscription_id: subscriptionId } : undefined,
|
||||||
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get node usage (always 30 days with daily breakdown)
|
// Get node usage (always 30 days with daily breakdown)
|
||||||
getNodeUsage: async (userId: number): Promise<UserNodeUsageResponse> => {
|
getNodeUsage: async (userId: number, subscriptionId?: number): Promise<UserNodeUsageResponse> => {
|
||||||
const response = await apiClient.get(`/cabinet/admin/users/${userId}/node-usage`);
|
const response = await apiClient.get(`/cabinet/admin/users/${userId}/node-usage`, {
|
||||||
|
params: subscriptionId != null ? { subscription_id: subscriptionId } : undefined,
|
||||||
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get user devices
|
// Get user devices
|
||||||
getUserDevices: async (
|
getUserDevices: async (
|
||||||
userId: number,
|
userId: number,
|
||||||
|
subscriptionId?: number,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
devices: { hwid: string; platform: string; device_model: string; created_at: string | null }[];
|
devices: { hwid: string; platform: string; device_model: string; created_at: string | null }[];
|
||||||
total: number;
|
total: number;
|
||||||
device_limit: 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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -628,16 +679,22 @@ export const adminUsersApi = {
|
|||||||
deleteUserDevice: async (
|
deleteUserDevice: async (
|
||||||
userId: number,
|
userId: number,
|
||||||
hwid: string,
|
hwid: string,
|
||||||
|
subscriptionId?: number,
|
||||||
): Promise<{ success: boolean; message: string; deleted_hwid: string | null }> => {
|
): 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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Reset all devices
|
// Reset all devices
|
||||||
resetUserDevices: async (
|
resetUserDevices: async (
|
||||||
userId: number,
|
userId: number,
|
||||||
|
subscriptionId?: number,
|
||||||
): Promise<{ success: boolean; message: string; deleted_count: 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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,12 @@ export const authApi = {
|
|||||||
registerEmail: async (
|
registerEmail: async (
|
||||||
email: string,
|
email: string,
|
||||||
password: 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', {
|
const response = await apiClient.post('/cabinet/auth/email/register', {
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import apiClient from './client';
|
import apiClient from './client';
|
||||||
|
import i18n from '../i18n';
|
||||||
import type {
|
import type {
|
||||||
Balance,
|
Balance,
|
||||||
Transaction,
|
Transaction,
|
||||||
@@ -54,6 +55,7 @@ export const balanceApi = {
|
|||||||
amount_kopeks: number;
|
amount_kopeks: number;
|
||||||
payment_method: string;
|
payment_method: string;
|
||||||
payment_option?: string;
|
payment_option?: string;
|
||||||
|
language?: string;
|
||||||
} = {
|
} = {
|
||||||
amount_kopeks: amountKopeks,
|
amount_kopeks: amountKopeks,
|
||||||
payment_method: paymentMethod,
|
payment_method: paymentMethod,
|
||||||
@@ -61,6 +63,7 @@ export const balanceApi = {
|
|||||||
if (paymentOption) {
|
if (paymentOption) {
|
||||||
payload.payment_option = paymentOption;
|
payload.payment_option = paymentOption;
|
||||||
}
|
}
|
||||||
|
payload.language = i18n.language || 'ru';
|
||||||
const response = await apiClient.post('/cabinet/balance/topup', payload);
|
const response = await apiClient.post('/cabinet/balance/topup', payload);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
@@ -68,14 +71,21 @@ export const balanceApi = {
|
|||||||
// Activate promo code
|
// Activate promo code
|
||||||
activatePromocode: async (
|
activatePromocode: async (
|
||||||
code: string,
|
code: string,
|
||||||
|
subscriptionId?: number,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message?: string;
|
||||||
balance_before: number;
|
balance_before?: number;
|
||||||
balance_after: number;
|
balance_after?: number;
|
||||||
bonus_description: string | null;
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export interface PurchaseRequest {
|
|||||||
gift_recipient_type?: 'email' | 'telegram';
|
gift_recipient_type?: 'email' | 'telegram';
|
||||||
gift_recipient_value?: string;
|
gift_recipient_value?: string;
|
||||||
gift_message?: string;
|
gift_message?: string;
|
||||||
|
language?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PurchaseResponse {
|
export interface PurchaseResponse {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export interface PromoCode {
|
|||||||
valid_from: string;
|
valid_from: string;
|
||||||
valid_until: string | null;
|
valid_until: string | null;
|
||||||
promo_group_id: number | null;
|
promo_group_id: number | null;
|
||||||
|
tariff_id: number | null;
|
||||||
|
tariff_name: string | null;
|
||||||
created_by: number | null;
|
created_by: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -63,6 +65,7 @@ export interface PromoCodeCreateRequest {
|
|||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
first_purchase_only?: boolean;
|
first_purchase_only?: boolean;
|
||||||
promo_group_id?: number | null;
|
promo_group_id?: number | null;
|
||||||
|
tariff_id?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromoCodeUpdateRequest {
|
export interface PromoCodeUpdateRequest {
|
||||||
@@ -76,6 +79,7 @@ export interface PromoCodeUpdateRequest {
|
|||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
first_purchase_only?: boolean;
|
first_purchase_only?: boolean;
|
||||||
promo_group_id?: number | null;
|
promo_group_id?: number | null;
|
||||||
|
tariff_id?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============== PromoGroup Types ==============
|
// ============== PromoGroup Types ==============
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import apiClient from './client';
|
|||||||
import type {
|
import type {
|
||||||
Subscription,
|
Subscription,
|
||||||
SubscriptionStatusResponse,
|
SubscriptionStatusResponse,
|
||||||
|
SubscriptionListItem,
|
||||||
|
SubscriptionsListResponse,
|
||||||
RenewalOption,
|
RenewalOption,
|
||||||
TrafficPackage,
|
TrafficPackage,
|
||||||
TrialInfo,
|
TrialInfo,
|
||||||
@@ -11,50 +13,162 @@ import type {
|
|||||||
AppConfig,
|
AppConfig,
|
||||||
} from '../types';
|
} 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 = {
|
export const subscriptionApi = {
|
||||||
getSubscription: async (): Promise<SubscriptionStatusResponse> => {
|
// ── Multi-tariff endpoints ──────────────────────────────────────────
|
||||||
const response = await apiClient.get<SubscriptionStatusResponse>('/cabinet/subscription');
|
|
||||||
|
getSubscriptions: async (): Promise<SubscriptionsListResponse> => {
|
||||||
|
const response = await apiClient.get<SubscriptionsListResponse>('/cabinet/subscriptions');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getRenewalOptions: async (): Promise<RenewalOption[]> => {
|
getSubscriptionById: async (subscriptionId: number): Promise<SubscriptionListItem> => {
|
||||||
const response = await apiClient.get<RenewalOption[]>('/cabinet/subscription/renewal-options');
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
renewSubscription: async (
|
renewSubscription: async (
|
||||||
periodDays: number,
|
periodDays: number,
|
||||||
|
subscriptionId?: number,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
message: string;
|
message: string;
|
||||||
new_end_date: string;
|
new_end_date: string;
|
||||||
amount_paid_kopeks: number;
|
amount_paid_kopeks: number;
|
||||||
}> => {
|
}> => {
|
||||||
const response = await apiClient.post('/cabinet/subscription/renew', {
|
const response = await apiClient.post(
|
||||||
period_days: periodDays,
|
'/cabinet/subscription/renew',
|
||||||
});
|
...bodyWithSubId({ period_days: periodDays }, subscriptionId),
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getTrafficPackages: async (): Promise<TrafficPackage[]> => {
|
// ── Traffic ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
getTrafficPackages: async (subscriptionId?: number): Promise<TrafficPackage[]> => {
|
||||||
const response = await apiClient.get<TrafficPackage[]>(
|
const response = await apiClient.get<TrafficPackage[]>(
|
||||||
'/cabinet/subscription/traffic-packages',
|
'/cabinet/subscription/traffic-packages',
|
||||||
|
withSubId(subscriptionId),
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
purchaseTraffic: async (
|
purchaseTraffic: async (
|
||||||
gb: number,
|
gb: number,
|
||||||
|
subscriptionId?: number,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
message: string;
|
message: string;
|
||||||
gb_added: number;
|
gb_added: number;
|
||||||
amount_paid_kopeks: 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;
|
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 (
|
purchaseDevices: async (
|
||||||
devices: number,
|
devices: number,
|
||||||
|
subscriptionId?: number,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -65,12 +179,16 @@ export const subscriptionApi = {
|
|||||||
balance_kopeks: number;
|
balance_kopeks: number;
|
||||||
balance_label: string;
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getDevicePrice: async (
|
getDevicePrice: async (
|
||||||
devices: number = 1,
|
devices: number = 1,
|
||||||
|
subscriptionId?: number,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
available: boolean;
|
available: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
@@ -84,23 +202,30 @@ export const subscriptionApi = {
|
|||||||
can_add?: number;
|
can_add?: number;
|
||||||
days_left?: number;
|
days_left?: number;
|
||||||
base_device_price_kopeks?: number;
|
base_device_price_kopeks?: number;
|
||||||
// Discount fields (from promo group)
|
|
||||||
original_price_per_device_kopeks?: number;
|
original_price_per_device_kopeks?: number;
|
||||||
base_total_price_kopeks?: number;
|
base_total_price_kopeks?: number;
|
||||||
discount_percent?: number;
|
discount_percent?: number;
|
||||||
discount_kopeks?: number;
|
discount_kopeks?: number;
|
||||||
}> => {
|
}> => {
|
||||||
const response = await apiClient.get('/cabinet/subscription/devices/price', {
|
const response = await apiClient.get('/cabinet/subscription/devices/price', {
|
||||||
params: { devices },
|
params: {
|
||||||
|
devices,
|
||||||
|
...(subscriptionId != null && { subscription_id: subscriptionId }),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
saveDevicesCart: async (devices: number): Promise<void> => {
|
saveDevicesCart: async (devices: number, subscriptionId?: number): Promise<void> => {
|
||||||
await apiClient.post('/cabinet/subscription/devices/save-cart', { devices });
|
await apiClient.post(
|
||||||
|
'/cabinet/subscription/devices/save-cart',
|
||||||
|
...bodyWithSubId({ devices }, subscriptionId),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
getDeviceReductionInfo: async (): Promise<{
|
getDeviceReductionInfo: async (
|
||||||
|
subscriptionId?: number,
|
||||||
|
): Promise<{
|
||||||
available: boolean;
|
available: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
current_device_limit: number;
|
current_device_limit: number;
|
||||||
@@ -108,43 +233,98 @@ export const subscriptionApi = {
|
|||||||
can_reduce: number;
|
can_reduce: number;
|
||||||
connected_devices_count: 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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
reduceDevices: async (
|
reduceDevices: async (
|
||||||
newDeviceLimit: number,
|
newDeviceLimit: number,
|
||||||
|
subscriptionId?: number,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
old_device_limit: number;
|
old_device_limit: number;
|
||||||
new_device_limit: number;
|
new_device_limit: number;
|
||||||
}> => {
|
}> => {
|
||||||
const response = await apiClient.post('/cabinet/subscription/devices/reduce', {
|
const response = await apiClient.post(
|
||||||
new_device_limit: newDeviceLimit,
|
'/cabinet/subscription/devices/reduce',
|
||||||
});
|
...bodyWithSubId({ new_device_limit: newDeviceLimit }, subscriptionId),
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
saveTrafficCart: async (trafficGb: number): Promise<void> => {
|
getDevices: async (
|
||||||
await apiClient.post('/cabinet/subscription/traffic/save-cart', { gb: trafficGb });
|
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 (
|
updateAutopay: async (
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
daysBefore?: number,
|
daysBefore?: number,
|
||||||
|
subscriptionId?: number,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
message: string;
|
message: string;
|
||||||
autopay_enabled: boolean;
|
autopay_enabled: boolean;
|
||||||
autopay_days_before: number;
|
autopay_days_before: number;
|
||||||
}> => {
|
}> => {
|
||||||
const response = await apiClient.patch('/cabinet/subscription/autopay', {
|
const response = await apiClient.patch(
|
||||||
enabled,
|
'/cabinet/subscription/autopay',
|
||||||
days_before: daysBefore,
|
{ enabled, days_before: daysBefore },
|
||||||
});
|
withSubId(subscriptionId),
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Trial ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
getTrialInfo: async (): Promise<TrialInfo> => {
|
getTrialInfo: async (): Promise<TrialInfo> => {
|
||||||
const response = await apiClient.get<TrialInfo>('/cabinet/subscription/trial');
|
const response = await apiClient.get<TrialInfo>('/cabinet/subscription/trial');
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -155,32 +335,40 @@ export const subscriptionApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getPurchaseOptions: async (): Promise<PurchaseOptions> => {
|
// ── Purchase ────────────────────────────────────────────────────────
|
||||||
const response = await apiClient.get<PurchaseOptions>('/cabinet/subscription/purchase-options');
|
|
||||||
|
getPurchaseOptions: async (subscriptionId?: number): Promise<PurchaseOptions> => {
|
||||||
|
const response = await apiClient.get<PurchaseOptions>(
|
||||||
|
'/cabinet/subscription/purchase-options',
|
||||||
|
withSubId(subscriptionId),
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
previewPurchase: async (selection: PurchaseSelection): Promise<PurchasePreview> => {
|
previewPurchase: async (
|
||||||
|
selection: PurchaseSelection,
|
||||||
|
subscriptionId?: number,
|
||||||
|
): Promise<PurchasePreview> => {
|
||||||
const response = await apiClient.post<PurchasePreview>(
|
const response = await apiClient.post<PurchasePreview>(
|
||||||
'/cabinet/subscription/purchase-preview',
|
'/cabinet/subscription/purchase-preview',
|
||||||
{
|
...bodyWithSubId({ selection }, subscriptionId),
|
||||||
selection,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
submitPurchase: async (
|
submitPurchase: async (
|
||||||
selection: PurchaseSelection,
|
selection: PurchaseSelection,
|
||||||
|
subscriptionId?: number,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
subscription: Subscription;
|
subscription: Subscription;
|
||||||
was_trial_conversion: boolean;
|
was_trial_conversion: boolean;
|
||||||
}> => {
|
}> => {
|
||||||
const response = await apiClient.post('/cabinet/subscription/purchase', {
|
const response = await apiClient.post(
|
||||||
selection,
|
'/cabinet/subscription/purchase',
|
||||||
});
|
...bodyWithSubId({ selection }, subscriptionId),
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -205,12 +393,11 @@ export const subscriptionApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getAppConfig: async (): Promise<AppConfig> => {
|
// ── Countries / Servers ─────────────────────────────────────────────
|
||||||
const response = await apiClient.get<AppConfig>('/cabinet/subscription/app-config');
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
getCountries: async (): Promise<{
|
getCountries: async (
|
||||||
|
subscriptionId?: number,
|
||||||
|
): Promise<{
|
||||||
countries: Array<{
|
countries: Array<{
|
||||||
uuid: string;
|
uuid: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -229,12 +416,16 @@ export const subscriptionApi = {
|
|||||||
days_left: number;
|
days_left: number;
|
||||||
discount_percent: 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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
updateCountries: async (
|
updateCountries: async (
|
||||||
countries: string[],
|
countries: string[],
|
||||||
|
subscriptionId?: number,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
message: string;
|
message: string;
|
||||||
added: string[];
|
added: string[];
|
||||||
@@ -242,11 +433,18 @@ export const subscriptionApi = {
|
|||||||
amount_paid_kopeks: number;
|
amount_paid_kopeks: number;
|
||||||
connected_squads: string[];
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getConnectionLink: async (): Promise<{
|
// ── Connection ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
getConnectionLink: async (
|
||||||
|
subscriptionId?: number,
|
||||||
|
): Promise<{
|
||||||
subscription_url: string | null;
|
subscription_url: string | null;
|
||||||
display_link: string | null;
|
display_link: string | null;
|
||||||
happ_redirect_link: string | null;
|
happ_redirect_link: string | null;
|
||||||
@@ -257,7 +455,10 @@ export const subscriptionApi = {
|
|||||||
steps: string[];
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -276,44 +477,19 @@ export const subscriptionApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getDevices: async (): Promise<{
|
getAppConfig: async (subscriptionId?: number): Promise<AppConfig> => {
|
||||||
devices: Array<{
|
const response = await apiClient.get<AppConfig>(
|
||||||
hwid: string;
|
'/cabinet/subscription/app-config',
|
||||||
platform: string;
|
withSubId(subscriptionId),
|
||||||
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)}`,
|
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteAllDevices: async (): Promise<{
|
// ── Tariff switch ───────────────────────────────────────────────────
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
deleted_count: number;
|
|
||||||
}> => {
|
|
||||||
const response = await apiClient.delete('/cabinet/subscription/devices');
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
previewTariffSwitch: async (
|
previewTariffSwitch: async (
|
||||||
tariffId: number,
|
tariffId: number,
|
||||||
|
subscriptionId?: number,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
can_switch: boolean;
|
can_switch: boolean;
|
||||||
current_tariff_id: number | null;
|
current_tariff_id: number | null;
|
||||||
@@ -329,20 +505,20 @@ export const subscriptionApi = {
|
|||||||
missing_amount_kopeks: number;
|
missing_amount_kopeks: number;
|
||||||
missing_amount_label: string;
|
missing_amount_label: string;
|
||||||
is_upgrade: boolean;
|
is_upgrade: boolean;
|
||||||
// Discount fields (from promo group)
|
|
||||||
base_upgrade_cost_kopeks?: number;
|
base_upgrade_cost_kopeks?: number;
|
||||||
discount_percent?: number;
|
discount_percent?: number;
|
||||||
discount_kopeks?: number;
|
discount_kopeks?: number;
|
||||||
}> => {
|
}> => {
|
||||||
const response = await apiClient.post('/cabinet/subscription/tariff/switch/preview', {
|
const response = await apiClient.post(
|
||||||
tariff_id: tariffId,
|
'/cabinet/subscription/tariff/switch/preview',
|
||||||
period_days: 30, // Default period for switch
|
...bodyWithSubId({ tariff_id: tariffId, period_days: 30 }, subscriptionId),
|
||||||
});
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
switchTariff: async (
|
switchTariff: async (
|
||||||
tariffId: number,
|
tariffId: number,
|
||||||
|
subscriptionId?: number,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -354,56 +530,29 @@ export const subscriptionApi = {
|
|||||||
balance_kopeks: number;
|
balance_kopeks: number;
|
||||||
balance_label: string;
|
balance_label: string;
|
||||||
}> => {
|
}> => {
|
||||||
const response = await apiClient.post('/cabinet/subscription/tariff/switch', {
|
const response = await apiClient.post(
|
||||||
tariff_id: tariffId,
|
'/cabinet/subscription/tariff/switch',
|
||||||
period_days: 30,
|
...bodyWithSubId({ tariff_id: tariffId, period_days: 30 }, subscriptionId),
|
||||||
});
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
togglePause: async (): Promise<{
|
// ── Daily subscription ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
togglePause: async (
|
||||||
|
subscriptionId?: number,
|
||||||
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
is_paused: boolean;
|
is_paused: boolean;
|
||||||
balance_kopeks: number;
|
balance_kopeks: number;
|
||||||
balance_label: string;
|
balance_label: string;
|
||||||
}> => {
|
}> => {
|
||||||
const response = await apiClient.post('/cabinet/subscription/pause');
|
const response = await apiClient.post(
|
||||||
return response.data;
|
'/cabinet/subscription/pause',
|
||||||
},
|
undefined,
|
||||||
|
withSubId(subscriptionId),
|
||||||
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');
|
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export interface TariffDetail {
|
|||||||
is_daily: boolean;
|
is_daily: boolean;
|
||||||
daily_price_kopeks: number;
|
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
|
// Внешний сквад RemnaWave
|
||||||
external_squad_uuid: string | null;
|
external_squad_uuid: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ export interface WheelPrize {
|
|||||||
prize_type: string;
|
prize_type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EligibleSubscription {
|
||||||
|
id: number;
|
||||||
|
tariff_name: string | null;
|
||||||
|
days_left: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WheelConfig {
|
export interface WheelConfig {
|
||||||
is_enabled: boolean;
|
is_enabled: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -25,6 +31,7 @@ export interface WheelConfig {
|
|||||||
user_balance_kopeks: number;
|
user_balance_kopeks: number;
|
||||||
required_balance_kopeks: number;
|
required_balance_kopeks: number;
|
||||||
has_subscription: boolean;
|
has_subscription: boolean;
|
||||||
|
eligible_subscriptions: EligibleSubscription[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpinAvailability {
|
export interface SpinAvailability {
|
||||||
@@ -187,9 +194,13 @@ export const wheelApi = {
|
|||||||
return response.data;
|
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', {
|
const response = await apiClient.post<SpinResult>('/cabinet/wheel/spin', {
|
||||||
payment_type: paymentType,
|
payment_type: paymentType,
|
||||||
|
...(subscriptionId != null && { subscription_id: subscriptionId }),
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,6 +11,18 @@ interface ErrorBoundaryState {
|
|||||||
error: Error | null;
|
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> {
|
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
constructor(props: ErrorBoundaryProps) {
|
constructor(props: ErrorBoundaryProps) {
|
||||||
super(props);
|
super(props);
|
||||||
@@ -23,6 +35,19 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
|||||||
|
|
||||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
console.error('[ErrorBoundary]', error, 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 = () => {
|
handleReset = () => {
|
||||||
@@ -36,6 +61,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { level = 'page' } = this.props;
|
const { level = 'page' } = this.props;
|
||||||
|
const isChunk = this.state.error ? isChunkLoadError(this.state.error) : false;
|
||||||
|
|
||||||
if (level === 'app') {
|
if (level === 'app') {
|
||||||
return (
|
return (
|
||||||
@@ -78,13 +104,15 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
|||||||
<div className="mb-4 text-4xl">⚠️</div>
|
<div className="mb-4 text-4xl">⚠️</div>
|
||||||
<h1 className="mb-2 text-xl font-bold text-dark-50">Something went wrong</h1>
|
<h1 className="mb-2 text-xl font-bold text-dark-50">Something went wrong</h1>
|
||||||
<p className="mb-6 text-sm text-dark-400">
|
<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>
|
</p>
|
||||||
<button
|
<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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { infoApi, type LanguageInfo } from '@/api/info';
|
||||||
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: 'فارسی' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function LanguageSwitcher() {
|
export default function LanguageSwitcher() {
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [availableLanguages, setAvailableLanguages] = useState<LanguageInfo[]>([]);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
function handleClickOutside(event: MouseEvent) {
|
function handleClickOutside(event: MouseEvent) {
|
||||||
@@ -27,16 +35,18 @@ export default function LanguageSwitcher() {
|
|||||||
|
|
||||||
const changeLanguage = (code: string) => {
|
const changeLanguage = (code: string) => {
|
||||||
i18n.changeLanguage(code);
|
i18n.changeLanguage(code);
|
||||||
// Set document direction for RTL languages
|
|
||||||
document.documentElement.dir = code === 'fa' ? 'rtl' : 'ltr';
|
document.documentElement.dir = code === 'fa' ? 'rtl' : 'ltr';
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set initial direction on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.dir = i18n.language === 'fa' ? 'rtl' : 'ltr';
|
document.documentElement.dir = i18n.language === 'fa' ? 'rtl' : 'ltr';
|
||||||
}, [i18n.language]);
|
}, [i18n.language]);
|
||||||
|
|
||||||
|
if (availableLanguages.length <= 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
@@ -49,7 +59,7 @@ export default function LanguageSwitcher() {
|
|||||||
aria-label="Change language"
|
aria-label="Change language"
|
||||||
>
|
>
|
||||||
<span>{currentLang.flag}</span>
|
<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
|
<svg
|
||||||
className={`h-3.5 w-3.5 text-dark-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
className={`h-3.5 w-3.5 text-dark-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -62,7 +72,7 @@ export default function LanguageSwitcher() {
|
|||||||
|
|
||||||
{isOpen && (
|
{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">
|
<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
|
<button
|
||||||
key={lang.code}
|
key={lang.code}
|
||||||
onClick={() => changeLanguage(lang.code)}
|
onClick={() => changeLanguage(lang.code)}
|
||||||
@@ -73,7 +83,7 @@ export default function LanguageSwitcher() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span>{lang.flag}</span>
|
<span>{lang.flag}</span>
|
||||||
<span>{lang.fullName}</span>
|
<span>{lang.name}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
|||||||
queryClient.invalidateQueries({ queryKey: ['promo-offers'] });
|
queryClient.invalidateQueries({ queryKey: ['promo-offers'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['active-discount'] });
|
queryClient.invalidateQueries({ queryKey: ['active-discount'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['balance'] });
|
queryClient.invalidateQueries({ queryKey: ['balance'] });
|
||||||
setSuccessMessage(result.message);
|
setSuccessMessage(result.message);
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ export default function SuccessNotificationModal() {
|
|||||||
|
|
||||||
const handleGoToSubscription = () => {
|
const handleGoToSubscription = () => {
|
||||||
hide();
|
hide();
|
||||||
navigate('/subscription');
|
navigate('/subscriptions');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGoToBalance = () => {
|
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,
|
expiresAt: message.expires_at,
|
||||||
tariffName: message.tariff_name,
|
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: ['balance'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
||||||
refreshUser();
|
refreshUser();
|
||||||
@@ -96,7 +100,11 @@ export default function WebSocketNotifications() {
|
|||||||
amountKopeks: message.amount_kopeks,
|
amountKopeks: message.amount_kopeks,
|
||||||
expiresAt: message.new_expires_at,
|
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: ['balance'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
||||||
refreshUser();
|
refreshUser();
|
||||||
@@ -115,7 +123,7 @@ export default function WebSocketNotifications() {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
icon: <span className="text-lg">⏰</span>,
|
icon: <span className="text-lg">⏰</span>,
|
||||||
onClick: () => navigate('/subscription'),
|
onClick: () => navigate('/subscriptions'),
|
||||||
duration: 10000,
|
duration: 10000,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -130,10 +138,14 @@ export default function WebSocketNotifications() {
|
|||||||
'Your subscription has expired. Renew to continue using the service.',
|
'Your subscription has expired. Renew to continue using the service.',
|
||||||
),
|
),
|
||||||
icon: <span className="text-lg">😢</span>,
|
icon: <span className="text-lg">😢</span>,
|
||||||
onClick: () => navigate('/subscription'),
|
onClick: () => navigate('/subscriptions'),
|
||||||
duration: 10000,
|
duration: 10000,
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
queryClient.invalidateQueries({
|
||||||
|
predicate: (query) =>
|
||||||
|
Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription',
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||||||
refreshUser();
|
refreshUser();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -171,7 +183,11 @@ export default function WebSocketNotifications() {
|
|||||||
icon: <span className="text-lg">🔄</span>,
|
icon: <span className="text-lg">🔄</span>,
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
queryClient.invalidateQueries({
|
||||||
|
predicate: (query) =>
|
||||||
|
Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription',
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +199,11 @@ export default function WebSocketNotifications() {
|
|||||||
devicesAdded: message.devices_added,
|
devicesAdded: message.devices_added,
|
||||||
newDeviceLimit: message.new_device_limit,
|
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: ['balance'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['transactions'] });
|
queryClient.invalidateQueries({ queryKey: ['transactions'] });
|
||||||
@@ -199,7 +219,11 @@ export default function WebSocketNotifications() {
|
|||||||
trafficGbAdded: message.traffic_gb_added,
|
trafficGbAdded: message.traffic_gb_added,
|
||||||
newTrafficLimitGb: message.new_traffic_limit_gb,
|
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: ['balance'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['transactions'] });
|
queryClient.invalidateQueries({ queryKey: ['transactions'] });
|
||||||
@@ -222,10 +246,14 @@ export default function WebSocketNotifications() {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
icon: <span className="text-lg">🔁</span>,
|
icon: <span className="text-lg">🔁</span>,
|
||||||
onClick: () => navigate('/subscription'),
|
onClick: () => navigate('/subscriptions'),
|
||||||
duration: 8000,
|
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: ['balance'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
||||||
refreshUser();
|
refreshUser();
|
||||||
@@ -240,7 +268,7 @@ export default function WebSocketNotifications() {
|
|||||||
message.reason ||
|
message.reason ||
|
||||||
t('wsNotifications.autopay.failedMessage', 'Failed to auto-renew your subscription'),
|
t('wsNotifications.autopay.failedMessage', 'Failed to auto-renew your subscription'),
|
||||||
icon: <span className="text-lg">❌</span>,
|
icon: <span className="text-lg">❌</span>,
|
||||||
onClick: () => navigate('/subscription'),
|
onClick: () => navigate('/subscriptions'),
|
||||||
duration: 10000,
|
duration: 10000,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings';
|
import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings';
|
||||||
import { StarIcon } from './icons';
|
import { StarIcon } from './icons';
|
||||||
import { SettingRow } from './SettingRow';
|
import { SettingsTableRow } from './SettingsTableRow';
|
||||||
|
|
||||||
interface FavoritesTabProps {
|
interface FavoritesTabProps {
|
||||||
settings: SettingDefinition[];
|
settings: SettingDefinition[];
|
||||||
@@ -42,9 +42,9 @@ export function FavoritesTab({ settings, isFavorite, toggleFavorite }: Favorites
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
<div className="overflow-hidden rounded-xl border border-dark-700/40">
|
||||||
{settings.map((setting) => (
|
{settings.map((setting, idx) => (
|
||||||
<SettingRow
|
<SettingsTableRow
|
||||||
key={setting.key}
|
key={setting.key}
|
||||||
setting={setting}
|
setting={setting}
|
||||||
isFavorite={isFavorite(setting.key)}
|
isFavorite={isFavorite(setting.key)}
|
||||||
@@ -53,6 +53,7 @@ export function FavoritesTab({ settings, isFavorite, toggleFavorite }: Favorites
|
|||||||
onReset={() => resetSettingMutation.mutate(setting.key)}
|
onReset={() => resetSettingMutation.mutate(setting.key)}
|
||||||
isUpdating={updateSettingMutation.isPending}
|
isUpdating={updateSettingMutation.isPending}
|
||||||
isResetting={resetSettingMutation.isPending}
|
isResetting={resetSettingMutation.isPending}
|
||||||
|
isLast={idx === settings.length - 1}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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
|
<button
|
||||||
onClick={handleStart}
|
onClick={handleStart}
|
||||||
disabled={disabled}
|
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="flex-1 truncate">{currentValue || '-'}</span>
|
||||||
<span className="text-dark-500 opacity-0 transition-colors group-hover:text-accent-400 group-hover:opacity-100">
|
<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 { useTranslation } from 'react-i18next';
|
||||||
import { useRef, useEffect } from 'react';
|
import { useRef, useEffect, useState } from 'react';
|
||||||
import { MENU_SECTIONS } from './constants';
|
import { SETTINGS_TREE } from './constants';
|
||||||
import { StarIcon } from './icons';
|
import { StarIcon } from './icons';
|
||||||
|
|
||||||
interface SettingsMobileTabsProps {
|
interface SettingsMobileTabsProps {
|
||||||
@@ -17,6 +17,7 @@ export function SettingsMobileTabs({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const activeRef = useRef<HTMLButtonElement>(null);
|
const activeRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const [expandedGroup, setExpandedGroup] = useState<string | null>(null);
|
||||||
|
|
||||||
// Scroll active tab into view
|
// Scroll active tab into view
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -26,53 +27,157 @@ export function SettingsMobileTabs({
|
|||||||
const containerRect = container.getBoundingClientRect();
|
const containerRect = container.getBoundingClientRect();
|
||||||
const activeRect = activeEl.getBoundingClientRect();
|
const activeRect = activeEl.getBoundingClientRect();
|
||||||
|
|
||||||
// Check if active element is not fully visible
|
|
||||||
if (activeRect.left < containerRect.left || activeRect.right > containerRect.right) {
|
if (activeRect.left < containerRect.left || activeRect.right > containerRect.right) {
|
||||||
activeEl.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
|
activeEl.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [activeSection]);
|
}, [activeSection]);
|
||||||
|
|
||||||
// Flatten all items from all sections
|
// Auto-expand the group containing the active section
|
||||||
const allItems = MENU_SECTIONS.flatMap((section) => section.items);
|
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 (
|
return (
|
||||||
<div
|
<div>
|
||||||
ref={scrollRef}
|
{/* Level 1: Favorites + special items + group chips */}
|
||||||
className="scrollbar-hide flex gap-2 overflow-x-auto px-3 py-3"
|
<div
|
||||||
style={{ WebkitOverflowScrolling: 'touch' }}
|
ref={scrollRef}
|
||||||
>
|
className="scrollbar-hide flex gap-2 overflow-x-auto px-3 py-3"
|
||||||
{allItems.map((item) => {
|
style={{ WebkitOverflowScrolling: 'touch' }}
|
||||||
const isActive = activeSection === item.id;
|
>
|
||||||
const hasIcon = item.iconType === 'star';
|
{/* Favorites chip */}
|
||||||
|
<button
|
||||||
|
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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<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 ${
|
||||||
|
isFavoritesActive
|
||||||
|
? 'bg-accent-500/20 text-accent-400'
|
||||||
|
: 'bg-warning-500/20 text-warning-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{favoritesCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
return (
|
{/* Special item chips (branding, theme, analytics, buttons) */}
|
||||||
<button
|
{specialItems.map((item) => {
|
||||||
key={item.id}
|
const isActive = isSpecialActive(item.id);
|
||||||
ref={isActive ? activeRef : null}
|
return (
|
||||||
onClick={() => setActiveSection(item.id)}
|
<button
|
||||||
className={`flex shrink-0 items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium transition-all ${
|
key={item.id}
|
||||||
isActive
|
ref={isActive ? activeRef : null}
|
||||||
? 'bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/30'
|
onClick={() => {
|
||||||
: 'bg-dark-800/50 text-dark-400 active:bg-dark-700'
|
setActiveSection(item.id);
|
||||||
}`}
|
setExpandedGroup(null);
|
||||||
>
|
}}
|
||||||
{hasIcon && <StarIcon filled={isActive && item.id === 'favorites'} />}
|
className={`flex shrink-0 items-center gap-2 rounded-xl px-4 py-3 text-sm font-medium transition-all ${
|
||||||
<span className="whitespace-nowrap">{t(`admin.settings.${item.id}`)}</span>
|
isActive
|
||||||
{item.id === 'favorites' && favoritesCount > 0 && (
|
? 'bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/30'
|
||||||
<span
|
: 'bg-dark-800/50 text-dark-400 active:bg-dark-700'
|
||||||
className={`rounded-full px-1.5 py-0.5 text-xs ${
|
}`}
|
||||||
isActive
|
>
|
||||||
? 'bg-accent-500/20 text-accent-400'
|
{item.icon && <span className="text-sm">{item.icon}</span>}
|
||||||
: 'bg-warning-500/20 text-warning-400'
|
<span className="whitespace-nowrap">{t(`admin.settings.${item.id}`)}</span>
|
||||||
}`}
|
</button>
|
||||||
>
|
);
|
||||||
{favoritesCount}
|
})}
|
||||||
</span>
|
|
||||||
)}
|
{/* Group chips */}
|
||||||
</button>
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ export function SettingsSearchMobile({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative mt-3 sm:hidden">
|
<div ref={containerRef} className="relative mt-3 lg:hidden">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings';
|
import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings';
|
||||||
import { ChevronDownIcon } from './icons';
|
import { QuickToggles } from './QuickToggles';
|
||||||
import { SettingRow } from './SettingRow';
|
import { SettingsTableRow } from './SettingsTableRow';
|
||||||
|
|
||||||
interface CategoryGroup {
|
interface CategoryGroup {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -29,20 +28,6 @@ export function SettingsTab({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
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({
|
const updateSettingMutation = useMutation({
|
||||||
mutationFn: ({ key, value }: { key: string; value: string }) =>
|
mutationFn: ({ key, value }: { key: string; value: string }) =>
|
||||||
adminSettingsApi.updateSetting(key, value),
|
adminSettingsApi.updateSetting(key, value),
|
||||||
@@ -58,92 +43,80 @@ export function SettingsTab({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// If searching, show flat list
|
// Search mode: flat list of filtered results
|
||||||
if (searchQuery) {
|
if (searchQuery.trim()) {
|
||||||
|
if (filteredSettings.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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="overflow-hidden rounded-xl border border-dark-700/40">
|
||||||
{filteredSettings.length === 0 ? (
|
{filteredSettings.map((setting, idx) => (
|
||||||
<div className="rounded-2xl border border-dark-700/30 bg-dark-800/30 p-12 text-center">
|
<SettingsTableRow
|
||||||
<p className="text-dark-400">{t('admin.settings.noSettings')}</p>
|
key={setting.key}
|
||||||
</div>
|
setting={setting}
|
||||||
) : (
|
isFavorite={isFavorite(setting.key)}
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
onToggleFavorite={() => toggleFavorite(setting.key)}
|
||||||
{filteredSettings.map((setting) => (
|
onUpdate={(value) => updateSettingMutation.mutate({ key: setting.key, value })}
|
||||||
<SettingRow
|
onReset={() => resetSettingMutation.mutate(setting.key)}
|
||||||
key={setting.key}
|
isUpdating={updateSettingMutation.isPending}
|
||||||
setting={setting}
|
isResetting={resetSettingMutation.isPending}
|
||||||
isFavorite={isFavorite(setting.key)}
|
isLast={idx === filteredSettings.length - 1}
|
||||||
onToggleFavorite={() => toggleFavorite(setting.key)}
|
/>
|
||||||
onUpdate={(value) => updateSettingMutation.mutate({ key: setting.key, value })}
|
))}
|
||||||
onReset={() => resetSettingMutation.mutate(setting.key)}
|
|
||||||
isUpdating={updateSettingMutation.isPending}
|
|
||||||
isResetting={resetSettingMutation.isPending}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show accordion for subcategories
|
// Normal mode: QuickToggles + settings by category
|
||||||
return (
|
const allCategorySettings = categories.flatMap((c) => c.settings);
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Accordion content */}
|
if (allCategorySettings.length === 0) {
|
||||||
{isExpanded && (
|
return (
|
||||||
<div className="border-t border-dark-700/30 p-4 pt-0">
|
<div className="rounded-xl border border-dark-700/30 bg-dark-800/30 p-12 text-center">
|
||||||
<div className="grid grid-cols-1 gap-4 pt-4 lg:grid-cols-2">
|
<p className="text-dark-400">{t('admin.settings.noSettings')}</p>
|
||||||
{cat.settings.map((setting) => (
|
</div>
|
||||||
<SettingRow
|
);
|
||||||
key={setting.key}
|
}
|
||||||
setting={setting}
|
|
||||||
isFavorite={isFavorite(setting.key)}
|
return (
|
||||||
onToggleFavorite={() => toggleFavorite(setting.key)}
|
<div>
|
||||||
onUpdate={(value) =>
|
<QuickToggles
|
||||||
updateSettingMutation.mutate({ key: setting.key, value })
|
settings={allCategorySettings}
|
||||||
}
|
onUpdate={(key, value) => updateSettingMutation.mutate({ key, value })}
|
||||||
onReset={() => resetSettingMutation.mutate(setting.key)}
|
disabled={updateSettingMutation.isPending}
|
||||||
isUpdating={updateSettingMutation.isPending}
|
/>
|
||||||
isResetting={resetSettingMutation.isPending}
|
{categories.map((category) => {
|
||||||
/>
|
if (category.settings.length === 0) return null;
|
||||||
))}
|
return (
|
||||||
</div>
|
<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>
|
||||||
)}
|
)}
|
||||||
|
<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 })}
|
||||||
|
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>
|
</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';
|
import { ThemeColors, DEFAULT_THEME_COLORS } from '../../types/theme';
|
||||||
|
|
||||||
// Menu item types
|
// Tree sidebar types
|
||||||
export interface MenuItem {
|
export interface TreeSubItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
categories: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TreeGroup {
|
||||||
|
id: string;
|
||||||
|
icon: string;
|
||||||
|
children: TreeSubItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpecialItem {
|
||||||
|
id: string;
|
||||||
|
icon?: string;
|
||||||
iconType?: 'star' | null;
|
iconType?: 'star' | null;
|
||||||
categories?: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MenuSection {
|
export interface SettingsTreeConfig {
|
||||||
id: string;
|
specialItems: SpecialItem[];
|
||||||
items: MenuItem[];
|
groups: TreeGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sidebar menu configuration
|
// Hierarchical settings tree — all 61 backend category keys mapped into 7 groups
|
||||||
export const MENU_SECTIONS: MenuSection[] = [
|
export const SETTINGS_TREE: SettingsTreeConfig = {
|
||||||
{
|
specialItems: [
|
||||||
id: 'main',
|
{ id: 'favorites', iconType: 'star' },
|
||||||
items: [
|
{ id: 'branding', icon: '🎨' },
|
||||||
{ id: 'favorites', iconType: 'star' },
|
{ id: 'theme', icon: '🌈' },
|
||||||
{ id: 'branding', iconType: null },
|
{ id: 'analytics', icon: '📊' },
|
||||||
{ id: 'theme', iconType: null },
|
{ id: 'buttons', icon: '📱' },
|
||||||
{ id: 'analytics', iconType: null },
|
],
|
||||||
{ id: 'buttons', iconType: null },
|
groups: [
|
||||||
],
|
{
|
||||||
},
|
id: 'payments',
|
||||||
{
|
icon: '💳',
|
||||||
id: 'settings',
|
children: [
|
||||||
items: [
|
{ id: 'payments_general', categories: ['PAYMENT', 'PAYMENT_VERIFICATION'] },
|
||||||
{
|
{ id: 'payments_stars', categories: ['TELEGRAM'] },
|
||||||
id: 'payments',
|
{ id: 'payments_yookassa', categories: ['YOOKASSA'] },
|
||||||
iconType: null,
|
{ id: 'payments_cryptobot', categories: ['CRYPTOBOT'] },
|
||||||
categories: [
|
{ id: 'payments_cloudpayments', categories: ['CLOUDPAYMENTS'] },
|
||||||
'PAYMENT',
|
{ id: 'payments_freekassa', categories: ['FREEKASSA'] },
|
||||||
'PAYMENT_VERIFICATION',
|
{ id: 'payments_kassa_ai', categories: ['KASSA_AI'] },
|
||||||
'YOOKASSA',
|
{ id: 'payments_platega', categories: ['PLATEGA'] },
|
||||||
'CRYPTOBOT',
|
{ id: 'payments_pal24', categories: ['PAL24'] },
|
||||||
'HELEKET',
|
{ id: 'payments_heleket', categories: ['HELEKET'] },
|
||||||
'PLATEGA',
|
{ id: 'payments_mulenpay', categories: ['MULENPAY'] },
|
||||||
'TRIBUTE',
|
{ id: 'payments_tribute', categories: ['TRIBUTE'] },
|
||||||
'MULENPAY',
|
{ id: 'payments_wata', categories: ['WATA'] },
|
||||||
'PAL24',
|
{ id: 'payments_riopay', categories: ['RIOPAY'] },
|
||||||
'WATA',
|
{ id: 'payments_severpay', categories: ['SEVERPAY'] },
|
||||||
'TELEGRAM',
|
],
|
||||||
],
|
},
|
||||||
},
|
{
|
||||||
{
|
id: 'subscriptions',
|
||||||
id: 'subscriptions',
|
icon: '📦',
|
||||||
iconType: null,
|
children: [
|
||||||
categories: [
|
{ id: 'subs_core', categories: ['SUBSCRIPTIONS_CORE'] },
|
||||||
'SUBSCRIPTIONS_CORE',
|
{ id: 'subs_trial', categories: ['TRIAL'] },
|
||||||
'SIMPLE_SUBSCRIPTION',
|
{ id: 'subs_pricing', categories: ['SUBSCRIPTION_PRICES'] },
|
||||||
'PERIODS',
|
{ id: 'subs_periods', categories: ['PERIODS'] },
|
||||||
'SUBSCRIPTION_PRICES',
|
{ id: 'subs_traffic', categories: ['TRAFFIC', 'TRAFFIC_PACKAGES'] },
|
||||||
'TRAFFIC',
|
{ id: 'subs_simple', categories: ['SIMPLE_SUBSCRIPTION'] },
|
||||||
'TRAFFIC_PACKAGES',
|
{ id: 'subs_autopay', categories: ['AUTOPAY'] },
|
||||||
'TRIAL',
|
],
|
||||||
'AUTOPAY',
|
},
|
||||||
],
|
{
|
||||||
},
|
id: 'interface',
|
||||||
{
|
icon: '🖥️',
|
||||||
id: 'interface',
|
children: [
|
||||||
iconType: null,
|
{
|
||||||
categories: [
|
id: 'iface_general',
|
||||||
'INTERFACE',
|
categories: ['INTERFACE', 'INTERFACE_BRANDING', 'INTERFACE_SUBSCRIPTION'],
|
||||||
'INTERFACE_BRANDING',
|
},
|
||||||
'INTERFACE_SUBSCRIPTION',
|
{ id: 'iface_connect', categories: ['CONNECT_BUTTON'] },
|
||||||
'CONNECT_BUTTON',
|
{ id: 'iface_miniapp', categories: ['MINIAPP'] },
|
||||||
'MINIAPP',
|
{ id: 'iface_happ', categories: ['HAPP'] },
|
||||||
'TELEGRAM_WIDGET',
|
{ id: 'iface_widget', categories: ['TELEGRAM_WIDGET'] },
|
||||||
'TELEGRAM_OIDC',
|
{ id: 'iface_oidc', categories: ['TELEGRAM_OIDC'] },
|
||||||
'HAPP',
|
{ id: 'iface_skip', categories: ['SKIP'] },
|
||||||
'SKIP',
|
{ id: 'iface_additional', categories: ['ADDITIONAL'] },
|
||||||
'ADDITIONAL',
|
],
|
||||||
],
|
},
|
||||||
},
|
{
|
||||||
{
|
id: 'users',
|
||||||
id: 'notifications',
|
icon: '👥',
|
||||||
iconType: null,
|
children: [
|
||||||
categories: ['NOTIFICATIONS', 'ADMIN_NOTIFICATIONS', 'ADMIN_REPORTS'],
|
{ id: 'users_support', categories: ['SUPPORT'] },
|
||||||
},
|
{ id: 'users_referral', categories: ['REFERRAL'] },
|
||||||
{ id: 'database', iconType: null, categories: ['DATABASE', 'POSTGRES', 'SQLITE', 'REDIS'] },
|
{ id: 'users_channel', categories: ['CHANNEL'] },
|
||||||
{
|
{ id: 'users_localization', categories: ['LOCALIZATION', 'TIMEZONE'] },
|
||||||
id: 'system',
|
{ id: 'users_moderation', categories: ['MODERATION', 'BAN_NOTIFICATIONS'] },
|
||||||
iconType: null,
|
],
|
||||||
categories: [
|
},
|
||||||
'CORE',
|
{
|
||||||
'REMNAWAVE',
|
id: 'notifications',
|
||||||
'SERVER_STATUS',
|
icon: '🔔',
|
||||||
'MONITORING',
|
children: [
|
||||||
'MAINTENANCE',
|
{ id: 'notif_user', categories: ['NOTIFICATIONS', 'WEBHOOK_NOTIFICATIONS'] },
|
||||||
'BACKUP',
|
{ id: 'notif_admin', categories: ['ADMIN_NOTIFICATIONS'] },
|
||||||
'VERSION',
|
{ id: 'notif_reports', categories: ['ADMIN_REPORTS'] },
|
||||||
'WEB_API',
|
],
|
||||||
'WEBHOOK',
|
},
|
||||||
'LOG',
|
{
|
||||||
'DEBUG',
|
id: 'database',
|
||||||
'EXTERNAL_ADMIN',
|
icon: '🗄️',
|
||||||
],
|
children: [
|
||||||
},
|
{ id: 'db_general', categories: ['DATABASE'] },
|
||||||
{
|
{ id: 'db_postgres', categories: ['POSTGRES'] },
|
||||||
id: 'users',
|
{ id: 'db_sqlite', categories: ['SQLITE'] },
|
||||||
iconType: null,
|
{ id: 'db_redis', categories: ['REDIS'] },
|
||||||
categories: ['SUPPORT', 'LOCALIZATION', 'CHANNEL', 'TIMEZONE', 'REFERRAL', 'MODERATION'],
|
],
|
||||||
},
|
},
|
||||||
],
|
{
|
||||||
},
|
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
|
// Theme preset type
|
||||||
export interface ThemePreset {
|
export interface ThemePreset {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export * from './icons';
|
|||||||
export * from './Toggle';
|
export * from './Toggle';
|
||||||
export * from './SettingInput';
|
export * from './SettingInput';
|
||||||
export * from './SettingRow';
|
export * from './SettingRow';
|
||||||
|
export * from './SettingsTableRow';
|
||||||
export * from './AnalyticsTab';
|
export * from './AnalyticsTab';
|
||||||
export * from './BrandingTab';
|
export * from './BrandingTab';
|
||||||
export * from './ButtonsTab';
|
export * from './ButtonsTab';
|
||||||
@@ -13,6 +14,8 @@ export * from './FavoritesTab';
|
|||||||
export * from './SettingsTab';
|
export * from './SettingsTab';
|
||||||
export * from './SettingsMobileTabs';
|
export * from './SettingsMobileTabs';
|
||||||
export * from './SettingsSearch';
|
export * from './SettingsSearch';
|
||||||
|
export * from './SettingsTreeSidebar';
|
||||||
|
export * from './QuickToggles';
|
||||||
export * from './LocaleTabs';
|
export * from './LocaleTabs';
|
||||||
export * from './LocalizedInput';
|
export * from './LocalizedInput';
|
||||||
|
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ export default function SubscriptionCardActive({
|
|||||||
haptic.notification('error');
|
haptic.notification('error');
|
||||||
return;
|
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' : ''}`}
|
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"
|
data-onboarding="connect-devices"
|
||||||
@@ -312,7 +312,7 @@ export default function SubscriptionCardActive({
|
|||||||
<div className="mb-5 flex gap-2.5">
|
<div className="mb-5 flex gap-2.5">
|
||||||
{/* Tariff badge — clickable */}
|
{/* Tariff badge — clickable */}
|
||||||
<Link
|
<Link
|
||||||
to="/subscription"
|
to={`/subscriptions/${subscription.id}`}
|
||||||
className="flex-1 rounded-[14px] p-3.5 transition-all duration-500"
|
className="flex-1 rounded-[14px] p-3.5 transition-all duration-500"
|
||||||
style={{
|
style={{
|
||||||
background: `linear-gradient(135deg, rgba(${zone.mainVarRaw}, 0.07), rgba(${zone.mainVarRaw}, 0.02))`,
|
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')}
|
{trafficRefreshCooldown > 0 ? `${trafficRefreshCooldown}s` : t('common.refresh')}
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
to="/subscription"
|
to={`/subscriptions/${subscription.id}`}
|
||||||
className="text-[11px] font-medium text-dark-50/25 transition-colors hover:text-dark-50/40"
|
className="text-[11px] font-medium text-dark-50/25 transition-colors hover:text-dark-50/40"
|
||||||
>
|
>
|
||||||
{t('dashboard.viewSubscription')} →
|
{t('dashboard.viewSubscription')} →
|
||||||
|
|||||||
@@ -57,15 +57,18 @@ export default function SubscriptionCardExpired({
|
|||||||
try {
|
try {
|
||||||
if (isDisabledDaily) {
|
if (isDisabledDaily) {
|
||||||
// Resume daily subscription via toggle pause endpoint
|
// Resume daily subscription via toggle pause endpoint
|
||||||
await subscriptionApi.togglePause();
|
await subscriptionApi.togglePause(subscription.id);
|
||||||
} else if (isDaily && subscription.tariff_id) {
|
} else if (isDaily && subscription.tariff_id) {
|
||||||
// Expired daily tariff — purchase for 1 day
|
// Expired daily tariff — purchase for 1 day
|
||||||
await subscriptionApi.purchaseTariff(subscription.tariff_id, 1);
|
await subscriptionApi.purchaseTariff(subscription.tariff_id, 1);
|
||||||
} else {
|
} else {
|
||||||
await subscriptionApi.renewSubscription(30);
|
await subscriptionApi.renewSubscription(30, subscription.id);
|
||||||
}
|
}
|
||||||
haptic.success();
|
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: ['balance'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -257,7 +260,7 @@ export default function SubscriptionCardExpired({
|
|||||||
<div className="flex gap-2.5">
|
<div className="flex gap-2.5">
|
||||||
{isLimited ? (
|
{isLimited ? (
|
||||||
<Link
|
<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"
|
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={{
|
style={{
|
||||||
background: accent.gradient,
|
background: accent.gradient,
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export function AppHeader({
|
|||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
|
{ 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 },
|
{ path: '/balance', label: t('nav.balance'), icon: WalletIcon },
|
||||||
...(referralEnabled ? [{ path: '/referral', label: t('nav.referral'), icon: UsersIcon }] : []),
|
...(referralEnabled ? [{ path: '/referral', label: t('nav.referral'), icon: UsersIcon }] : []),
|
||||||
{ path: '/support', label: t('nav.support'), icon: ChatIcon },
|
{ path: '/support', label: t('nav.support'), icon: ChatIcon },
|
||||||
|
|||||||
@@ -221,6 +221,11 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const [isKeyboardOpen, setIsKeyboardOpen] = 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
|
// Keyboard detection for hiding bottom nav
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleFocusIn = (e: FocusEvent) => {
|
const handleFocusIn = (e: FocusEvent) => {
|
||||||
@@ -254,7 +259,7 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
// Desktop navigation items
|
// Desktop navigation items
|
||||||
const desktopNavItems = [
|
const desktopNavItems = [
|
||||||
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
|
{ 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: '/balance', label: t('nav.balance'), icon: CreditCardIcon },
|
||||||
{ path: '/support', label: t('nav.support'), icon: ChatIcon },
|
{ path: '/support', label: t('nav.support'), icon: ChatIcon },
|
||||||
{ path: '/info', label: t('nav.info'), icon: InfoIcon },
|
{ path: '/info', label: t('nav.info'), icon: InfoIcon },
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function DesktopSidebar({
|
|||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
|
{ 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 },
|
{ path: '/balance', label: t('nav.balance'), icon: WalletIcon },
|
||||||
...(referralEnabled ? [{ path: '/referral', label: t('nav.referral'), icon: UsersIcon }] : []),
|
...(referralEnabled ? [{ path: '/referral', label: t('nav.referral'), icon: UsersIcon }] : []),
|
||||||
{ path: '/support', label: t('nav.support'), icon: ChatIcon },
|
{ 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)
|
// When wheel is enabled, it replaces Support in the bottom nav (Support is still accessible via hamburger menu)
|
||||||
const coreItems = [
|
const coreItems = [
|
||||||
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
|
{ 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 },
|
{ path: '/balance', label: t('nav.balance'), icon: WalletIcon },
|
||||||
...(referralEnabled ? [{ path: '/referral', label: t('nav.referral'), icon: UsersIcon }] : []),
|
...(referralEnabled ? [{ path: '/referral', label: t('nav.referral'), icon: UsersIcon }] : []),
|
||||||
...(wheelEnabled
|
...(wheelEnabled
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export function CommandPalette({
|
|||||||
// Navigation items
|
// Navigation items
|
||||||
const navigationItems = [
|
const navigationItems = [
|
||||||
{ label: t('nav.dashboard'), icon: HomeIcon, path: '/' },
|
{ 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' },
|
{ label: t('nav.balance'), icon: WalletIcon, path: '/balance' },
|
||||||
...(referralEnabled ? [{ label: t('nav.referral'), icon: UsersIcon, path: '/referral' }] : []),
|
...(referralEnabled ? [{ label: t('nav.referral'), icon: UsersIcon, path: '/referral' }] : []),
|
||||||
{ label: t('nav.support'), icon: ChatIcon, path: '/support' },
|
{ label: t('nav.support'), icon: ChatIcon, path: '/support' },
|
||||||
@@ -100,7 +100,7 @@ export function CommandPalette({
|
|||||||
{
|
{
|
||||||
label: t('subscription.get_config') || 'Get VPN config',
|
label: t('subscription.get_config') || 'Get VPN config',
|
||||||
icon: DownloadIcon,
|
icon: DownloadIcon,
|
||||||
action: () => navigate('/subscription'),
|
action: () => navigate('/subscriptions'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode',
|
label: isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode',
|
||||||
|
|||||||
@@ -369,7 +369,7 @@ export default function NewsSection() {
|
|||||||
gcTime: 10 * 60_000,
|
gcTime: 10 * 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const items = data?.items ?? [];
|
const items = useMemo(() => data?.items ?? [], [data?.items]);
|
||||||
const total = data?.total ?? 0;
|
const total = data?.total ?? 0;
|
||||||
const categories = data?.categories ?? [];
|
const categories = data?.categories ?? [];
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,22 @@ import type { Subscription } from '../../types';
|
|||||||
|
|
||||||
interface PurchaseCTAButtonProps {
|
interface PurchaseCTAButtonProps {
|
||||||
subscription: Subscription | null;
|
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 { t } = useTranslation();
|
||||||
|
|
||||||
const isExpired = !subscription || (!subscription.is_active && !subscription.is_trial);
|
const isExpired = !subscription || (!subscription.is_active && !subscription.is_trial);
|
||||||
const isTrial = 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))';
|
const accentColor = isExpired ? '#FF3B5C' : 'rgb(var(--color-accent-400))';
|
||||||
|
|
||||||
@@ -25,10 +34,21 @@ export default function PurchaseCTAButton({ subscription }: PurchaseCTAButtonPro
|
|||||||
? t('subscription.cta.expiredHint')
|
? t('subscription.cta.expiredHint')
|
||||||
: isTrial
|
: isTrial
|
||||||
? t('subscription.cta.trialHint')
|
? t('subscription.cta.trialHint')
|
||||||
: t('subscription.cta.activeHint');
|
: 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 (
|
return (
|
||||||
<Link to="/subscription/purchase" className="block">
|
<Link to={linkTo} className="block">
|
||||||
<HoverBorderGradient
|
<HoverBorderGradient
|
||||||
accentColor={accentColor}
|
accentColor={accentColor}
|
||||||
duration={4}
|
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: {
|
react: {
|
||||||
useSuspense: false,
|
useSuspense: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
showSupportNotice: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load detected language + fallback on startup
|
// Load detected language + fallback on startup
|
||||||
|
|||||||
@@ -316,7 +316,8 @@
|
|||||||
"trafficReset": {
|
"trafficReset": {
|
||||||
"DAY": "Resets daily",
|
"DAY": "Resets daily",
|
||||||
"WEEK": "Resets weekly",
|
"WEEK": "Resets weekly",
|
||||||
"MONTH": "Resets monthly"
|
"MONTH": "Resets monthly",
|
||||||
|
"MONTH_ROLLING": "Resets every 30 days"
|
||||||
},
|
},
|
||||||
"traffic": "Traffic",
|
"traffic": "Traffic",
|
||||||
"unlimited": "Unlimited",
|
"unlimited": "Unlimited",
|
||||||
@@ -1116,6 +1117,17 @@
|
|||||||
"panel": {
|
"panel": {
|
||||||
"title": "Admin Panel",
|
"title": "Admin Panel",
|
||||||
"subtitle": "System management",
|
"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",
|
"dashboardDesc": "Statistics and system monitoring",
|
||||||
"ticketsDesc": "Handle user support tickets",
|
"ticketsDesc": "Handle user support tickets",
|
||||||
"settingsDesc": "System settings and parameters",
|
"settingsDesc": "System settings and parameters",
|
||||||
@@ -1481,16 +1493,26 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"overview": "Overview",
|
"overview": "Overview",
|
||||||
"nodes": "Nodes",
|
"nodes": "Nodes",
|
||||||
|
"traffic": "Traffic",
|
||||||
"squads": "Squads",
|
"squads": "Squads",
|
||||||
"sync": "Sync"
|
"sync": "Sync"
|
||||||
},
|
},
|
||||||
|
"traffic": {
|
||||||
|
"noData": "No traffic data available",
|
||||||
|
"totalDownload": "Download",
|
||||||
|
"totalUpload": "Upload",
|
||||||
|
"totalTraffic": "Total",
|
||||||
|
"online": "online",
|
||||||
|
"inbounds": "Inbounds",
|
||||||
|
"outbounds": "Outbounds"
|
||||||
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"system": "System",
|
"system": "System",
|
||||||
"usersOnline": "Online",
|
"usersOnline": "Online",
|
||||||
"totalUsers": "Total Users",
|
"totalUsers": "Total Users",
|
||||||
"nodesOnline": "Nodes Online",
|
"nodesOnline": "Nodes Online",
|
||||||
"connections": "Connections",
|
"connections": "Connections",
|
||||||
"bandwidth": "Realtime Bandwidth",
|
"bandwidth": "Inbound Traffic",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"upload": "Upload",
|
"upload": "Upload",
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
@@ -1814,8 +1836,11 @@
|
|||||||
"title": "System Settings",
|
"title": "System Settings",
|
||||||
"allCategories": "All categories",
|
"allCategories": "All categories",
|
||||||
"noSettings": "No settings found",
|
"noSettings": "No settings found",
|
||||||
|
"quickToggles": "Quick Toggles",
|
||||||
"modified": "Modified",
|
"modified": "Modified",
|
||||||
"readOnly": "Read only",
|
"readOnly": "Read only",
|
||||||
|
"badgeDb": "DB",
|
||||||
|
"badgeEnv": "ENV",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
"categoriesCount": "categories",
|
"categoriesCount": "categories",
|
||||||
"settingsCount": "settings",
|
"settingsCount": "settings",
|
||||||
@@ -1888,6 +1913,84 @@
|
|||||||
"quickPresets": "Quick presets",
|
"quickPresets": "Quick presets",
|
||||||
"resetAllColors": "Reset all colors",
|
"resetAllColors": "Reset all colors",
|
||||||
"statusColors": "Status 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": {
|
"categories": {
|
||||||
"TELEGRAM_WIDGET": "Telegram Login Widget",
|
"TELEGRAM_WIDGET": "Telegram Login Widget",
|
||||||
"TELEGRAM_OIDC": "Telegram Login (OIDC)"
|
"TELEGRAM_OIDC": "Telegram Login (OIDC)"
|
||||||
@@ -2198,6 +2301,8 @@
|
|||||||
"resetModeWeeklyDesc": "Reset every week",
|
"resetModeWeeklyDesc": "Reset every week",
|
||||||
"resetModeMonthly": "Monthly",
|
"resetModeMonthly": "Monthly",
|
||||||
"resetModeMonthlyDesc": "Reset every month",
|
"resetModeMonthlyDesc": "Reset every month",
|
||||||
|
"resetModeMonthRolling": "Rolling month",
|
||||||
|
"resetModeMonthRollingDesc": "Reset every 30 days from first connection",
|
||||||
"resetModeNever": "Never",
|
"resetModeNever": "Never",
|
||||||
"resetModeNeverDesc": "Traffic is not reset",
|
"resetModeNeverDesc": "Traffic is not reset",
|
||||||
"cancelButton": "Cancel",
|
"cancelButton": "Cancel",
|
||||||
@@ -2835,7 +2940,8 @@
|
|||||||
"balance": "Balance",
|
"balance": "Balance",
|
||||||
"sync": "Synchronization",
|
"sync": "Synchronization",
|
||||||
"tickets": "Tickets",
|
"tickets": "Tickets",
|
||||||
"gifts": "Gifts"
|
"gifts": "Gifts",
|
||||||
|
"referrals": "Referrals"
|
||||||
},
|
},
|
||||||
"noTickets": "No tickets from this user",
|
"noTickets": "No tickets from this user",
|
||||||
"ticketsCount": "tickets",
|
"ticketsCount": "tickets",
|
||||||
@@ -2947,6 +3053,7 @@
|
|||||||
"recentTransactions": "Recent transactions"
|
"recentTransactions": "Recent transactions"
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
|
"selectSubscription": "Select subscription",
|
||||||
"hasDifferences": "Differences found",
|
"hasDifferences": "Differences found",
|
||||||
"synced": "Synchronized",
|
"synced": "Synchronized",
|
||||||
"bot": "Bot",
|
"bot": "Bot",
|
||||||
@@ -2994,6 +3101,28 @@
|
|||||||
"unknownUser": "Unknown",
|
"unknownUser": "Unknown",
|
||||||
"totalSent": "Total sent",
|
"totalSent": "Total sent",
|
||||||
"totalReceived": "Total received"
|
"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": {
|
"trafficReset": {
|
||||||
"DAY": "بازنشانی روزانه",
|
"DAY": "بازنشانی روزانه",
|
||||||
"WEEK": "بازنشانی هفتگی",
|
"WEEK": "بازنشانی هفتگی",
|
||||||
"MONTH": "بازنشانی ماهانه"
|
"MONTH": "بازنشانی ماهانه",
|
||||||
|
"MONTH_ROLLING": "بازنشانی هر ۳۰ روز"
|
||||||
},
|
},
|
||||||
"traffic": "ترافیک",
|
"traffic": "ترافیک",
|
||||||
"unlimited": "نامحدود",
|
"unlimited": "نامحدود",
|
||||||
@@ -950,6 +951,17 @@
|
|||||||
"panel": {
|
"panel": {
|
||||||
"title": "پنل مدیریت",
|
"title": "پنل مدیریت",
|
||||||
"subtitle": "مدیریت سیستم",
|
"subtitle": "مدیریت سیستم",
|
||||||
|
"statsUptime": "آپتایم",
|
||||||
|
"statsBot": "ربات",
|
||||||
|
"statsCabinet": "کابینت",
|
||||||
|
"statsTrials": "آزمایشی",
|
||||||
|
"statsPaid": "پولی",
|
||||||
|
"statsOnline": "آنلاین",
|
||||||
|
"statsToday": "امروز",
|
||||||
|
"searchPlaceholder": "جستجو...",
|
||||||
|
"searchEmpty": "چیزی پیدا نشد",
|
||||||
|
"searchEmptyHint": "عبارت دیگری امتحان کنید",
|
||||||
|
"searchClear": "پاک کردن جستجو",
|
||||||
"dashboardDesc": "آمار و مانیتورینگ سیستم",
|
"dashboardDesc": "آمار و مانیتورینگ سیستم",
|
||||||
"ticketsDesc": "مدیریت تیکتهای کاربران",
|
"ticketsDesc": "مدیریت تیکتهای کاربران",
|
||||||
"settingsDesc": "تنظیمات و پارامترهای سیستم",
|
"settingsDesc": "تنظیمات و پارامترهای سیستم",
|
||||||
@@ -1485,6 +1497,7 @@
|
|||||||
"title": "تنظیمات سیستم",
|
"title": "تنظیمات سیستم",
|
||||||
"allCategories": "همه دستهها",
|
"allCategories": "همه دستهها",
|
||||||
"noSettings": "تنظیماتی یافت نشد",
|
"noSettings": "تنظیماتی یافت نشد",
|
||||||
|
"quickToggles": "تغییر سریع",
|
||||||
"modified": "تغییر یافته",
|
"modified": "تغییر یافته",
|
||||||
"readOnly": "فقط خواندنی",
|
"readOnly": "فقط خواندنی",
|
||||||
"reset": "بازنشانی",
|
"reset": "بازنشانی",
|
||||||
@@ -1559,6 +1572,86 @@
|
|||||||
"quickPresets": "پیشتنظیمهای سریع",
|
"quickPresets": "پیشتنظیمهای سریع",
|
||||||
"resetAllColors": "بازنشانی همه رنگها",
|
"resetAllColors": "بازنشانی همه رنگها",
|
||||||
"statusColors": "رنگهای وضعیت",
|
"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": {
|
"categories": {
|
||||||
"TELEGRAM_WIDGET": "Telegram Login Widget",
|
"TELEGRAM_WIDGET": "Telegram Login Widget",
|
||||||
"TELEGRAM_OIDC": "Telegram Login (OIDC)"
|
"TELEGRAM_OIDC": "Telegram Login (OIDC)"
|
||||||
@@ -1853,6 +1946,8 @@
|
|||||||
"resetModeWeeklyDesc": "بازنشانی هر هفته",
|
"resetModeWeeklyDesc": "بازنشانی هر هفته",
|
||||||
"resetModeMonthly": "ماهانه",
|
"resetModeMonthly": "ماهانه",
|
||||||
"resetModeMonthlyDesc": "بازنشانی هر ماه",
|
"resetModeMonthlyDesc": "بازنشانی هر ماه",
|
||||||
|
"resetModeMonthRolling": "ماه متحرک",
|
||||||
|
"resetModeMonthRollingDesc": "بازنشانی هر ۳۰ روز از اولین اتصال",
|
||||||
"resetModeNever": "هرگز",
|
"resetModeNever": "هرگز",
|
||||||
"resetModeNeverDesc": "ترافیک بازنشانی نمیشود",
|
"resetModeNeverDesc": "ترافیک بازنشانی نمیشود",
|
||||||
"cancelButton": "لغو",
|
"cancelButton": "لغو",
|
||||||
@@ -2467,7 +2562,8 @@
|
|||||||
"balance": "موجودی",
|
"balance": "موجودی",
|
||||||
"sync": "همگامسازی",
|
"sync": "همگامسازی",
|
||||||
"tickets": "تیکتها",
|
"tickets": "تیکتها",
|
||||||
"gifts": "هدایا"
|
"gifts": "هدایا",
|
||||||
|
"referrals": "معرفیها"
|
||||||
},
|
},
|
||||||
"noTickets": "این کاربر تیکتی ندارد",
|
"noTickets": "این کاربر تیکتی ندارد",
|
||||||
"ticketsCount": "تیکت",
|
"ticketsCount": "تیکت",
|
||||||
@@ -2575,6 +2671,7 @@
|
|||||||
"recentTransactions": "تراکنشهای اخیر"
|
"recentTransactions": "تراکنشهای اخیر"
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
|
"selectSubscription": "انتخاب اشتراک",
|
||||||
"hasDifferences": "اختلاف وجود دارد",
|
"hasDifferences": "اختلاف وجود دارد",
|
||||||
"synced": "همگامسازی شده",
|
"synced": "همگامسازی شده",
|
||||||
"bot": "ربات",
|
"bot": "ربات",
|
||||||
@@ -2622,6 +2719,28 @@
|
|||||||
"unknownUser": "ناشناس",
|
"unknownUser": "ناشناس",
|
||||||
"totalSent": "کل ارسال شده",
|
"totalSent": "کل ارسال شده",
|
||||||
"totalReceived": "کل دریافت شده"
|
"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": {
|
"tabs": {
|
||||||
"overview": "نمای کلی",
|
"overview": "نمای کلی",
|
||||||
"nodes": "گرهها",
|
"nodes": "گرهها",
|
||||||
|
"traffic": "ترافیک",
|
||||||
"squads": "گروهها",
|
"squads": "گروهها",
|
||||||
"sync": "همگامسازی"
|
"sync": "همگامسازی"
|
||||||
},
|
},
|
||||||
|
"traffic": {
|
||||||
|
"noData": "داده ترافیکی موجود نیست",
|
||||||
|
"totalDownload": "دانلود",
|
||||||
|
"totalUpload": "آپلود",
|
||||||
|
"totalTraffic": "مجموع",
|
||||||
|
"online": "آنلاین",
|
||||||
|
"inbounds": "ورودیها",
|
||||||
|
"outbounds": "خروجیها"
|
||||||
|
},
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"confirmRestartAll": "آیا مطمئنید میخواهید همه گرهها را راهاندازی مجدد کنید؟",
|
"confirmRestartAll": "آیا مطمئنید میخواهید همه گرهها را راهاندازی مجدد کنید؟",
|
||||||
"disable": "غیرفعال",
|
"disable": "غیرفعال",
|
||||||
|
|||||||
@@ -331,7 +331,8 @@
|
|||||||
"trafficReset": {
|
"trafficReset": {
|
||||||
"DAY": "Сброс ежедневно",
|
"DAY": "Сброс ежедневно",
|
||||||
"WEEK": "Сброс еженедельно",
|
"WEEK": "Сброс еженедельно",
|
||||||
"MONTH": "Сброс ежемесячно"
|
"MONTH": "Сброс ежемесячно",
|
||||||
|
"MONTH_ROLLING": "Сброс раз в 30 дней"
|
||||||
},
|
},
|
||||||
"traffic": "Трафик",
|
"traffic": "Трафик",
|
||||||
"unlimited": "Безлимит",
|
"unlimited": "Безлимит",
|
||||||
@@ -1137,6 +1138,17 @@
|
|||||||
"panel": {
|
"panel": {
|
||||||
"title": "Панель администратора",
|
"title": "Панель администратора",
|
||||||
"subtitle": "Управление системой",
|
"subtitle": "Управление системой",
|
||||||
|
"statsUptime": "Аптайм",
|
||||||
|
"statsBot": "Бот",
|
||||||
|
"statsCabinet": "Кабинет",
|
||||||
|
"statsTrials": "Триалы",
|
||||||
|
"statsPaid": "Платные",
|
||||||
|
"statsOnline": "Онлайн",
|
||||||
|
"statsToday": "сегодня",
|
||||||
|
"searchPlaceholder": "Поиск...",
|
||||||
|
"searchEmpty": "Ничего не найдено",
|
||||||
|
"searchEmptyHint": "Попробуйте другой запрос",
|
||||||
|
"searchClear": "Очистить поиск",
|
||||||
"dashboardDesc": "Статистика и мониторинг системы",
|
"dashboardDesc": "Статистика и мониторинг системы",
|
||||||
"ticketsDesc": "Обработка обращений пользователей",
|
"ticketsDesc": "Обработка обращений пользователей",
|
||||||
"settingsDesc": "Настройки системы и параметры",
|
"settingsDesc": "Настройки системы и параметры",
|
||||||
@@ -1502,16 +1514,26 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"overview": "Обзор",
|
"overview": "Обзор",
|
||||||
"nodes": "Ноды",
|
"nodes": "Ноды",
|
||||||
|
"traffic": "Трафик",
|
||||||
"squads": "Сквады",
|
"squads": "Сквады",
|
||||||
"sync": "Синхронизация"
|
"sync": "Синхронизация"
|
||||||
},
|
},
|
||||||
|
"traffic": {
|
||||||
|
"noData": "Нет данных о трафике",
|
||||||
|
"totalDownload": "Загрузка",
|
||||||
|
"totalUpload": "Отдача",
|
||||||
|
"totalTraffic": "Всего",
|
||||||
|
"online": "онлайн",
|
||||||
|
"inbounds": "Inbounds",
|
||||||
|
"outbounds": "Outbounds"
|
||||||
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"system": "Система",
|
"system": "Система",
|
||||||
"usersOnline": "Онлайн",
|
"usersOnline": "Онлайн",
|
||||||
"totalUsers": "Всего пользователей",
|
"totalUsers": "Всего пользователей",
|
||||||
"nodesOnline": "Нод онлайн",
|
"nodesOnline": "Нод онлайн",
|
||||||
"connections": "Подключений",
|
"connections": "Подключений",
|
||||||
"bandwidth": "Пропускная способность",
|
"bandwidth": "Трафик по inbounds",
|
||||||
"download": "Загрузка",
|
"download": "Загрузка",
|
||||||
"upload": "Отдача",
|
"upload": "Отдача",
|
||||||
"total": "Всего",
|
"total": "Всего",
|
||||||
@@ -1839,8 +1861,11 @@
|
|||||||
"title": "Настройки системы",
|
"title": "Настройки системы",
|
||||||
"allCategories": "Все категории",
|
"allCategories": "Все категории",
|
||||||
"noSettings": "Настройки не найдены",
|
"noSettings": "Настройки не найдены",
|
||||||
|
"quickToggles": "Быстрые переключатели",
|
||||||
"modified": "Изменено",
|
"modified": "Изменено",
|
||||||
"readOnly": "Только чтение",
|
"readOnly": "Только чтение",
|
||||||
|
"badgeDb": "БД",
|
||||||
|
"badgeEnv": "ENV",
|
||||||
"reset": "Сбросить",
|
"reset": "Сбросить",
|
||||||
"categoriesCount": "категорий",
|
"categoriesCount": "категорий",
|
||||||
"settingsCount": "настроек",
|
"settingsCount": "настроек",
|
||||||
@@ -1914,6 +1939,74 @@
|
|||||||
"googleLabelPlaceholder": "Например: AbCdEfGhIjKl",
|
"googleLabelPlaceholder": "Например: AbCdEfGhIjKl",
|
||||||
"googleLabelHint": "Метка конверсии для отслеживания событий",
|
"googleLabelHint": "Метка конверсии для отслеживания событий",
|
||||||
"analyticsHint": "Счётчики автоматически встраиваются на все страницы кабинета. После сохранения ID скрипты подключаются при следующей загрузке страницы. Для удаления счётчика очистите поле и сохраните.",
|
"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": {
|
"presets": {
|
||||||
"standard": "Стандарт",
|
"standard": "Стандарт",
|
||||||
"ocean": "Океан",
|
"ocean": "Океан",
|
||||||
@@ -2162,6 +2255,8 @@
|
|||||||
"Devices Selection Enabled": "Выбор устройств",
|
"Devices Selection Enabled": "Выбор устройств",
|
||||||
"Max Devices Limit": "Макс. устройств",
|
"Max Devices Limit": "Макс. устройств",
|
||||||
"Price Per Device": "Цена за устройство",
|
"Price Per Device": "Цена за устройство",
|
||||||
|
"Multi Tariff Enabled": "Мультитарифный режим",
|
||||||
|
"Max Active Subscriptions": "Макс. подписок",
|
||||||
"Sales Mode": "Режим продаж",
|
"Sales Mode": "Режим продаж",
|
||||||
"Available Renewal Periods": "Периоды продления",
|
"Available Renewal Periods": "Периоды продления",
|
||||||
"Available Subscription Periods": "Периоды подписки",
|
"Available Subscription Periods": "Периоды подписки",
|
||||||
@@ -2712,6 +2807,8 @@
|
|||||||
"resetModeWeeklyDesc": "Сброс каждую неделю",
|
"resetModeWeeklyDesc": "Сброс каждую неделю",
|
||||||
"resetModeMonthly": "Ежемесячно",
|
"resetModeMonthly": "Ежемесячно",
|
||||||
"resetModeMonthlyDesc": "Сброс каждый месяц",
|
"resetModeMonthlyDesc": "Сброс каждый месяц",
|
||||||
|
"resetModeMonthRolling": "Скользящий месяц",
|
||||||
|
"resetModeMonthRollingDesc": "Сброс через 30 дней от первого подключения",
|
||||||
"resetModeNever": "Никогда",
|
"resetModeNever": "Никогда",
|
||||||
"resetModeNeverDesc": "Трафик не сбрасывается",
|
"resetModeNeverDesc": "Трафик не сбрасывается",
|
||||||
"cancelButton": "Отмена",
|
"cancelButton": "Отмена",
|
||||||
@@ -3357,7 +3454,8 @@
|
|||||||
"balance": "Баланс",
|
"balance": "Баланс",
|
||||||
"sync": "Синхронизация",
|
"sync": "Синхронизация",
|
||||||
"tickets": "Тикеты",
|
"tickets": "Тикеты",
|
||||||
"gifts": "Подарки"
|
"gifts": "Подарки",
|
||||||
|
"referrals": "Рефералы"
|
||||||
},
|
},
|
||||||
"noTickets": "У пользователя нет тикетов",
|
"noTickets": "У пользователя нет тикетов",
|
||||||
"ticketsCount": "тикетов",
|
"ticketsCount": "тикетов",
|
||||||
@@ -3468,6 +3566,7 @@
|
|||||||
"recentTransactions": "Последние транзакции"
|
"recentTransactions": "Последние транзакции"
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
|
"selectSubscription": "Выберите подписку",
|
||||||
"hasDifferences": "Есть расхождения",
|
"hasDifferences": "Есть расхождения",
|
||||||
"synced": "Синхронизировано",
|
"synced": "Синхронизировано",
|
||||||
"bot": "Бот",
|
"bot": "Бот",
|
||||||
@@ -3515,6 +3614,28 @@
|
|||||||
"unknownUser": "Неизвестный",
|
"unknownUser": "Неизвестный",
|
||||||
"totalSent": "Всего отправлено",
|
"totalSent": "Всего отправлено",
|
||||||
"totalReceived": "Всего получено"
|
"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": {
|
"trafficReset": {
|
||||||
"DAY": "每日重置",
|
"DAY": "每日重置",
|
||||||
"WEEK": "每周重置",
|
"WEEK": "每周重置",
|
||||||
"MONTH": "每月重置"
|
"MONTH": "每月重置",
|
||||||
|
"MONTH_ROLLING": "每30天重置"
|
||||||
},
|
},
|
||||||
"traffic": "流量",
|
"traffic": "流量",
|
||||||
"unlimited": "无限",
|
"unlimited": "无限",
|
||||||
@@ -950,6 +951,17 @@
|
|||||||
"panel": {
|
"panel": {
|
||||||
"title": "管理面板",
|
"title": "管理面板",
|
||||||
"subtitle": "系统管理",
|
"subtitle": "系统管理",
|
||||||
|
"statsUptime": "运行时间",
|
||||||
|
"statsBot": "机器人",
|
||||||
|
"statsCabinet": "控制台",
|
||||||
|
"statsTrials": "试用",
|
||||||
|
"statsPaid": "付费",
|
||||||
|
"statsOnline": "在线",
|
||||||
|
"statsToday": "今日",
|
||||||
|
"searchPlaceholder": "搜索...",
|
||||||
|
"searchEmpty": "未找到结果",
|
||||||
|
"searchEmptyHint": "请尝试其他关键词",
|
||||||
|
"searchClear": "清除搜索",
|
||||||
"dashboardDesc": "统计和系统监控",
|
"dashboardDesc": "统计和系统监控",
|
||||||
"ticketsDesc": "处理用户工单",
|
"ticketsDesc": "处理用户工单",
|
||||||
"settingsDesc": "系统设置和参数",
|
"settingsDesc": "系统设置和参数",
|
||||||
@@ -1523,6 +1535,7 @@
|
|||||||
"title": "系统设置",
|
"title": "系统设置",
|
||||||
"allCategories": "所有分类",
|
"allCategories": "所有分类",
|
||||||
"noSettings": "未找到设置",
|
"noSettings": "未找到设置",
|
||||||
|
"quickToggles": "快速开关",
|
||||||
"modified": "已修改",
|
"modified": "已修改",
|
||||||
"readOnly": "只读",
|
"readOnly": "只读",
|
||||||
"reset": "重置",
|
"reset": "重置",
|
||||||
@@ -1597,6 +1610,86 @@
|
|||||||
"quickPresets": "快速预设",
|
"quickPresets": "快速预设",
|
||||||
"resetAllColors": "重置所有颜色",
|
"resetAllColors": "重置所有颜色",
|
||||||
"statusColors": "状态颜色",
|
"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": {
|
"categories": {
|
||||||
"TELEGRAM_WIDGET": "Telegram Login Widget",
|
"TELEGRAM_WIDGET": "Telegram Login Widget",
|
||||||
"TELEGRAM_OIDC": "Telegram Login (OIDC)"
|
"TELEGRAM_OIDC": "Telegram Login (OIDC)"
|
||||||
@@ -1890,6 +1983,8 @@
|
|||||||
"resetModeWeeklyDesc": "每周重置",
|
"resetModeWeeklyDesc": "每周重置",
|
||||||
"resetModeMonthly": "每月",
|
"resetModeMonthly": "每月",
|
||||||
"resetModeMonthlyDesc": "每月重置",
|
"resetModeMonthlyDesc": "每月重置",
|
||||||
|
"resetModeMonthRolling": "滚动月",
|
||||||
|
"resetModeMonthRollingDesc": "从首次连接起每30天重置",
|
||||||
"resetModeNever": "从不",
|
"resetModeNever": "从不",
|
||||||
"resetModeNeverDesc": "流量不重置",
|
"resetModeNeverDesc": "流量不重置",
|
||||||
"cancelButton": "取消",
|
"cancelButton": "取消",
|
||||||
@@ -2466,7 +2561,8 @@
|
|||||||
"balance": "余额",
|
"balance": "余额",
|
||||||
"sync": "同步",
|
"sync": "同步",
|
||||||
"tickets": "工单",
|
"tickets": "工单",
|
||||||
"gifts": "礼物"
|
"gifts": "礼物",
|
||||||
|
"referrals": "推荐"
|
||||||
},
|
},
|
||||||
"noTickets": "该用户没有工单",
|
"noTickets": "该用户没有工单",
|
||||||
"ticketsCount": "个工单",
|
"ticketsCount": "个工单",
|
||||||
@@ -2574,6 +2670,7 @@
|
|||||||
"recentTransactions": "最近交易"
|
"recentTransactions": "最近交易"
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
|
"selectSubscription": "选择订阅",
|
||||||
"hasDifferences": "存在差异",
|
"hasDifferences": "存在差异",
|
||||||
"synced": "已同步",
|
"synced": "已同步",
|
||||||
"bot": "机器人",
|
"bot": "机器人",
|
||||||
@@ -2621,6 +2718,28 @@
|
|||||||
"unknownUser": "未知",
|
"unknownUser": "未知",
|
||||||
"totalSent": "已发送总数",
|
"totalSent": "已发送总数",
|
||||||
"totalReceived": "已收到总数"
|
"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": {
|
"tabs": {
|
||||||
"overview": "概览",
|
"overview": "概览",
|
||||||
"nodes": "节点",
|
"nodes": "节点",
|
||||||
|
"traffic": "流量",
|
||||||
"squads": "小队",
|
"squads": "小队",
|
||||||
"sync": "同步"
|
"sync": "同步"
|
||||||
},
|
},
|
||||||
|
"traffic": {
|
||||||
|
"noData": "暂无流量数据",
|
||||||
|
"totalDownload": "下载",
|
||||||
|
"totalUpload": "上传",
|
||||||
|
"totalTraffic": "总计",
|
||||||
|
"online": "在线",
|
||||||
|
"inbounds": "入站",
|
||||||
|
"outbounds": "出站"
|
||||||
|
},
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"confirmRestartAll": "确定要重启所有节点吗?",
|
"confirmRestartAll": "确定要重启所有节点吗?",
|
||||||
"disable": "禁用",
|
"disable": "禁用",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type TopCampaignsResponse,
|
type TopCampaignsResponse,
|
||||||
type RecentPaymentsResponse,
|
type RecentPaymentsResponse,
|
||||||
} from '../api/admin';
|
} from '../api/admin';
|
||||||
|
import { formatUptime } from '../utils/format';
|
||||||
|
|
||||||
const CABINET_VERSION = __APP_VERSION__;
|
const CABINET_VERSION = __APP_VERSION__;
|
||||||
import { useCurrency } from '../hooks/useCurrency';
|
import { useCurrency } from '../hooks/useCurrency';
|
||||||
@@ -254,14 +255,16 @@ function NodeCard({ node, onRestart, onToggle, isLoading }: NodeCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Xray Version & Uptime */}
|
{/* 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">
|
<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">
|
<span className="rounded bg-dark-700/50 px-2 py-1 text-dark-300">
|
||||||
Xray {node.xray_version}
|
Xray {node.versions.xray}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -165,10 +165,49 @@ function isSafeUrl(url: string | null | undefined): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Slug utility ---
|
// --- 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 {
|
function generateSlug(title: string): string {
|
||||||
return title
|
const lower = title.toLowerCase();
|
||||||
.toLowerCase()
|
const transliterated = Array.from(lower)
|
||||||
.replace(/[^\w\s\-а-яёА-ЯЁ]/g, '')
|
.map((ch) => TRANSLIT_MAP[ch] ?? ch)
|
||||||
|
.join('');
|
||||||
|
return transliterated
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
.replace(/[\s_]+/g, '-')
|
.replace(/[\s_]+/g, '-')
|
||||||
.replace(/-+/g, '-')
|
.replace(/-+/g, '-')
|
||||||
.replace(/^-+|-+$/g, '')
|
.replace(/^-+|-+$/g, '')
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ import {
|
|||||||
OFFER_TYPE_CONFIG,
|
OFFER_TYPE_CONFIG,
|
||||||
OfferType,
|
OfferType,
|
||||||
} from '../api/promoOffers';
|
} from '../api/promoOffers';
|
||||||
|
import { serversApi } from '../api/servers';
|
||||||
import { AdminBackButton } from '../components/admin';
|
import { AdminBackButton } from '../components/admin';
|
||||||
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
|
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
|
||||||
|
|
||||||
@@ -31,6 +32,14 @@ export default function AdminPromoOfferTemplateEdit() {
|
|||||||
const [testDurationHours, setTestDurationHours] = useState<number | ''>(0);
|
const [testDurationHours, setTestDurationHours] = useState<number | ''>(0);
|
||||||
const [isActive, setIsActive] = useState(true);
|
const [isActive, setIsActive] = useState(true);
|
||||||
const [isTestAccess, setIsTestAccess] = useState(false);
|
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
|
// Query template
|
||||||
const { data: templatesData, isLoading } = useQuery({
|
const { data: templatesData, isLoading } = useQuery({
|
||||||
@@ -52,6 +61,7 @@ export default function AdminPromoOfferTemplateEdit() {
|
|||||||
setTestDurationHours(template.test_duration_hours || 0);
|
setTestDurationHours(template.test_duration_hours || 0);
|
||||||
setIsActive(template.is_active);
|
setIsActive(template.is_active);
|
||||||
setIsTestAccess(template.offer_type === 'test_access');
|
setIsTestAccess(template.offer_type === 'test_access');
|
||||||
|
setSelectedSquadUuids(template.test_squad_uuids || []);
|
||||||
}
|
}
|
||||||
}, [template]);
|
}, [template]);
|
||||||
|
|
||||||
@@ -78,6 +88,7 @@ export default function AdminPromoOfferTemplateEdit() {
|
|||||||
const discountHours = toNumber(activeDiscountHours);
|
const discountHours = toNumber(activeDiscountHours);
|
||||||
if (isTestAccess) {
|
if (isTestAccess) {
|
||||||
data.test_duration_hours = testHours > 0 ? testHours : undefined;
|
data.test_duration_hours = testHours > 0 ? testHours : undefined;
|
||||||
|
data.test_squad_uuids = selectedSquadUuids;
|
||||||
} else {
|
} else {
|
||||||
data.active_discount_hours = discountHours > 0 ? discountHours : undefined;
|
data.active_discount_hours = discountHours > 0 ? discountHours : undefined;
|
||||||
}
|
}
|
||||||
@@ -201,22 +212,70 @@ export default function AdminPromoOfferTemplateEdit() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isTestAccess ? (
|
{isTestAccess ? (
|
||||||
<div>
|
<>
|
||||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
<div>
|
||||||
{t('admin.promoOffers.form.testDurationHours')}
|
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
</label>
|
{t('admin.promoOffers.form.testDurationHours')}
|
||||||
<input
|
</label>
|
||||||
type="number"
|
<input
|
||||||
value={testDurationHours}
|
type="number"
|
||||||
onChange={createNumberInputHandler(setTestDurationHours, 0)}
|
value={testDurationHours}
|
||||||
className="input"
|
onChange={createNumberInputHandler(setTestDurationHours, 0)}
|
||||||
min={0}
|
className="input"
|
||||||
placeholder="0"
|
min={0}
|
||||||
/>
|
placeholder="0"
|
||||||
<p className="mt-1 text-xs text-dark-500">
|
/>
|
||||||
{t('admin.promoOffers.form.defaultZero')}
|
<p className="mt-1 text-xs text-dark-500">
|
||||||
</p>
|
{t('admin.promoOffers.form.defaultZero')}
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
PromoCodeUpdateRequest,
|
PromoCodeUpdateRequest,
|
||||||
PromoGroup,
|
PromoGroup,
|
||||||
} from '../api/promocodes';
|
} from '../api/promocodes';
|
||||||
|
import { tariffsApi } from '../api/tariffs';
|
||||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
@@ -60,6 +61,7 @@ export default function AdminPromocodeCreate() {
|
|||||||
const [firstPurchaseOnly, setFirstPurchaseOnly] = useState(false);
|
const [firstPurchaseOnly, setFirstPurchaseOnly] = useState(false);
|
||||||
const [validUntil, setValidUntil] = useState('');
|
const [validUntil, setValidUntil] = useState('');
|
||||||
const [promoGroupId, setPromoGroupId] = useState<number | null>(null);
|
const [promoGroupId, setPromoGroupId] = useState<number | null>(null);
|
||||||
|
const [tariffId, setTariffId] = useState<number | null>(null);
|
||||||
|
|
||||||
// Fetch promo groups (for promo_group type)
|
// Fetch promo groups (for promo_group type)
|
||||||
const { data: promoGroupsData } = useQuery({
|
const { data: promoGroupsData } = useQuery({
|
||||||
@@ -69,6 +71,15 @@ export default function AdminPromocodeCreate() {
|
|||||||
|
|
||||||
const promoGroups: PromoGroup[] = promoGroupsData?.items || [];
|
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
|
// Fetch promocode for editing
|
||||||
const { isLoading: isLoadingPromocode } = useQuery({
|
const { isLoading: isLoadingPromocode } = useQuery({
|
||||||
queryKey: ['admin-promocode', id],
|
queryKey: ['admin-promocode', id],
|
||||||
@@ -94,6 +105,7 @@ export default function AdminPromocodeCreate() {
|
|||||||
setFirstPurchaseOnly(data.first_purchase_only || false);
|
setFirstPurchaseOnly(data.first_purchase_only || false);
|
||||||
setValidUntil(data.valid_until ? data.valid_until.split('T')[0] : '');
|
setValidUntil(data.valid_until ? data.valid_until.split('T')[0] : '');
|
||||||
setPromoGroupId(data.promo_group_id || null);
|
setPromoGroupId(data.promo_group_id || null);
|
||||||
|
setTariffId(data.tariff_id || null);
|
||||||
return data;
|
return data;
|
||||||
}, []),
|
}, []),
|
||||||
});
|
});
|
||||||
@@ -137,6 +149,7 @@ export default function AdminPromocodeCreate() {
|
|||||||
first_purchase_only: firstPurchaseOnly,
|
first_purchase_only: firstPurchaseOnly,
|
||||||
valid_until: validUntil ? new Date(validUntil).toISOString() : null,
|
valid_until: validUntil ? new Date(validUntil).toISOString() : null,
|
||||||
promo_group_id: type === 'promo_group' ? promoGroupId : null,
|
promo_group_id: type === 'promo_group' ? promoGroupId : null,
|
||||||
|
...(type === 'trial_subscription' && tariffId ? { tariff_id: tariffId } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
@@ -301,6 +314,40 @@ export default function AdminPromocodeCreate() {
|
|||||||
</div>
|
</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' && (
|
{type === 'promo_group' && (
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import {
|
import {
|
||||||
adminRemnawaveApi,
|
adminRemnawaveApi,
|
||||||
NodeInfo,
|
NodeInfo,
|
||||||
|
NodeRealtimeStats,
|
||||||
SquadWithLocalInfo,
|
SquadWithLocalInfo,
|
||||||
SystemStatsResponse,
|
SystemStatsResponse,
|
||||||
AutoSyncStatus,
|
AutoSyncStatus,
|
||||||
} from '../api/adminRemnawave';
|
} from '../api/adminRemnawave';
|
||||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
|
import { formatUptime } from '../utils/format';
|
||||||
import {
|
import {
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
ChartIcon,
|
ChartIcon,
|
||||||
@@ -43,16 +45,6 @@ const formatBytes = (bytes: number): string => {
|
|||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
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 => {
|
const getCountryFlag = (code: string | null | undefined): string => {
|
||||||
if (!code) return '🌍';
|
if (!code) return '🌍';
|
||||||
const codeMap: Record<string, string> = {
|
const codeMap: Record<string, string> = {
|
||||||
@@ -175,11 +167,12 @@ function NodeCard({ node, onAction, isLoading }: NodeCardProps) {
|
|||||||
{t('admin.remnawave.nodes.trafficUsed', 'used')}
|
{t('admin.remnawave.nodes.trafficUsed', 'used')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{node.xray_uptime && (
|
{node.xray_uptime > 0 && (
|
||||||
<span>
|
<span>
|
||||||
{t('admin.remnawave.nodes.uptimeLabel', 'Uptime')}: {node.xray_uptime}
|
{t('admin.remnawave.nodes.uptimeLabel', 'Uptime')}: {formatUptime(node.xray_uptime)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{node.versions?.xray && <span>Xray {node.versions.xray}</span>}
|
||||||
</div>
|
</div>
|
||||||
</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 =
|
const memoryUsedPercent =
|
||||||
stats.server_info.memory_total > 0
|
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;
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -405,24 +392,24 @@ function OverviewTab({ stats, isLoading, onRefresh }: OverviewTabProps) {
|
|||||||
{/* Bandwidth */}
|
{/* Bandwidth */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-sm font-medium text-dark-300">
|
<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>
|
</h3>
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t('admin.remnawave.overview.download', 'Download')}
|
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>}
|
icon={<span className="text-lg">↓</span>}
|
||||||
color="green"
|
color="green"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t('admin.remnawave.overview.upload', 'Upload')}
|
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>}
|
icon={<span className="text-lg">↑</span>}
|
||||||
color="blue"
|
color="blue"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t('admin.remnawave.overview.total', 'Total')}
|
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>}
|
icon={<span className="text-lg">⇅</span>}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
@@ -437,14 +424,14 @@ function OverviewTab({ stats, isLoading, onRefresh }: OverviewTabProps) {
|
|||||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3">
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3">
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t('admin.remnawave.overview.cpu', 'CPU Cores')}
|
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>}
|
icon={<span className="text-lg">⚡</span>}
|
||||||
color="accent"
|
color="accent"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t('admin.remnawave.overview.memory', 'Memory')}
|
label={t('admin.remnawave.overview.memory', 'Memory')}
|
||||||
value={`${memoryUsedPercent}%`}
|
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>}
|
icon={<span className="text-lg">💾</span>}
|
||||||
color={memoryUsedPercent > 80 ? 'red' : memoryUsedPercent > 60 ? 'orange' : 'green'}
|
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() {
|
export default function AdminRemnawave() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -918,6 +1036,17 @@ export default function AdminRemnawave() {
|
|||||||
enabled: activeTab === 'squads',
|
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({
|
const { data: autoSyncStatus, isLoading: isLoadingAutoSync } = useQuery({
|
||||||
queryKey: ['admin-remnawave-autosync'],
|
queryKey: ['admin-remnawave-autosync'],
|
||||||
queryFn: adminRemnawaveApi.getAutoSyncStatus,
|
queryFn: adminRemnawaveApi.getAutoSyncStatus,
|
||||||
@@ -992,6 +1121,11 @@ export default function AdminRemnawave() {
|
|||||||
icon: <ChartIcon />,
|
icon: <ChartIcon />,
|
||||||
},
|
},
|
||||||
{ id: 'nodes' as const, label: t('admin.remnawave.tabs.nodes', 'Nodes'), icon: <GlobeIcon /> },
|
{ 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,
|
id: 'squads' as const,
|
||||||
label: t('admin.remnawave.tabs.squads', 'Squads'),
|
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' && (
|
{activeTab === 'squads' && (
|
||||||
<SquadsTab
|
<SquadsTab
|
||||||
squads={squadsData?.items || []}
|
squads={squadsData?.items || []}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { adminSettingsApi, SettingDefinition } from '../api/adminSettings';
|
import { adminSettingsApi, SettingDefinition } from '../api/adminSettings';
|
||||||
import { themeColorsApi } from '../api/themeColors';
|
import { themeColorsApi } from '../api/themeColors';
|
||||||
import { useFavoriteSettings } from '../hooks/useFavoriteSettings';
|
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 { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
import { AnalyticsTab } from '../components/admin/AnalyticsTab';
|
import { AnalyticsTab } from '../components/admin/AnalyticsTab';
|
||||||
import { BrandingTab } from '../components/admin/BrandingTab';
|
import { BrandingTab } from '../components/admin/BrandingTab';
|
||||||
@@ -13,12 +13,9 @@ import { MenuEditorTab } from '../components/admin/MenuEditorTab';
|
|||||||
import { ThemeTab } from '../components/admin/ThemeTab';
|
import { ThemeTab } from '../components/admin/ThemeTab';
|
||||||
import { FavoritesTab } from '../components/admin/FavoritesTab';
|
import { FavoritesTab } from '../components/admin/FavoritesTab';
|
||||||
import { SettingsTab } from '../components/admin/SettingsTab';
|
import { SettingsTab } from '../components/admin/SettingsTab';
|
||||||
|
import { SettingsTreeSidebar } from '../components/admin/SettingsTreeSidebar';
|
||||||
import { SettingsMobileTabs } from '../components/admin/SettingsMobileTabs';
|
import { SettingsMobileTabs } from '../components/admin/SettingsMobileTabs';
|
||||||
import {
|
import { SettingsSearchMobile, SettingsSearchResults } from '../components/admin/SettingsSearch';
|
||||||
SettingsSearch,
|
|
||||||
SettingsSearchMobile,
|
|
||||||
SettingsSearchResults,
|
|
||||||
} from '../components/admin/SettingsSearch';
|
|
||||||
|
|
||||||
// BackIcon
|
// BackIcon
|
||||||
const BackIcon = () => (
|
const BackIcon = () => (
|
||||||
@@ -33,17 +30,18 @@ const BackIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Find section ID by category key
|
// ChevronRight for breadcrumbs
|
||||||
function findSectionByCategory(categoryKey: string): string | null {
|
const ChevronRightIcon = () => (
|
||||||
for (const section of MENU_SECTIONS) {
|
<svg
|
||||||
for (const item of section.items) {
|
className="h-3.5 w-3.5 text-dark-600"
|
||||||
if (item.categories?.includes(categoryKey)) {
|
fill="none"
|
||||||
return item.id;
|
viewBox="0 0 24 24"
|
||||||
}
|
stroke="currentColor"
|
||||||
}
|
strokeWidth={2}
|
||||||
}
|
>
|
||||||
return null;
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||||
}
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export default function AdminSettings() {
|
export default function AdminSettings() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -73,23 +71,42 @@ export default function AdminSettings() {
|
|||||||
queryFn: () => adminSettingsApi.getSettings(),
|
queryFn: () => adminSettingsApi.getSettings(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get current menu item configuration
|
// Find active tree info (group + child for tree sub-items)
|
||||||
const currentMenuItem = useMemo(() => {
|
// No useMemo needed — SETTINGS_TREE is a static constant, iteration is trivial
|
||||||
for (const section of MENU_SECTIONS) {
|
let activeTreeInfo: {
|
||||||
const item = section.items.find((i: MenuItem) => i.id === activeSection);
|
group: (typeof SETTINGS_TREE.groups)[number];
|
||||||
if (item) return item;
|
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(() => {
|
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[]>();
|
const categoryMap = new Map<string, SettingDefinition[]>();
|
||||||
|
|
||||||
for (const setting of allSettings) {
|
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)) {
|
if (!categoryMap.has(setting.category.key)) {
|
||||||
categoryMap.set(setting.category.key, []);
|
categoryMap.set(setting.category.key, []);
|
||||||
}
|
}
|
||||||
@@ -102,7 +119,8 @@ export default function AdminSettings() {
|
|||||||
label: t(`admin.settings.categories.${key}`, key),
|
label: t(`admin.settings.categories.${key}`, key),
|
||||||
settings,
|
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
|
// Filter settings for search - GLOBAL search across all settings
|
||||||
const filteredSettings = useMemo(() => {
|
const filteredSettings = useMemo(() => {
|
||||||
@@ -112,27 +130,20 @@ export default function AdminSettings() {
|
|||||||
if (!q) return [];
|
if (!q) return [];
|
||||||
|
|
||||||
return allSettings.filter((s: SettingDefinition) => {
|
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;
|
if (s.key.toLowerCase().includes(q)) return true;
|
||||||
|
|
||||||
// Search by original name
|
|
||||||
if (s.name?.toLowerCase().includes(q)) return true;
|
if (s.name?.toLowerCase().includes(q)) return true;
|
||||||
|
|
||||||
// Search by translated name
|
|
||||||
const formattedKey = formatSettingKey(s.name || s.key);
|
const formattedKey = formatSettingKey(s.name || s.key);
|
||||||
const translatedName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey);
|
const translatedName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey);
|
||||||
if (translatedName.toLowerCase().includes(q)) return true;
|
if (translatedName.toLowerCase().includes(q)) return true;
|
||||||
|
|
||||||
// Search by description
|
|
||||||
if (s.hint?.description?.toLowerCase().includes(q)) return true;
|
if (s.hint?.description?.toLowerCase().includes(q)) return true;
|
||||||
|
|
||||||
// Search by category
|
|
||||||
const categoryLabel = t(`admin.settings.categories.${s.category.key}`, s.category.key);
|
const categoryLabel = t(`admin.settings.categories.${s.category.key}`, s.category.key);
|
||||||
if (categoryLabel.toLowerCase().includes(q)) return true;
|
if (categoryLabel.toLowerCase().includes(q)) return true;
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
}, [allSettings, searchQuery, t]);
|
}, [allSettings, searchQuery, isTariffsMode, t]);
|
||||||
|
|
||||||
// Favorite settings
|
// Favorite settings
|
||||||
const favoriteSettings = useMemo(() => {
|
const favoriteSettings = useMemo(() => {
|
||||||
@@ -140,19 +151,43 @@ export default function AdminSettings() {
|
|||||||
return allSettings.filter((s: SettingDefinition) => favorites.includes(s.key));
|
return allSettings.filter((s: SettingDefinition) => favorites.includes(s.key));
|
||||||
}, [allSettings, favorites]);
|
}, [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
|
// Handle setting selection from autocomplete
|
||||||
const handleSelectSetting = useCallback(
|
const handleSelectSetting = useCallback(
|
||||||
(setting: SettingDefinition) => {
|
(setting: SettingDefinition) => {
|
||||||
const sectionId = findSectionByCategory(setting.category.key);
|
const location = findTreeLocation(setting.category.key);
|
||||||
if (sectionId) {
|
if (location) {
|
||||||
setActiveSection(sectionId);
|
setActiveSection(location.subItemId);
|
||||||
}
|
}
|
||||||
// Set search to setting key to filter to just this setting
|
|
||||||
setSearchQuery(setting.key);
|
setSearchQuery(setting.key);
|
||||||
},
|
},
|
||||||
[setActiveSection, setSearchQuery],
|
[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
|
// Render content based on active section
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
// If searching, always show search results regardless of active section
|
// If searching, always show search results regardless of active section
|
||||||
@@ -186,17 +221,7 @@ export default function AdminSettings() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
if (
|
if (activeTreeInfo) {
|
||||||
[
|
|
||||||
'payments',
|
|
||||||
'subscriptions',
|
|
||||||
'interface',
|
|
||||||
'notifications',
|
|
||||||
'database',
|
|
||||||
'system',
|
|
||||||
'users',
|
|
||||||
].includes(activeSection)
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
<SettingsTab
|
<SettingsTab
|
||||||
categories={currentCategories}
|
categories={currentCategories}
|
||||||
@@ -207,6 +232,8 @@ export default function AdminSettings() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Unknown section — fallback to branding
|
||||||
|
setActiveSection('branding');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -214,7 +241,7 @@ export default function AdminSettings() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Mobile Layout */}
|
{/* Mobile Layout */}
|
||||||
<div className="space-y-4 lg:hidden">
|
<div className="space-y-4 pb-4 lg:hidden">
|
||||||
<SettingsMobileTabs
|
<SettingsMobileTabs
|
||||||
activeSection={activeSection}
|
activeSection={activeSection}
|
||||||
setActiveSection={setActiveSection}
|
setActiveSection={setActiveSection}
|
||||||
@@ -233,7 +260,7 @@ export default function AdminSettings() {
|
|||||||
{/* Desktop Layout - fixed sidebar, scrollable content */}
|
{/* Desktop Layout - fixed sidebar, scrollable content */}
|
||||||
<div className="hidden h-[calc(100vh-120px)] lg:flex">
|
<div className="hidden h-[calc(100vh-120px)] lg:flex">
|
||||||
{/* Fixed Sidebar */}
|
{/* 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="border-b border-dark-700/50 p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Show back button only on web, not in Telegram Mini App */}
|
{/* Show back button only on web, not in Telegram Mini App */}
|
||||||
@@ -241,6 +268,7 @@ export default function AdminSettings() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => navigate('/admin')}
|
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"
|
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 />
|
<BackIcon />
|
||||||
</button>
|
</button>
|
||||||
@@ -248,67 +276,52 @@ export default function AdminSettings() {
|
|||||||
<h1 className="text-lg font-bold text-dark-100">{t('admin.settings.title')}</h1>
|
<h1 className="text-lg font-bold text-dark-100">{t('admin.settings.title')}</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav className="space-y-1 p-2">
|
<SettingsTreeSidebar
|
||||||
{MENU_SECTIONS.map((section, sectionIdx) => (
|
activeSection={activeSection}
|
||||||
<div key={section.id}>
|
onSectionChange={setActiveSection}
|
||||||
{sectionIdx > 0 && <div className="my-3 border-t border-dark-700/50" />}
|
favoritesCount={favorites.length}
|
||||||
{section.items.map((item) => {
|
searchQuery={searchQuery}
|
||||||
const isActive = activeSection === item.id;
|
onSearchChange={setSearchQuery}
|
||||||
const hasIcon = item.iconType === 'star';
|
allSettings={allSettings}
|
||||||
return (
|
onSelectSetting={handleSelectSetting}
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable Content */}
|
{/* Scrollable Content */}
|
||||||
<div className="min-w-0 flex-1 overflow-y-auto p-6">
|
<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">
|
<div className="mb-4 flex items-center gap-3">
|
||||||
<h2 className="truncate text-xl font-semibold text-dark-100">
|
<h2 className="truncate text-xl font-semibold text-dark-100">{sectionTitle}</h2>
|
||||||
{t(`admin.settings.${activeSection}`)}
|
{totalCount > 0 && !searchQuery.trim() && activeTreeInfo && (
|
||||||
</h2>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex-1" />
|
<span className="rounded-full bg-dark-700/50 px-2 py-0.5 text-xs text-dark-400">
|
||||||
<SettingsSearch
|
{t('admin.settings.totalCount', { count: totalCount })}
|
||||||
searchQuery={searchQuery}
|
</span>
|
||||||
setSearchQuery={setSearchQuery}
|
{modifiedCount > 0 && (
|
||||||
resultsCount={filteredSettings.length}
|
<span className="rounded-full bg-warning-500/20 px-2 py-0.5 text-xs text-warning-400">
|
||||||
allSettings={allSettings}
|
{t('admin.settings.modifiedCount', { count: modifiedCount })}
|
||||||
onSelectSetting={handleSelectSetting}
|
</span>
|
||||||
/>
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsSearchResults searchQuery={searchQuery} resultsCount={filteredSettings.length} />
|
<SettingsSearchResults searchQuery={searchQuery} resultsCount={filteredSettings.length} />
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ export default function AdminTariffCreate() {
|
|||||||
traffic_limit_gb: toNumber(trafficLimitGb, 100),
|
traffic_limit_gb: toNumber(trafficLimitGb, 100),
|
||||||
device_limit: toNumber(deviceLimit, 1),
|
device_limit: toNumber(deviceLimit, 1),
|
||||||
device_price_kopeks:
|
device_price_kopeks:
|
||||||
toNumber(devicePriceKopeks) > 0 ? toNumber(devicePriceKopeks) : undefined,
|
toNumber(devicePriceKopeks) >= 0 ? toNumber(devicePriceKopeks) : undefined,
|
||||||
max_device_limit: toNumber(maxDeviceLimit) > 0 ? toNumber(maxDeviceLimit) : undefined,
|
max_device_limit: toNumber(maxDeviceLimit) > 0 ? toNumber(maxDeviceLimit) : undefined,
|
||||||
tier_level: toNumber(tierLevel, 1),
|
tier_level: toNumber(tierLevel, 1),
|
||||||
period_prices: isDaily ? [] : periodPrices.filter((p) => p.price_kopeks >= 0),
|
period_prices: isDaily ? [] : periodPrices.filter((p) => p.price_kopeks >= 0),
|
||||||
@@ -912,7 +912,7 @@ export default function AdminTariffCreate() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
const gb = toNumber(newPackageGb, 0);
|
const gb = toNumber(newPackageGb, 0);
|
||||||
const price = toNumber(newPackagePrice, 0);
|
const price = toNumber(newPackagePrice, 0);
|
||||||
if (gb > 0 && price > 0 && !trafficTopupPackages[String(gb)]) {
|
if (gb > 0 && price >= 0 && !trafficTopupPackages[String(gb)]) {
|
||||||
setTrafficTopupPackages((prev) => ({
|
setTrafficTopupPackages((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[String(gb)]: price * 100,
|
[String(gb)]: price * 100,
|
||||||
@@ -1029,6 +1029,11 @@ export default function AdminTariffCreate() {
|
|||||||
{ value: 'DAY', labelKey: 'admin.tariffs.resetModeDaily', emoji: '📅' },
|
{ value: 'DAY', labelKey: 'admin.tariffs.resetModeDaily', emoji: '📅' },
|
||||||
{ value: 'WEEK', labelKey: 'admin.tariffs.resetModeWeekly', emoji: '📆' },
|
{ value: 'WEEK', labelKey: 'admin.tariffs.resetModeWeekly', emoji: '📆' },
|
||||||
{ value: 'MONTH', labelKey: 'admin.tariffs.resetModeMonthly', emoji: '🗓️' },
|
{ value: 'MONTH', labelKey: 'admin.tariffs.resetModeMonthly', emoji: '🗓️' },
|
||||||
|
{
|
||||||
|
value: 'MONTH_ROLLING',
|
||||||
|
labelKey: 'admin.tariffs.resetModeMonthRolling',
|
||||||
|
emoji: '🔄',
|
||||||
|
},
|
||||||
{ value: 'NO_RESET', labelKey: 'admin.tariffs.resetModeNever', emoji: '🚫' },
|
{ value: 'NO_RESET', labelKey: 'admin.tariffs.resetModeNever', emoji: '🚫' },
|
||||||
].map((option) => (
|
].map((option) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -181,10 +181,12 @@ export default function AdminTickets() {
|
|||||||
|
|
||||||
// Cancel in-flight uploads and cleanup blob URL on unmount
|
// Cancel in-flight uploads and cleanup blob URL on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const uploadRef = uploadIdRef;
|
||||||
|
const prevRef = previewRef;
|
||||||
return () => {
|
return () => {
|
||||||
uploadIdRef.current++;
|
uploadRef.current++;
|
||||||
if (previewRef.current) {
|
if (prevRef.current) {
|
||||||
URL.revokeObjectURL(previewRef.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;
|
message: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
} | null>(null);
|
} | 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 [transactionsPage, setTransactionsPage] = useState(1);
|
||||||
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
||||||
|
|
||||||
@@ -135,27 +141,38 @@ export default function Balance() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePromocodeActivate = async () => {
|
const handlePromocodeActivate = async (subscriptionId?: number) => {
|
||||||
if (!promocode.trim()) return;
|
const code = subscriptionId ? promoSelectCode || '' : promocode.trim();
|
||||||
|
if (!code) return;
|
||||||
|
|
||||||
setPromocodeLoading(true);
|
setPromocodeLoading(true);
|
||||||
setPromocodeError(null);
|
setPromocodeError(null);
|
||||||
setPromocodeSuccess(null);
|
setPromocodeSuccess(null);
|
||||||
|
|
||||||
try {
|
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) {
|
if (result.success) {
|
||||||
const bonusAmount = result.balance_after - result.balance_before;
|
const bonusAmount = (result.balance_after || 0) - (result.balance_before || 0);
|
||||||
setPromocodeSuccess({
|
setPromocodeSuccess({
|
||||||
message: result.bonus_description || t('balance.promocode.success'),
|
message: result.bonus_description || t('balance.promocode.success'),
|
||||||
amount: bonusAmount,
|
amount: bonusAmount,
|
||||||
});
|
});
|
||||||
setTransactionsPage(1);
|
setTransactionsPage(1);
|
||||||
setPromocode('');
|
setPromocode('');
|
||||||
|
setPromoSelectSubs(null);
|
||||||
|
setPromoSelectCode(null);
|
||||||
await refetchBalance();
|
await refetchBalance();
|
||||||
await refreshUser();
|
await refreshUser();
|
||||||
queryClient.invalidateQueries({ queryKey: ['transactions'] });
|
queryClient.invalidateQueries({ queryKey: ['transactions'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const axiosError = error as { response?: { data?: { detail?: string } } };
|
const axiosError = error as { response?: { data?: { detail?: string } } };
|
||||||
@@ -170,6 +187,8 @@ export default function Balance() {
|
|||||||
? 'already_used_by_user'
|
? 'already_used_by_user'
|
||||||
: 'server_error';
|
: 'server_error';
|
||||||
setPromocodeError(t(`balance.promocode.errors.${errorKey}`));
|
setPromocodeError(t(`balance.promocode.errors.${errorKey}`));
|
||||||
|
setPromoSelectSubs(null);
|
||||||
|
setPromoSelectCode(null);
|
||||||
} finally {
|
} finally {
|
||||||
setPromocodeLoading(false);
|
setPromocodeLoading(false);
|
||||||
}
|
}
|
||||||
@@ -214,7 +233,7 @@ export default function Balance() {
|
|||||||
disabled={promocodeLoading}
|
disabled={promocodeLoading}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
onClick={handlePromocodeActivate}
|
onClick={() => handlePromocodeActivate()}
|
||||||
disabled={!promocode.trim()}
|
disabled={!promocode.trim()}
|
||||||
loading={promocodeLoading}
|
loading={promocodeLoading}
|
||||||
>
|
>
|
||||||
@@ -250,6 +269,39 @@ export default function Balance() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</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>
|
</Card>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useCallback, useMemo, useRef } from 'react';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { openLink as sdkOpenLink } from '@telegram-apps/sdk-react';
|
import { openLink as sdkOpenLink } from '@telegram-apps/sdk-react';
|
||||||
@@ -14,6 +14,8 @@ import InstallationGuide from '../components/connection/InstallationGuide';
|
|||||||
export default function Connection() {
|
export default function Connection() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const subId = searchParams.get('sub') ? Number(searchParams.get('sub')) : undefined;
|
||||||
const user = useAuthStore((state) => state.user);
|
const user = useAuthStore((state) => state.user);
|
||||||
const isAdmin = useAuthStore((state) => state.isAdmin);
|
const isAdmin = useAuthStore((state) => state.isAdmin);
|
||||||
const { isTelegramWebApp } = useTelegramSDK();
|
const { isTelegramWebApp } = useTelegramSDK();
|
||||||
@@ -27,8 +29,8 @@ export default function Connection() {
|
|||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
} = useQuery<AppConfig>({
|
} = useQuery<AppConfig>({
|
||||||
queryKey: ['appConfig'],
|
queryKey: ['appConfig', subId],
|
||||||
queryFn: () => subscriptionApi.getAppConfig(),
|
queryFn: () => subscriptionApi.getAppConfig(subId),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleGoBack = useCallback(() => {
|
const handleGoBack = useCallback(() => {
|
||||||
@@ -41,9 +43,10 @@ export default function Connection() {
|
|||||||
state: {
|
state: {
|
||||||
url: appConfig?.subscriptionUrl,
|
url: appConfig?.subscriptionUrl,
|
||||||
hideLink: appConfig?.hideLink ?? false,
|
hideLink: appConfig?.hideLink ?? false,
|
||||||
|
subscriptionId: subId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [navigate, appConfig?.subscriptionUrl, appConfig?.hideLink, isTelegramWebApp]);
|
}, [navigate, appConfig?.subscriptionUrl, appConfig?.hideLink, isTelegramWebApp, subId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { AdminBackButton } from '@/components/admin';
|
|||||||
interface ConnectionQRState {
|
interface ConnectionQRState {
|
||||||
url: string;
|
url: string;
|
||||||
hideLink: boolean;
|
hideLink: boolean;
|
||||||
|
subscriptionId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidState(state: unknown): state is ConnectionQRState {
|
function isValidState(state: unknown): state is ConnectionQRState {
|
||||||
@@ -24,12 +25,14 @@ export default function ConnectionQR() {
|
|||||||
|
|
||||||
const state = location.state as unknown;
|
const state = location.state as unknown;
|
||||||
const validState = isValidState(state) ? state : null;
|
const validState = isValidState(state) ? state : null;
|
||||||
|
const subId = validState?.subscriptionId;
|
||||||
|
const connectionPath = subId ? `/connection?sub=${subId}` : '/connection';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!validState) {
|
if (!validState) {
|
||||||
navigate('/connection', { replace: true });
|
navigate(connectionPath, { replace: true });
|
||||||
}
|
}
|
||||||
}, [validState, navigate]);
|
}, [validState, navigate, connectionPath]);
|
||||||
|
|
||||||
if (!validState) {
|
if (!validState) {
|
||||||
return null;
|
return null;
|
||||||
@@ -38,7 +41,7 @@ export default function ConnectionQR() {
|
|||||||
return (
|
return (
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
<div className="mb-6 flex items-center gap-3">
|
<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>
|
<h1 className="text-2xl font-bold text-dark-100">{t('subscription.connection.qrTitle')}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { useAuthStore } from '../store/auth';
|
import { useAuthStore } from '../store/auth';
|
||||||
import { useBlockingStore } from '../store/blocking';
|
import { useBlockingStore } from '../store/blocking';
|
||||||
@@ -18,6 +18,7 @@ import StatsGrid from '../components/dashboard/StatsGrid';
|
|||||||
import { giftApi } from '../api/gift';
|
import { giftApi } from '../api/gift';
|
||||||
import { promoApi } from '../api/promo';
|
import { promoApi } from '../api/promo';
|
||||||
import PendingGiftCard from '../components/dashboard/PendingGiftCard';
|
import PendingGiftCard from '../components/dashboard/PendingGiftCard';
|
||||||
|
import SubscriptionListCard from '../components/subscription/SubscriptionListCard';
|
||||||
import { API } from '../config/constants';
|
import { API } from '../config/constants';
|
||||||
|
|
||||||
const ChevronRightIcon = () => (
|
const ChevronRightIcon = () => (
|
||||||
@@ -28,6 +29,7 @@ const ChevronRightIcon = () => (
|
|||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const user = useAuthStore((state) => state.user);
|
const user = useAuthStore((state) => state.user);
|
||||||
const refreshUser = useAuthStore((state) => state.refreshUser);
|
const refreshUser = useAuthStore((state) => state.refreshUser);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -49,26 +51,35 @@ export default function Dashboard() {
|
|||||||
refetchOnMount: 'always',
|
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({
|
const { data: subscriptionResponse, isLoading: subLoading } = useQuery({
|
||||||
queryKey: ['subscription'],
|
queryKey: ['subscription'],
|
||||||
queryFn: subscriptionApi.getSubscription,
|
queryFn: () => subscriptionApi.getSubscription(),
|
||||||
retry: false,
|
retry: false,
|
||||||
staleTime: API.BALANCE_STALE_TIME_MS,
|
staleTime: API.BALANCE_STALE_TIME_MS,
|
||||||
refetchOnMount: 'always',
|
refetchOnMount: 'always',
|
||||||
|
enabled: !isMultiTariff,
|
||||||
});
|
});
|
||||||
|
|
||||||
const subscription = subscriptionResponse?.subscription ?? null;
|
const subscription = subscriptionResponse?.subscription ?? null;
|
||||||
|
|
||||||
const { data: trialInfo, isLoading: trialLoading } = useQuery({
|
const { data: trialInfo, isLoading: trialLoading } = useQuery({
|
||||||
queryKey: ['trial-info'],
|
queryKey: ['trial-info'],
|
||||||
queryFn: subscriptionApi.getTrialInfo,
|
queryFn: () => subscriptionApi.getTrialInfo(),
|
||||||
enabled: !subscription && !subLoading,
|
enabled: !subscription && !subLoading,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: devicesData } = useQuery({
|
const { data: devicesData } = useQuery({
|
||||||
queryKey: ['devices'],
|
queryKey: ['devices'],
|
||||||
queryFn: subscriptionApi.getDevices,
|
queryFn: () => subscriptionApi.getDevices(),
|
||||||
enabled: !!subscription,
|
enabled: !!subscription && !isMultiTariff,
|
||||||
staleTime: API.BALANCE_STALE_TIME_MS,
|
staleTime: API.BALANCE_STALE_TIME_MS,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -99,10 +110,11 @@ export default function Dashboard() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const activateTrialMutation = useMutation({
|
const activateTrialMutation = useMutation({
|
||||||
mutationFn: subscriptionApi.activateTrial,
|
mutationFn: () => subscriptionApi.activateTrial(),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setTrialError(null);
|
setTrialError(null);
|
||||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['trial-info'] });
|
queryClient.invalidateQueries({ queryKey: ['trial-info'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['balance'] });
|
queryClient.invalidateQueries({ queryKey: ['balance'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
||||||
@@ -122,20 +134,23 @@ export default function Dashboard() {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const refreshTrafficMutation = useMutation({
|
const refreshTrafficMutation = useMutation({
|
||||||
mutationFn: subscriptionApi.refreshTraffic,
|
mutationFn: () => subscriptionApi.refreshTraffic(subscription?.id),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setTrafficData({
|
setTrafficData({
|
||||||
traffic_used_gb: data.traffic_used_gb,
|
traffic_used_gb: data.traffic_used_gb,
|
||||||
traffic_used_percent: data.traffic_used_percent,
|
traffic_used_percent: data.traffic_used_percent,
|
||||||
is_unlimited: data.is_unlimited,
|
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) {
|
if (data.rate_limited && data.retry_after_seconds) {
|
||||||
setTrafficRefreshCooldown(data.retry_after_seconds);
|
setTrafficRefreshCooldown(data.retry_after_seconds);
|
||||||
} else {
|
} else {
|
||||||
setTrafficRefreshCooldown(30);
|
setTrafficRefreshCooldown(30);
|
||||||
}
|
}
|
||||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
queryClient.invalidateQueries({ queryKey: ['subscription', subscription?.id] });
|
||||||
},
|
},
|
||||||
onError: (error: {
|
onError: (error: {
|
||||||
response?: { status?: number; headers?: { get?: (key: string) => string } };
|
response?: { status?: number; headers?: { get?: (key: string) => string } };
|
||||||
@@ -164,7 +179,7 @@ export default function Dashboard() {
|
|||||||
if (hasAutoRefreshed.current) return;
|
if (hasAutoRefreshed.current) return;
|
||||||
hasAutoRefreshed.current = true;
|
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 now = Date.now();
|
||||||
const cacheMs = API.TRAFFIC_CACHE_MS;
|
const cacheMs = API.TRAFFIC_CACHE_MS;
|
||||||
|
|
||||||
@@ -266,37 +281,76 @@ export default function Dashboard() {
|
|||||||
{/* Pending Gift Activations */}
|
{/* Pending Gift Activations */}
|
||||||
{pendingGifts && pendingGifts.length > 0 && <PendingGiftCard gifts={pendingGifts} />}
|
{pendingGifts && pendingGifts.length > 0 && <PendingGiftCard gifts={pendingGifts} />}
|
||||||
|
|
||||||
{/* Subscription Status Card */}
|
{/* Multi-tariff: show subscription cards (max 3) */}
|
||||||
{subLoading ? (
|
{isMultiTariff && multiSubData?.subscriptions && (
|
||||||
<div className="bento-card">
|
<div className="space-y-3">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="flex items-center justify-between px-1">
|
||||||
<div className="skeleton h-5 w-20" />
|
<span className="text-sm font-medium opacity-60">
|
||||||
<div className="skeleton h-6 w-16 rounded-full" />
|
{t('dashboard.subscriptions', 'Подписки')}
|
||||||
</div>
|
</span>
|
||||||
<div className="skeleton mb-3 h-10 w-32" />
|
<Link to="/subscriptions" className="text-xs text-accent-400 hover:underline">
|
||||||
<div className="skeleton mb-3 h-4 w-40" />
|
{t('dashboard.manageAll', 'Управление')} →
|
||||||
<div className="skeleton h-3 w-full rounded-full" />
|
</Link>
|
||||||
<div className="mt-5">
|
|
||||||
<div className="skeleton h-12 w-full rounded-xl" />
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
) : subscription?.is_expired ||
|
)}
|
||||||
subscription?.status === 'disabled' ||
|
|
||||||
subscription?.is_limited ? (
|
{/* Subscription Status Card — hidden in multi-tariff (managed via /subscriptions) */}
|
||||||
<SubscriptionCardExpired
|
{!isMultiTariff && (
|
||||||
subscription={subscription}
|
<>
|
||||||
balanceKopeks={balanceData?.balance_kopeks ?? 0}
|
{subLoading ? (
|
||||||
balanceRubles={balanceData?.balance_rubles ?? 0}
|
<div className="bento-card">
|
||||||
/>
|
<div className="mb-4 flex items-center justify-between">
|
||||||
) : subscription ? (
|
<div className="skeleton h-5 w-20" />
|
||||||
<SubscriptionCardActive
|
<div className="skeleton h-6 w-16 rounded-full" />
|
||||||
subscription={subscription}
|
</div>
|
||||||
trafficData={trafficData}
|
<div className="skeleton mb-3 h-10 w-32" />
|
||||||
refreshTrafficMutation={refreshTrafficMutation}
|
<div className="skeleton mb-3 h-4 w-40" />
|
||||||
trafficRefreshCooldown={trafficRefreshCooldown}
|
<div className="skeleton h-3 w-full rounded-full" />
|
||||||
connectedDevices={devicesData?.total ?? 0}
|
<div className="mt-5">
|
||||||
/>
|
<div className="skeleton h-12 w-full rounded-xl" />
|
||||||
) : null}
|
</div>
|
||||||
|
</div>
|
||||||
|
) : subscription?.is_expired ||
|
||||||
|
subscription?.status === 'disabled' ||
|
||||||
|
subscription?.is_limited ? (
|
||||||
|
<SubscriptionCardExpired
|
||||||
|
subscription={subscription}
|
||||||
|
balanceKopeks={balanceData?.balance_kopeks ?? 0}
|
||||||
|
balanceRubles={balanceData?.balance_rubles ?? 0}
|
||||||
|
/>
|
||||||
|
) : subscription ? (
|
||||||
|
<SubscriptionCardActive
|
||||||
|
subscription={subscription}
|
||||||
|
trafficData={trafficData}
|
||||||
|
refreshTrafficMutation={refreshTrafficMutation}
|
||||||
|
trafficRefreshCooldown={trafficRefreshCooldown}
|
||||||
|
connectedDevices={devicesData?.total ?? 0}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Trial Activation */}
|
{/* Trial Activation */}
|
||||||
{hasNoSubscription && !trialLoading && trialInfo?.is_available && (
|
{hasNoSubscription && !trialLoading && trialInfo?.is_available && (
|
||||||
|
|||||||
@@ -311,7 +311,7 @@ export default function DeepLinkRedirect() {
|
|||||||
|
|
||||||
{/* Back to cabinet */}
|
{/* Back to cabinet */}
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/subscription')}
|
onClick={() => navigate('/subscriptions')}
|
||||||
className="w-full py-2 text-sm text-dark-500 transition-colors hover:text-dark-300"
|
className="w-full py-2 text-sm text-dark-500 transition-colors hover:text-dark-300"
|
||||||
>
|
>
|
||||||
{t('deepLink.backToCabinet')}
|
{t('deepLink.backToCabinet')}
|
||||||
@@ -353,7 +353,7 @@ export default function DeepLinkRedirect() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="mb-2 font-medium text-dark-200">{t('deepLink.errorTitle')}</p>
|
<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>
|
<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')}
|
{t('deepLink.goToSubscription')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,8 +55,10 @@ function CodeOnlySuccessState({
|
|||||||
const shortCode = purchaseToken.slice(0, 12);
|
const shortCode = purchaseToken.slice(0, 12);
|
||||||
const giftCode = `GIFT-${shortCode}`;
|
const giftCode = `GIFT-${shortCode}`;
|
||||||
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME as string | undefined;
|
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME as string | undefined;
|
||||||
const botLink = botUsername ? `https://t.me/${botUsername}?start=GIFT_${shortCode}` : null;
|
// Encode underscores as %5F so Telegram auto-link detection doesn't strip them
|
||||||
const cabinetLink = `${window.location.origin}/gift?tab=activate&code=${shortCode}`;
|
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 = [
|
const fullMessage = [
|
||||||
t('gift.shareText', 'I have a gift for you! Activate it here:'),
|
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 buildShareMessage = useCallback(() => {
|
||||||
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME as string | undefined;
|
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME as string | undefined;
|
||||||
const botLink = botUsername ? `https://t.me/${botUsername}?start=GIFT_${shortCode}` : null;
|
// Encode underscores as %5F so Telegram auto-link detection doesn't strip them
|
||||||
const cabinetLink = `${window.location.origin}/gift?tab=activate&code=${shortCode}`;
|
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 [
|
return [
|
||||||
t('gift.shareText'),
|
t('gift.shareText'),
|
||||||
'',
|
'',
|
||||||
@@ -1304,7 +1306,9 @@ export default function GiftSubscription() {
|
|||||||
|
|
||||||
// URL params: ?tab=activate&code=TOKEN for auto-activation
|
// URL params: ?tab=activate&code=TOKEN for auto-activation
|
||||||
const urlTab = searchParams.get('tab') as TabId | null;
|
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>(
|
const [activeTab, setActiveTab] = useState<TabId>(
|
||||||
urlTab === 'activate' || urlTab === 'myGifts' ? urlTab : 'buy',
|
urlTab === 'activate' || urlTab === 'myGifts' ? urlTab : 'buy',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -408,7 +408,7 @@ export default function NewsArticlePage() {
|
|||||||
<img
|
<img
|
||||||
src={article.featured_image_url}
|
src={article.featured_image_url}
|
||||||
alt={article.title}
|
alt={article.title}
|
||||||
className="h-auto w-full rounded-xl object-cover"
|
className="max-h-96 w-full rounded-xl object-cover"
|
||||||
loading="eager"
|
loading="eager"
|
||||||
fetchPriority="high"
|
fetchPriority="high"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router';
|
import { Link, useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { usePlatform } from '@/platform';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useAuthStore } from '../store/auth';
|
import { useAuthStore } from '../store/auth';
|
||||||
@@ -147,7 +148,12 @@ export default function Profile() {
|
|||||||
const registerEmailMutation = useMutation({
|
const registerEmailMutation = useMutation({
|
||||||
mutationFn: ({ email, password }: { email: string; password: string }) =>
|
mutationFn: ({ email, password }: { email: string; password: string }) =>
|
||||||
authApi.registerEmail(email, password),
|
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'));
|
setSuccess(t('profile.emailSent'));
|
||||||
setError(null);
|
setError(null);
|
||||||
setEmail('');
|
setEmail('');
|
||||||
@@ -250,14 +256,16 @@ export default function Profile() {
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [verificationResendCooldown]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
|
if (profilePlatform === 'telegram') return;
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (changeEmailStep === 'email') newEmailInputRef.current?.focus();
|
if (changeEmailStep === 'email') newEmailInputRef.current?.focus();
|
||||||
else if (changeEmailStep === 'code') codeInputRef.current?.focus();
|
else if (changeEmailStep === 'code') codeInputRef.current?.focus();
|
||||||
}, 100);
|
}, 100);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [changeEmailStep]);
|
}, [changeEmailStep, profilePlatform]);
|
||||||
|
|
||||||
// Auto-close success after 3s
|
// Auto-close success after 3s
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -927,6 +927,7 @@ export default function QuickPurchase() {
|
|||||||
contact_type: detectContactType(contactValue),
|
contact_type: detectContactType(contactValue),
|
||||||
contact_value: contactValue.trim(),
|
contact_value: contactValue.trim(),
|
||||||
payment_method: paymentMethod,
|
payment_method: paymentMethod,
|
||||||
|
language: i18n.language,
|
||||||
is_gift: isGift,
|
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 { useState, useEffect, useRef, useCallback, memo } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router';
|
import { Navigate, useNavigate, useParams } from 'react-router';
|
||||||
import { subscriptionApi } from '../api/subscription';
|
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 TrafficProgressBar from '../components/dashboard/TrafficProgressBar';
|
||||||
import { HoverBorderGradient } from '../components/ui/hover-border-gradient';
|
import { HoverBorderGradient } from '../components/ui/hover-border-gradient';
|
||||||
import { useTrafficZone } from '../hooks/useTrafficZone';
|
import { useTrafficZone } from '../hooks/useTrafficZone';
|
||||||
@@ -168,10 +171,16 @@ export default function Subscription() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { formatAmount, currencySymbol } = useCurrency();
|
const { formatAmount, currencySymbol } = useCurrency();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { subscriptionId: subIdParam } = useParams<{ subscriptionId?: string }>();
|
||||||
|
const subscriptionId = subIdParam ? parseInt(subIdParam, 10) : undefined;
|
||||||
const { isDark } = useTheme();
|
const { isDark } = useTheme();
|
||||||
const g = getGlassColors(isDark);
|
const g = getGlassColors(isDark);
|
||||||
const haptic = useHaptic();
|
const haptic = useHaptic();
|
||||||
const [copied, setCopied] = useState(false);
|
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
|
// Helper to format price from kopeks
|
||||||
const formatPrice = (kopeks: number) => `${formatAmount(kopeks / 100)} ${currencySymbol}`;
|
const formatPrice = (kopeks: number) => `${formatAmount(kopeks / 100)} ${currencySymbol}`;
|
||||||
@@ -194,11 +203,19 @@ export default function Subscription() {
|
|||||||
is_unlimited: boolean;
|
is_unlimited: boolean;
|
||||||
} | null>(null);
|
} | 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({
|
const { data: subscriptionResponse, isLoading } = useQuery({
|
||||||
queryKey: ['subscription'],
|
queryKey: ['subscription', subscriptionId],
|
||||||
queryFn: subscriptionApi.getSubscription,
|
queryFn: () => subscriptionApi.getSubscription(subscriptionId),
|
||||||
retry: false,
|
retry: false,
|
||||||
staleTime: 0, // Always refetch to get latest data
|
staleTime: 0,
|
||||||
refetchOnMount: 'always',
|
refetchOnMount: 'always',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -211,8 +228,8 @@ export default function Subscription() {
|
|||||||
|
|
||||||
// Purchase options (needed for balance_kopeks in device/traffic/server management)
|
// Purchase options (needed for balance_kopeks in device/traffic/server management)
|
||||||
const { data: purchaseOptions } = useQuery({
|
const { data: purchaseOptions } = useQuery({
|
||||||
queryKey: ['purchase-options'],
|
queryKey: ['purchase-options', subscriptionId],
|
||||||
queryFn: subscriptionApi.getPurchaseOptions,
|
queryFn: () => subscriptionApi.getPurchaseOptions(subscriptionId),
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
refetchOnMount: 'always',
|
refetchOnMount: 'always',
|
||||||
});
|
});
|
||||||
@@ -220,40 +237,43 @@ export default function Subscription() {
|
|||||||
const isTariffsMode = purchaseOptions?.sales_mode === 'tariffs';
|
const isTariffsMode = purchaseOptions?.sales_mode === 'tariffs';
|
||||||
|
|
||||||
const autopayMutation = useMutation({
|
const autopayMutation = useMutation({
|
||||||
mutationFn: (enabled: boolean) => subscriptionApi.updateAutopay(enabled),
|
mutationFn: (enabled: boolean) =>
|
||||||
|
subscriptionApi.updateAutopay(enabled, undefined, subscriptionId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Devices query
|
// Devices query
|
||||||
const { data: devicesData, isLoading: devicesLoading } = useQuery({
|
const { data: devicesData, isLoading: devicesLoading } = useQuery({
|
||||||
queryKey: ['devices'],
|
queryKey: ['devices', subscriptionId],
|
||||||
queryFn: subscriptionApi.getDevices,
|
queryFn: () => subscriptionApi.getDevices(subscriptionId),
|
||||||
enabled: !!subscription,
|
enabled: !!subscription,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete device mutation
|
// Delete device mutation
|
||||||
const deleteDeviceMutation = useMutation({
|
const deleteDeviceMutation = useMutation({
|
||||||
mutationFn: (hwid: string) => subscriptionApi.deleteDevice(hwid),
|
mutationFn: (hwid: string) => subscriptionApi.deleteDevice(hwid, subscriptionId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['devices'] });
|
queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete all devices mutation
|
// Delete all devices mutation
|
||||||
const deleteAllDevicesMutation = useMutation({
|
const deleteAllDevicesMutation = useMutation({
|
||||||
mutationFn: () => subscriptionApi.deleteAllDevices(),
|
mutationFn: () => subscriptionApi.deleteAllDevices(subscriptionId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['devices'] });
|
queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pause subscription mutation
|
// Pause subscription mutation
|
||||||
const pauseMutation = useMutation({
|
const pauseMutation = useMutation({
|
||||||
mutationFn: () => subscriptionApi.togglePause(),
|
mutationFn: () => subscriptionApi.togglePause(subscriptionId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['balance'] });
|
queryClient.invalidateQueries({ queryKey: ['balance'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -269,18 +289,20 @@ export default function Subscription() {
|
|||||||
|
|
||||||
// Device price query
|
// Device price query
|
||||||
const { data: devicePriceData } = useQuery({
|
const { data: devicePriceData } = useQuery({
|
||||||
queryKey: ['device-price', devicesToAdd],
|
queryKey: ['device-price', devicesToAdd, subscriptionId],
|
||||||
queryFn: () => subscriptionApi.getDevicePrice(devicesToAdd),
|
queryFn: () => subscriptionApi.getDevicePrice(devicesToAdd, subscriptionId),
|
||||||
enabled: showDeviceTopup && !!subscription,
|
enabled: showDeviceTopup && !!subscription,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Device purchase mutation
|
// Device purchase mutation
|
||||||
const devicePurchaseMutation = useMutation({
|
const devicePurchaseMutation = useMutation({
|
||||||
mutationFn: () => subscriptionApi.purchaseDevices(devicesToAdd),
|
mutationFn: () => subscriptionApi.purchaseDevices(devicesToAdd, subscriptionId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['devices'] });
|
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['device-price'] });
|
queryClient.invalidateQueries({ queryKey: ['device-price'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['balance'] });
|
||||||
setShowDeviceTopup(false);
|
setShowDeviceTopup(false);
|
||||||
setDevicesToAdd(1);
|
setDevicesToAdd(1);
|
||||||
},
|
},
|
||||||
@@ -288,8 +310,8 @@ export default function Subscription() {
|
|||||||
|
|
||||||
// Device reduction info query
|
// Device reduction info query
|
||||||
const { data: deviceReductionInfo } = useQuery({
|
const { data: deviceReductionInfo } = useQuery({
|
||||||
queryKey: ['device-reduction-info'],
|
queryKey: ['device-reduction-info', subscriptionId],
|
||||||
queryFn: subscriptionApi.getDeviceReductionInfo,
|
queryFn: () => subscriptionApi.getDeviceReductionInfo(subscriptionId),
|
||||||
enabled: showDeviceReduction && !!subscription,
|
enabled: showDeviceReduction && !!subscription,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -307,27 +329,31 @@ export default function Subscription() {
|
|||||||
|
|
||||||
// Device reduction mutation
|
// Device reduction mutation
|
||||||
const deviceReductionMutation = useMutation({
|
const deviceReductionMutation = useMutation({
|
||||||
mutationFn: () => subscriptionApi.reduceDevices(targetDeviceLimit),
|
mutationFn: () => subscriptionApi.reduceDevices(targetDeviceLimit, subscriptionId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['devices'] });
|
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['device-reduction-info'] });
|
queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['device-reduction-info', subscriptionId] });
|
||||||
setShowDeviceReduction(false);
|
setShowDeviceReduction(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Traffic packages query
|
// Traffic packages query
|
||||||
const { data: trafficPackages } = useQuery({
|
const { data: trafficPackages } = useQuery({
|
||||||
queryKey: ['traffic-packages'],
|
queryKey: ['traffic-packages', subscriptionId],
|
||||||
queryFn: subscriptionApi.getTrafficPackages,
|
queryFn: () => subscriptionApi.getTrafficPackages(subscriptionId),
|
||||||
enabled: showTrafficTopup && !!subscription,
|
enabled: showTrafficTopup && !!subscription,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Traffic purchase mutation
|
// Traffic purchase mutation
|
||||||
const trafficPurchaseMutation = useMutation({
|
const trafficPurchaseMutation = useMutation({
|
||||||
mutationFn: (gb: number) => subscriptionApi.purchaseTraffic(gb),
|
mutationFn: (gb: number) => subscriptionApi.purchaseTraffic(gb, subscriptionId),
|
||||||
onSuccess: () => {
|
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);
|
setShowTrafficTopup(false);
|
||||||
setSelectedTrafficPackage(null);
|
setSelectedTrafficPackage(null);
|
||||||
},
|
},
|
||||||
@@ -335,8 +361,8 @@ export default function Subscription() {
|
|||||||
|
|
||||||
// Countries/servers query
|
// Countries/servers query
|
||||||
const { data: countriesData, isLoading: countriesLoading } = useQuery({
|
const { data: countriesData, isLoading: countriesLoading } = useQuery({
|
||||||
queryKey: ['countries'],
|
queryKey: ['countries', subscriptionId],
|
||||||
queryFn: subscriptionApi.getCountries,
|
queryFn: () => subscriptionApi.getCountries(subscriptionId),
|
||||||
enabled: showServerManagement && !!subscription && !subscription.is_trial,
|
enabled: showServerManagement && !!subscription && !subscription.is_trial,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -350,30 +376,34 @@ export default function Subscription() {
|
|||||||
|
|
||||||
// Countries update mutation
|
// Countries update mutation
|
||||||
const updateCountriesMutation = useMutation({
|
const updateCountriesMutation = useMutation({
|
||||||
mutationFn: (countries: string[]) => subscriptionApi.updateCountries(countries),
|
mutationFn: (countries: string[]) => subscriptionApi.updateCountries(countries, subscriptionId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['countries'] });
|
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['countries', subscriptionId] });
|
||||||
setShowServerManagement(false);
|
setShowServerManagement(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Traffic refresh mutation
|
// Traffic refresh mutation
|
||||||
const refreshTrafficMutation = useMutation({
|
const refreshTrafficMutation = useMutation({
|
||||||
mutationFn: subscriptionApi.refreshTraffic,
|
mutationFn: () => subscriptionApi.refreshTraffic(subscriptionId),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setTrafficData({
|
setTrafficData({
|
||||||
traffic_used_gb: data.traffic_used_gb,
|
traffic_used_gb: data.traffic_used_gb,
|
||||||
traffic_used_percent: data.traffic_used_percent,
|
traffic_used_percent: data.traffic_used_percent,
|
||||||
is_unlimited: data.is_unlimited,
|
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) {
|
if (data.rate_limited && data.retry_after_seconds) {
|
||||||
setTrafficRefreshCooldown(data.retry_after_seconds);
|
setTrafficRefreshCooldown(data.retry_after_seconds);
|
||||||
} else {
|
} else {
|
||||||
setTrafficRefreshCooldown(30);
|
setTrafficRefreshCooldown(30);
|
||||||
}
|
}
|
||||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
|
||||||
},
|
},
|
||||||
onError: (error: {
|
onError: (error: {
|
||||||
response?: { status?: number; headers?: { get?: (key: string) => string } };
|
response?: { status?: number; headers?: { get?: (key: string) => string } };
|
||||||
@@ -403,7 +433,7 @@ export default function Subscription() {
|
|||||||
if (hasAutoRefreshed.current) return;
|
if (hasAutoRefreshed.current) return;
|
||||||
hasAutoRefreshed.current = true;
|
hasAutoRefreshed.current = true;
|
||||||
|
|
||||||
const lastRefresh = localStorage.getItem('traffic_refresh_ts');
|
const lastRefresh = localStorage.getItem(`traffic_refresh_ts_${subscriptionId ?? 'default'}`);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const cacheMs = 30 * 1000;
|
const cacheMs = 30 * 1000;
|
||||||
|
|
||||||
@@ -417,7 +447,7 @@ export default function Subscription() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
refreshTrafficMutation.mutate();
|
refreshTrafficMutation.mutate();
|
||||||
}, [subscription, refreshTrafficMutation]);
|
}, [subscription, refreshTrafficMutation, subscriptionId]);
|
||||||
|
|
||||||
const copyUrl = () => {
|
const copyUrl = () => {
|
||||||
if (subscription?.subscription_url) {
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-64 items-center justify-center">
|
<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 (
|
return (
|
||||||
<div className="space-y-6">
|
<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 */}
|
{/* Current Subscription */}
|
||||||
{subscription ? (
|
{subscription ? (
|
||||||
@@ -736,7 +799,7 @@ export default function Subscription() {
|
|||||||
haptic.notification('error');
|
haptic.notification('error');
|
||||||
return;
|
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' : ''}`}
|
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' }}
|
style={{ fontFamily: 'inherit' }}
|
||||||
@@ -1210,7 +1273,100 @@ export default function Subscription() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Purchase / Renewal CTA */}
|
{/* 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) */}
|
{/* Additional Options (Buy Devices) */}
|
||||||
{subscription &&
|
{subscription &&
|
||||||
@@ -1381,7 +1537,7 @@ export default function Subscription() {
|
|||||||
}
|
}
|
||||||
compact
|
compact
|
||||||
onBeforeTopUp={async () => {
|
onBeforeTopUp={async () => {
|
||||||
await subscriptionApi.saveDevicesCart(devicesToAdd);
|
await subscriptionApi.saveDevicesCart(devicesToAdd, subscriptionId);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1721,7 +1877,10 @@ export default function Subscription() {
|
|||||||
compact
|
compact
|
||||||
className="mb-3"
|
className="mb-3"
|
||||||
onBeforeTopUp={async () => {
|
onBeforeTopUp={async () => {
|
||||||
await subscriptionApi.saveTrafficCart(selectedTrafficPackage);
|
await subscriptionApi.saveTrafficCart(
|
||||||
|
selectedTrafficPackage,
|
||||||
|
subscriptionId,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate, useSearchParams } from 'react-router';
|
||||||
import { AxiosError } from 'axios';
|
import { AxiosError } from 'axios';
|
||||||
import { subscriptionApi } from '../api/subscription';
|
import { subscriptionApi } from '../api/subscription';
|
||||||
import { promoApi } from '../api/promo';
|
import { promoApi } from '../api/promo';
|
||||||
|
import { WebBackButton } from '../components/WebBackButton';
|
||||||
import { getGlassColors } from '../utils/glassTheme';
|
import { getGlassColors } from '../utils/glassTheme';
|
||||||
import { useTheme } from '../hooks/useTheme';
|
import { useTheme } from '../hooks/useTheme';
|
||||||
import type {
|
import type {
|
||||||
@@ -28,6 +29,10 @@ export default function SubscriptionPurchase() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const subscriptionId = searchParams.get('subscriptionId')
|
||||||
|
? parseInt(searchParams.get('subscriptionId')!, 10)
|
||||||
|
: undefined;
|
||||||
const { formatAmount, currencySymbol } = useCurrency();
|
const { formatAmount, currencySymbol } = useCurrency();
|
||||||
const { isDark } = useTheme();
|
const { isDark } = useTheme();
|
||||||
const g = getGlassColors(isDark);
|
const g = getGlassColors(isDark);
|
||||||
@@ -36,8 +41,8 @@ export default function SubscriptionPurchase() {
|
|||||||
|
|
||||||
// Subscription query (shares cache with /subscription page)
|
// Subscription query (shares cache with /subscription page)
|
||||||
const { data: subscriptionResponse, isLoading } = useQuery({
|
const { data: subscriptionResponse, isLoading } = useQuery({
|
||||||
queryKey: ['subscription'],
|
queryKey: ['subscription', subscriptionId],
|
||||||
queryFn: subscriptionApi.getSubscription,
|
queryFn: () => subscriptionApi.getSubscription(subscriptionId),
|
||||||
retry: false,
|
retry: false,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
refetchOnMount: 'always',
|
refetchOnMount: 'always',
|
||||||
@@ -51,8 +56,8 @@ export default function SubscriptionPurchase() {
|
|||||||
isError: optionsError,
|
isError: optionsError,
|
||||||
refetch: refetchOptions,
|
refetch: refetchOptions,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ['purchase-options'],
|
queryKey: ['purchase-options', subscriptionId],
|
||||||
queryFn: subscriptionApi.getPurchaseOptions,
|
queryFn: () => subscriptionApi.getPurchaseOptions(subscriptionId),
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
refetchOnMount: 'always',
|
refetchOnMount: 'always',
|
||||||
});
|
});
|
||||||
@@ -70,6 +75,14 @@ export default function SubscriptionPurchase() {
|
|||||||
const tariffs =
|
const tariffs =
|
||||||
isTariffsMode && purchaseOptions && 'tariffs' in purchaseOptions ? purchaseOptions.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
|
// Helper to apply promo discount
|
||||||
const applyPromoDiscount = (
|
const applyPromoDiscount = (
|
||||||
priceKopeks: number,
|
priceKopeks: number,
|
||||||
@@ -217,36 +230,38 @@ export default function SubscriptionPurchase() {
|
|||||||
// Preview query (classic)
|
// Preview query (classic)
|
||||||
const { data: preview, isLoading: previewLoading } = useQuery({
|
const { data: preview, isLoading: previewLoading } = useQuery({
|
||||||
queryKey: ['purchase-preview', currentSelection],
|
queryKey: ['purchase-preview', currentSelection],
|
||||||
queryFn: () => subscriptionApi.previewPurchase(currentSelection),
|
queryFn: () => subscriptionApi.previewPurchase(currentSelection, subscriptionId),
|
||||||
enabled: !!selectedPeriod && showPurchaseForm && currentStep === 'confirm',
|
enabled: !!selectedPeriod && showPurchaseForm && currentStep === 'confirm',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Classic purchase mutation
|
// Classic purchase mutation
|
||||||
const purchaseMutation = useMutation({
|
const purchaseMutation = useMutation({
|
||||||
mutationFn: () => subscriptionApi.submitPurchase(currentSelection),
|
mutationFn: () => subscriptionApi.submitPurchase(currentSelection, subscriptionId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
queryClient.invalidateQueries({ queryKey: ['purchase-options', subscriptionId] });
|
||||||
navigate('/subscription', { replace: true });
|
queryClient.invalidateQueries({ queryKey: ['balance'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||||||
|
navigate('/subscriptions', { replace: true });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Switch preview query
|
// Switch preview query
|
||||||
const { data: switchPreview, isLoading: switchPreviewLoading } = useQuery({
|
const { data: switchPreview, isLoading: switchPreviewLoading } = useQuery({
|
||||||
queryKey: ['tariff-switch-preview', switchTariffId],
|
queryKey: ['tariff-switch-preview', switchTariffId],
|
||||||
queryFn: () => subscriptionApi.previewTariffSwitch(switchTariffId!),
|
queryFn: () => subscriptionApi.previewTariffSwitch(switchTariffId!, subscriptionId),
|
||||||
enabled: !!switchTariffId,
|
enabled: !!switchTariffId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Tariff switch mutation
|
// Tariff switch mutation
|
||||||
const switchTariffMutation = useMutation({
|
const switchTariffMutation = useMutation({
|
||||||
mutationFn: (tariffId: number) => subscriptionApi.switchTariff(tariffId),
|
mutationFn: (tariffId: number) => subscriptionApi.switchTariff(tariffId, subscriptionId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
queryClient.invalidateQueries({ queryKey: ['purchase-options', subscriptionId] });
|
||||||
setSwitchTariffId(null);
|
setSwitchTariffId(null);
|
||||||
|
|
||||||
navigate('/subscription', { replace: true });
|
navigate('/subscriptions', { replace: true });
|
||||||
},
|
},
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
if (error instanceof AxiosError) {
|
if (error instanceof AxiosError) {
|
||||||
@@ -263,7 +278,7 @@ export default function SubscriptionPurchase() {
|
|||||||
setSelectedTariff(targetTariff);
|
setSelectedTariff(targetTariff);
|
||||||
setSelectedTariffPeriod(targetTariff.periods[0] || null);
|
setSelectedTariffPeriod(targetTariff.periods[0] || null);
|
||||||
setShowTariffPurchase(true);
|
setShowTariffPurchase(true);
|
||||||
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
queryClient.invalidateQueries({ queryKey: ['purchase-options', subscriptionId] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -291,7 +306,8 @@ export default function SubscriptionPurchase() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
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)) {
|
if (optionsError || (!purchaseOptions && !optionsLoading)) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-3">
|
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('subscription.extend')}</h1>
|
||||||
<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>
|
|
||||||
<div
|
<div
|
||||||
className="rounded-3xl p-6 text-center"
|
className="rounded-3xl p-6 text-center"
|
||||||
style={{
|
style={{
|
||||||
@@ -421,37 +410,19 @@ export default function SubscriptionPurchase() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header with back link */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<WebBackButton
|
||||||
onClick={() => navigate('/subscription')}
|
to={subscriptionId ? `/subscriptions/${subscriptionId}` : '/subscriptions'}
|
||||||
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">
|
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">
|
||||||
{subscription?.is_daily && !subscription?.is_trial
|
{isMultiTariff && !subscriptionId
|
||||||
? t('subscription.switchTariff.title')
|
? t('subscription.newTariff', 'Новый тариф')
|
||||||
: subscription && !subscription.is_trial
|
: !isMultiTariff && subscription?.is_daily && !subscription?.is_trial
|
||||||
? t('subscription.extend')
|
? t('subscription.switchTariff.title')
|
||||||
: t('subscription.getSubscription')}
|
: subscription && !subscription.is_trial
|
||||||
|
? t('subscription.extend')
|
||||||
|
: t('subscription.getSubscription')}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -748,9 +719,37 @@ export default function SubscriptionPurchase() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tariff Grid */}
|
{/* 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">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
{[...tariffs]
|
{[...tariffs]
|
||||||
.filter((tariff) => {
|
.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')) {
|
if (subscription?.is_trial && tariff.name.toLowerCase().includes('trial')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -772,6 +771,7 @@ export default function SubscriptionPurchase() {
|
|||||||
'subscription_is_expired' in purchaseOptions &&
|
'subscription_is_expired' in purchaseOptions &&
|
||||||
purchaseOptions.subscription_is_expired === true;
|
purchaseOptions.subscription_is_expired === true;
|
||||||
const canSwitch =
|
const canSwitch =
|
||||||
|
!isMultiTariff &&
|
||||||
subscription &&
|
subscription &&
|
||||||
subscription.tariff_id &&
|
subscription.tariff_id &&
|
||||||
!isCurrentTariff &&
|
!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 queryClient = useQueryClient();
|
||||||
const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } =
|
const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } =
|
||||||
useCurrency();
|
useCurrency();
|
||||||
const { openInvoice, openTelegramLink, openLink } = usePlatform();
|
const { openInvoice, openTelegramLink, openLink, platform } = usePlatform();
|
||||||
const haptic = useHaptic();
|
const haptic = useHaptic();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
|
if (platform === 'telegram') return;
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (inputRef.current) {
|
if (inputRef.current) {
|
||||||
inputRef.current.focus();
|
inputRef.current.focus();
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, []);
|
}, [platform]);
|
||||||
|
|
||||||
if (!method) {
|
if (!method) {
|
||||||
return (
|
return (
|
||||||
@@ -399,7 +400,6 @@ export default function TopUpAmount() {
|
|||||||
placeholder="0"
|
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"
|
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"
|
autoComplete="off"
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-base font-semibold text-dark-500">
|
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-base font-semibold text-dark-500">
|
||||||
{currencySymbol}
|
{currencySymbol}
|
||||||
|
|||||||
@@ -305,7 +305,10 @@ export default function TopUpResult() {
|
|||||||
clearTopUpPendingInfo();
|
clearTopUpPendingInfo();
|
||||||
queryClient.invalidateQueries({ queryKey: ['balance'] });
|
queryClient.invalidateQueries({ queryKey: ['balance'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['transactions'] });
|
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'] });
|
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
||||||
refreshUser();
|
refreshUser();
|
||||||
} else if (resolvedFailed) {
|
} else if (resolvedFailed) {
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export default function Wheel() {
|
|||||||
const [isPayingStars, setIsPayingStars] = useState(false);
|
const [isPayingStars, setIsPayingStars] = useState(false);
|
||||||
const [historyExpanded, setHistoryExpanded] = useState(false);
|
const [historyExpanded, setHistoryExpanded] = useState(false);
|
||||||
const [showStarsConfirm, setShowStarsConfirm] = useState(false);
|
const [showStarsConfirm, setShowStarsConfirm] = useState(false);
|
||||||
|
const [selectedSubscriptionId, setSelectedSubscriptionId] = useState<number | null>(null);
|
||||||
const paymentTypeInitialized = useRef(false);
|
const paymentTypeInitialized = useRef(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -136,6 +137,11 @@ export default function Wheel() {
|
|||||||
} else if (daysEnabled) {
|
} else if (daysEnabled) {
|
||||||
setPaymentType('subscription_days');
|
setPaymentType('subscription_days');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-select subscription if only one eligible
|
||||||
|
if (config.eligible_subscriptions?.length === 1) {
|
||||||
|
setSelectedSubscriptionId(config.eligible_subscriptions[0].id);
|
||||||
|
}
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
// Function to poll for new spin result after Stars payment
|
// Function to poll for new spin result after Stars payment
|
||||||
@@ -368,7 +374,7 @@ export default function Wheel() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const spinMutation = useMutation({
|
const spinMutation = useMutation({
|
||||||
mutationFn: () => wheelApi.spin(paymentType),
|
mutationFn: () => wheelApi.spin(paymentType, selectedSubscriptionId ?? undefined),
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setTargetRotation(result.rotation_degrees);
|
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
|
// 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 dailyLimitReached = config.daily_limit > 0 && config.user_spins_today >= config.daily_limit;
|
||||||
const noSubscription = !config.has_subscription;
|
const noSubscription = !config.has_subscription;
|
||||||
|
const needsSubscriptionPick =
|
||||||
|
paymentType === 'subscription_days' &&
|
||||||
|
config.eligible_subscriptions &&
|
||||||
|
config.eligible_subscriptions.length > 1 &&
|
||||||
|
!selectedSubscriptionId;
|
||||||
|
|
||||||
const spinDisabled =
|
const spinDisabled =
|
||||||
isSpinning ||
|
isSpinning ||
|
||||||
isPayingStars ||
|
isPayingStars ||
|
||||||
dailyLimitReached ||
|
dailyLimitReached ||
|
||||||
noSubscription ||
|
noSubscription ||
|
||||||
|
needsSubscriptionPick ||
|
||||||
(paymentType === 'telegram_stars' ? !starsEnabled : !config.can_spin);
|
(paymentType === 'telegram_stars' ? !starsEnabled : !config.can_spin);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -582,6 +595,38 @@ export default function Wheel() {
|
|||||||
</div>
|
</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 */}
|
{/* Stars confirmation panel */}
|
||||||
{showStarsConfirm && !isSpinning && !isPayingStars ? (
|
{showStarsConfirm && !isSpinning && !isPayingStars ? (
|
||||||
<div className="space-y-3 rounded-xl border border-accent-500/30 bg-accent-500/5 p-4">
|
<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>
|
<p className="text-dark-400">{t('wheel.errors.dailyLimitReached')}</p>
|
||||||
</div>
|
</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 */}
|
{/* Inline Result Card */}
|
||||||
{spinResult && !isSpinning && (
|
{spinResult && !isSpinning && (
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface WSMessage {
|
|||||||
new_balance_rubles?: number;
|
new_balance_rubles?: number;
|
||||||
description?: string;
|
description?: string;
|
||||||
// Subscription events
|
// Subscription events
|
||||||
|
subscription_id?: number;
|
||||||
expires_at?: string;
|
expires_at?: string;
|
||||||
new_expires_at?: string;
|
new_expires_at?: string;
|
||||||
tariff_name?: string;
|
tariff_name?: string;
|
||||||
|
|||||||
@@ -1642,3 +1642,82 @@ input[type='checkbox']:hover:not(:checked) {
|
|||||||
.light .sheet-handle {
|
.light .sheet-handle {
|
||||||
@apply bg-champagne-400;
|
@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;
|
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
|
// Device types
|
||||||
export interface Device {
|
export interface Device {
|
||||||
hwid: string;
|
hwid: string;
|
||||||
@@ -320,6 +345,8 @@ export interface Tariff {
|
|||||||
custom_days_discount_percent?: number;
|
custom_days_discount_percent?: number;
|
||||||
// Traffic reset
|
// Traffic reset
|
||||||
traffic_reset_mode?: string;
|
traffic_reset_mode?: string;
|
||||||
|
// Multi-tariff: already purchased by user
|
||||||
|
is_purchased?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TariffsPurchaseOptions {
|
export interface TariffsPurchaseOptions {
|
||||||
@@ -332,6 +359,8 @@ export interface TariffsPurchaseOptions {
|
|||||||
subscription_status?: string;
|
subscription_status?: string;
|
||||||
subscription_is_expired?: boolean;
|
subscription_is_expired?: boolean;
|
||||||
has_subscription?: boolean;
|
has_subscription?: boolean;
|
||||||
|
// Multi-tariff: all available tariffs already purchased
|
||||||
|
all_tariffs_purchased?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClassicPurchaseOptions {
|
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 {
|
export function formatPrice(kopeks: number): string {
|
||||||
const rubles = kopeks / 100;
|
const rubles = kopeks / 100;
|
||||||
return new Intl.NumberFormat('ru-RU', {
|
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