mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
58
src/App.tsx
58
src/App.tsx
@@ -123,6 +123,7 @@ const AdminRemnawave = lazyWithRetry(() => import('./pages/AdminRemnawave'));
|
|||||||
const AdminRemnawaveSquadDetail = lazyWithRetry(() => import('./pages/AdminRemnawaveSquadDetail'));
|
const AdminRemnawaveSquadDetail = lazyWithRetry(() => import('./pages/AdminRemnawaveSquadDetail'));
|
||||||
const AdminEmailTemplates = lazyWithRetry(() => import('./pages/AdminEmailTemplates'));
|
const AdminEmailTemplates = lazyWithRetry(() => import('./pages/AdminEmailTemplates'));
|
||||||
const AdminTrafficUsage = lazyWithRetry(() => import('./pages/AdminTrafficUsage'));
|
const AdminTrafficUsage = lazyWithRetry(() => import('./pages/AdminTrafficUsage'));
|
||||||
|
const AdminBulkActions = lazyWithRetry(() => import('./pages/AdminBulkActions'));
|
||||||
const AdminSalesStats = lazyWithRetry(() => import('./pages/AdminSalesStats'));
|
const AdminSalesStats = lazyWithRetry(() => import('./pages/AdminSalesStats'));
|
||||||
const AdminUpdates = lazyWithRetry(() => import('./pages/AdminUpdates'));
|
const AdminUpdates = lazyWithRetry(() => import('./pages/AdminUpdates'));
|
||||||
const AdminUserDetail = lazyWithRetry(() => import('./pages/AdminUserDetail'));
|
const AdminUserDetail = lazyWithRetry(() => import('./pages/AdminUserDetail'));
|
||||||
@@ -147,6 +148,11 @@ const NewsArticlePage = lazyWithRetry(() => import('./pages/NewsArticle'));
|
|||||||
const AdminNews = lazyWithRetry(() => import('./pages/AdminNews'));
|
const AdminNews = lazyWithRetry(() => import('./pages/AdminNews'));
|
||||||
const AdminNewsCreate = lazyWithRetry(() => import('./pages/AdminNewsCreate'));
|
const AdminNewsCreate = lazyWithRetry(() => import('./pages/AdminNewsCreate'));
|
||||||
|
|
||||||
|
// Info pages
|
||||||
|
const InfoPageView = lazyWithRetry(() => import('./pages/InfoPageView'));
|
||||||
|
const AdminInfoPages = lazyWithRetry(() => import('./pages/AdminInfoPages'));
|
||||||
|
const AdminInfoPageEditor = lazyWithRetry(() => import('./pages/AdminInfoPageEditor'));
|
||||||
|
|
||||||
function ProtectedRoute({
|
function ProtectedRoute({
|
||||||
children,
|
children,
|
||||||
withLayout = true,
|
withLayout = true,
|
||||||
@@ -554,6 +560,16 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/info/:slug"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<LazyPage>
|
||||||
|
<InfoPageView />
|
||||||
|
</LazyPage>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Admin routes */}
|
{/* Admin routes */}
|
||||||
<Route
|
<Route
|
||||||
@@ -966,6 +982,16 @@ function App() {
|
|||||||
</PermissionRoute>
|
</PermissionRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/bulk-actions"
|
||||||
|
element={
|
||||||
|
<PermissionRoute permission="users:read">
|
||||||
|
<LazyPage>
|
||||||
|
<AdminBulkActions />
|
||||||
|
</LazyPage>
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/admin/payments"
|
path="/admin/payments"
|
||||||
element={
|
element={
|
||||||
@@ -1270,6 +1296,38 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Info pages admin routes */}
|
||||||
|
<Route
|
||||||
|
path="/admin/info-pages"
|
||||||
|
element={
|
||||||
|
<PermissionRoute permission="settings:read">
|
||||||
|
<LazyPage>
|
||||||
|
<AdminInfoPages />
|
||||||
|
</LazyPage>
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/info-pages/create"
|
||||||
|
element={
|
||||||
|
<PermissionRoute permission="settings:edit">
|
||||||
|
<LazyPage>
|
||||||
|
<AdminInfoPageEditor />
|
||||||
|
</LazyPage>
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/info-pages/:id/edit"
|
||||||
|
element={
|
||||||
|
<PermissionRoute permission="settings:edit">
|
||||||
|
<LazyPage>
|
||||||
|
<AdminInfoPageEditor />
|
||||||
|
</LazyPage>
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/admin/audit-log"
|
path="/admin/audit-log"
|
||||||
element={
|
element={
|
||||||
|
|||||||
167
src/api/adminBulkActions.ts
Normal file
167
src/api/adminBulkActions.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import apiClient from './client';
|
||||||
|
import { tokenStorage } from '../utils/token';
|
||||||
|
|
||||||
|
export type BulkActionType =
|
||||||
|
| 'extend_subscription'
|
||||||
|
| 'add_days'
|
||||||
|
| 'cancel_subscription'
|
||||||
|
| 'activate_subscription'
|
||||||
|
| 'change_tariff'
|
||||||
|
| 'add_traffic'
|
||||||
|
| 'add_balance'
|
||||||
|
| 'assign_promo_group'
|
||||||
|
| 'grant_subscription'
|
||||||
|
| 'set_devices'
|
||||||
|
| 'delete_subscription'
|
||||||
|
| 'delete_user';
|
||||||
|
|
||||||
|
export interface BulkActionRequest {
|
||||||
|
action: BulkActionType;
|
||||||
|
user_ids?: number[];
|
||||||
|
subscription_ids?: number[];
|
||||||
|
params: BulkActionParams;
|
||||||
|
dry_run?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BulkActionParams {
|
||||||
|
days?: number;
|
||||||
|
tariff_id?: number;
|
||||||
|
traffic_gb?: number;
|
||||||
|
amount_kopeks?: number;
|
||||||
|
balance_description?: string;
|
||||||
|
promo_group_id?: number | null;
|
||||||
|
device_limit?: number;
|
||||||
|
delete_from_panel?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BulkActionErrorItem {
|
||||||
|
user_id: number;
|
||||||
|
username?: string;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BulkActionResult {
|
||||||
|
success: boolean;
|
||||||
|
total: number;
|
||||||
|
success_count: number;
|
||||||
|
error_count: number;
|
||||||
|
skipped_count: number;
|
||||||
|
errors: BulkActionErrorItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BulkProgressEvent {
|
||||||
|
type: 'progress';
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
user_id: number;
|
||||||
|
subscription_id?: number;
|
||||||
|
username?: string;
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BulkCompleteEvent {
|
||||||
|
type: 'complete';
|
||||||
|
total: number;
|
||||||
|
success_count: number;
|
||||||
|
error_count: number;
|
||||||
|
skipped_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BulkSSEEvent = BulkProgressEvent | BulkCompleteEvent;
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
|
export const adminBulkActionsApi = {
|
||||||
|
execute: async (data: BulkActionRequest): Promise<BulkActionResult> => {
|
||||||
|
const response = await apiClient.post('/cabinet/admin/bulk/execute', data);
|
||||||
|
const raw = response.data;
|
||||||
|
// Transform backend results[] to frontend errors[]
|
||||||
|
const errors: BulkActionErrorItem[] = (raw.results || [])
|
||||||
|
.filter((r: { success: boolean }) => !r.success)
|
||||||
|
.map((r: { user_id: number; username?: string; message?: string }) => ({
|
||||||
|
user_id: r.user_id,
|
||||||
|
username: r.username,
|
||||||
|
error: r.message || 'Unknown error',
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
success: raw.error_count === 0,
|
||||||
|
total: raw.total,
|
||||||
|
success_count: raw.success_count,
|
||||||
|
error_count: raw.error_count,
|
||||||
|
skipped_count: raw.skipped_count || 0,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
executeWithStream: async (
|
||||||
|
data: BulkActionRequest,
|
||||||
|
onEvent: (event: BulkSSEEvent) => void,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<void> => {
|
||||||
|
const token = tokenStorage.getAccessToken();
|
||||||
|
const response = await fetch(`${API_BASE_URL}/cabinet/admin/bulk/execute?stream=true`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') || '';
|
||||||
|
|
||||||
|
if (contentType.includes('text/event-stream') && response.body) {
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed.startsWith('data: ')) {
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(trimmed.slice(6)) as BulkSSEEvent;
|
||||||
|
onEvent(event);
|
||||||
|
} catch {
|
||||||
|
// skip malformed SSE lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// process remaining buffer
|
||||||
|
if (buffer.trim().startsWith('data: ')) {
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(buffer.trim().slice(6)) as BulkSSEEvent;
|
||||||
|
onEvent(event);
|
||||||
|
} catch {
|
||||||
|
// skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: non-streaming JSON response
|
||||||
|
const raw = await response.json();
|
||||||
|
onEvent({
|
||||||
|
type: 'complete',
|
||||||
|
total: raw.total,
|
||||||
|
success_count: raw.success_count,
|
||||||
|
error_count: raw.error_count,
|
||||||
|
skipped_count: raw.skipped_count || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -33,6 +33,18 @@ export interface UserPromoGroupInfo {
|
|||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserListItemSubscription {
|
||||||
|
id: number;
|
||||||
|
tariff_id: number | null;
|
||||||
|
tariff_name: string | null;
|
||||||
|
status: string;
|
||||||
|
end_date: string | null;
|
||||||
|
days_remaining: number;
|
||||||
|
traffic_used_gb: number;
|
||||||
|
traffic_limit_gb: number;
|
||||||
|
device_limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserListItem {
|
export interface UserListItem {
|
||||||
id: number;
|
id: number;
|
||||||
telegram_id: number;
|
telegram_id: number;
|
||||||
@@ -49,6 +61,12 @@ export interface UserListItem {
|
|||||||
subscription_status: string | null;
|
subscription_status: string | null;
|
||||||
subscription_is_trial: boolean;
|
subscription_is_trial: boolean;
|
||||||
subscription_end_date: string | null;
|
subscription_end_date: string | null;
|
||||||
|
tariff_id: number | null;
|
||||||
|
tariff_name: string | null;
|
||||||
|
traffic_used_gb: number;
|
||||||
|
traffic_limit_gb: number;
|
||||||
|
device_limit: number;
|
||||||
|
days_remaining: number;
|
||||||
promo_group_id: number | null;
|
promo_group_id: number | null;
|
||||||
promo_group_name: string | null;
|
promo_group_name: string | null;
|
||||||
total_spent_kopeks: number;
|
total_spent_kopeks: number;
|
||||||
@@ -56,6 +74,7 @@ export interface UserListItem {
|
|||||||
has_restrictions: boolean;
|
has_restrictions: boolean;
|
||||||
restriction_topup: boolean;
|
restriction_topup: boolean;
|
||||||
restriction_subscription: boolean;
|
restriction_subscription: boolean;
|
||||||
|
subscriptions?: UserListItemSubscription[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UsersListResponse {
|
export interface UsersListResponse {
|
||||||
@@ -392,6 +411,11 @@ export const adminUsersApi = {
|
|||||||
search?: string;
|
search?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
status?: 'active' | 'blocked' | 'deleted';
|
status?: 'active' | 'blocked' | 'deleted';
|
||||||
|
subscription_status?: string;
|
||||||
|
tariff_id?: string;
|
||||||
|
promo_group_id?: number;
|
||||||
|
campaign_id?: number;
|
||||||
|
partner_id?: number;
|
||||||
sort_by?:
|
sort_by?:
|
||||||
| 'created_at'
|
| 'created_at'
|
||||||
| 'balance'
|
| 'balance'
|
||||||
|
|||||||
125
src/api/infoPages.ts
Normal file
125
src/api/infoPages.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
export type InfoPageType = 'page' | 'faq';
|
||||||
|
|
||||||
|
export type ReplacesTab = 'faq' | 'rules' | 'privacy' | 'offer';
|
||||||
|
|
||||||
|
export interface InfoPage {
|
||||||
|
id: number;
|
||||||
|
slug: string;
|
||||||
|
title: Record<string, string>;
|
||||||
|
content: Record<string, string>;
|
||||||
|
page_type: InfoPageType;
|
||||||
|
is_active: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
icon: string | null;
|
||||||
|
replaces_tab: ReplacesTab | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InfoPageListItem {
|
||||||
|
id: number;
|
||||||
|
slug: string;
|
||||||
|
title: Record<string, string>;
|
||||||
|
page_type: InfoPageType;
|
||||||
|
is_active: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
icon: string | null;
|
||||||
|
replaces_tab: ReplacesTab | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InfoPageCreateRequest {
|
||||||
|
slug: string;
|
||||||
|
title: Record<string, string>;
|
||||||
|
content: Record<string, string>;
|
||||||
|
page_type: InfoPageType;
|
||||||
|
is_active: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
icon: string | null;
|
||||||
|
replaces_tab: ReplacesTab | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InfoPageUpdateRequest {
|
||||||
|
slug?: string;
|
||||||
|
title?: Record<string, string>;
|
||||||
|
content?: Record<string, string>;
|
||||||
|
page_type?: InfoPageType;
|
||||||
|
is_active?: boolean;
|
||||||
|
sort_order?: number;
|
||||||
|
icon?: string | null;
|
||||||
|
replaces_tab?: ReplacesTab | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TabReplacements = Record<ReplacesTab, string | null>;
|
||||||
|
|
||||||
|
/** Single FAQ Q&A item stored in content JSONB. */
|
||||||
|
export interface FaqItem {
|
||||||
|
q: string;
|
||||||
|
a: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InfoPageReorderRequest {
|
||||||
|
items: Array<{ id: number; sort_order: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const infoPagesApi = {
|
||||||
|
// Public endpoints
|
||||||
|
getTabReplacements: async (): Promise<TabReplacements> => {
|
||||||
|
const response = await apiClient.get<TabReplacements>('/cabinet/info-pages/tab-replacements');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getPages: async (pageType?: InfoPageType): Promise<InfoPageListItem[]> => {
|
||||||
|
const params = pageType ? { page_type: pageType } : undefined;
|
||||||
|
const response = await apiClient.get<InfoPageListItem[]>('/cabinet/info-pages', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getPageBySlug: async (slug: string): Promise<InfoPage> => {
|
||||||
|
const response = await apiClient.get<InfoPage>(
|
||||||
|
`/cabinet/info-pages/${encodeURIComponent(slug)}`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Admin endpoints
|
||||||
|
getAdminPages: async (pageType?: InfoPageType): Promise<InfoPageListItem[]> => {
|
||||||
|
const params = pageType ? { page_type: pageType } : undefined;
|
||||||
|
const response = await apiClient.get<InfoPageListItem[]>('/cabinet/admin/info-pages', {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getAdminPage: async (id: number): Promise<InfoPage> => {
|
||||||
|
const response = await apiClient.get<InfoPage>(`/cabinet/admin/info-pages/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createPage: async (data: InfoPageCreateRequest): Promise<InfoPage> => {
|
||||||
|
const response = await apiClient.post<InfoPage>('/cabinet/admin/info-pages', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updatePage: async (id: number, data: InfoPageUpdateRequest): Promise<InfoPage> => {
|
||||||
|
const response = await apiClient.put<InfoPage>(`/cabinet/admin/info-pages/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deletePage: async (id: number): Promise<void> => {
|
||||||
|
await apiClient.delete(`/cabinet/admin/info-pages/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleActive: async (id: number): Promise<InfoPage> => {
|
||||||
|
const response = await apiClient.post<InfoPage>(
|
||||||
|
`/cabinet/admin/info-pages/${id}/toggle-active`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
reorder: async (data: InfoPageReorderRequest): Promise<void> => {
|
||||||
|
await apiClient.post('/cabinet/admin/info-pages/reorder', data);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1125,7 +1125,9 @@
|
|||||||
"salesStats": "Sales Statistics",
|
"salesStats": "Sales Statistics",
|
||||||
"landings": "Landings",
|
"landings": "Landings",
|
||||||
"referralNetwork": "Referral Network",
|
"referralNetwork": "Referral Network",
|
||||||
"news": "News"
|
"news": "News",
|
||||||
|
"bulkActions": "Bulk Actions",
|
||||||
|
"infoPages": "Info Pages"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "Admin Panel",
|
"title": "Admin Panel",
|
||||||
@@ -2021,6 +2023,113 @@
|
|||||||
"telegramOidcClientSecret": "Client Secret"
|
"telegramOidcClientSecret": "Client Secret"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"bulkActions": {
|
||||||
|
"title": "Bulk Actions",
|
||||||
|
"subtitle": "User management",
|
||||||
|
"selectedCount": "Selected: {{count}}",
|
||||||
|
"selectAll": "Select all",
|
||||||
|
"deselectAll": "Deselect all",
|
||||||
|
"selectUser": "Select user {{name}}",
|
||||||
|
"deselectUser": "Deselect user {{name}}",
|
||||||
|
"noResults": "No users found",
|
||||||
|
"perPage": "Per page",
|
||||||
|
"actions": {
|
||||||
|
"extend": "Extend subscription",
|
||||||
|
"cancel": "Deactivate subscriptions",
|
||||||
|
"activate": "Activate subscriptions",
|
||||||
|
"changeTariff": "Change tariff",
|
||||||
|
"addTraffic": "Add traffic",
|
||||||
|
"addBalance": "Add balance",
|
||||||
|
"assignPromoGroup": "Assign promo group",
|
||||||
|
"grantSubscription": "Grant subscription",
|
||||||
|
"setDevices": "Set devices",
|
||||||
|
"deleteSubscription": "Delete subscriptions",
|
||||||
|
"deleteUser": "Delete users"
|
||||||
|
},
|
||||||
|
"deleteSubscription": {
|
||||||
|
"warning": "Selected subscriptions will be permanently deleted from the bot and RemnaWave panel!",
|
||||||
|
"hint": "Users will lose VPN access for deleted subscriptions. This action cannot be undone."
|
||||||
|
},
|
||||||
|
"deleteUser": {
|
||||||
|
"warning": "Selected users will be permanently deleted from the bot! All their subscriptions, balance, and data will be lost!",
|
||||||
|
"hint": "This action cannot be undone. Users will need to restart the bot to create a new account.",
|
||||||
|
"deleteFromPanel": "Also delete from RemnaWave panel"
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"days": "Number of days",
|
||||||
|
"tariff": "Select tariff",
|
||||||
|
"trafficGb": "Traffic amount (GB)",
|
||||||
|
"balanceRub": "Amount (RUB)",
|
||||||
|
"promoGroup": "Promo group",
|
||||||
|
"removePromoGroup": "Remove promo group",
|
||||||
|
"deviceLimit": "Device count"
|
||||||
|
},
|
||||||
|
"grantSubscription": {
|
||||||
|
"warning": "Users who already have a subscription on the selected tariff will be skipped"
|
||||||
|
},
|
||||||
|
"confirm": "Apply",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"executing": "Executing...",
|
||||||
|
"complete": "Complete",
|
||||||
|
"successCount": "Succeeded: {{count}}",
|
||||||
|
"errorCount": "Errors: {{count}}",
|
||||||
|
"progress": {
|
||||||
|
"processed": "Processed: {{current}} / {{total}}",
|
||||||
|
"succeeded": "succeeded",
|
||||||
|
"failed": "failed",
|
||||||
|
"ok": "OK",
|
||||||
|
"errorGeneric": "Error",
|
||||||
|
"doNotClose": "Please do not close this window while the operation is in progress",
|
||||||
|
"summarySuccess": "{{count}} succeeded",
|
||||||
|
"summaryErrors": "{{count}} errors"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"title": "{{count}} errors — show details"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"search": "Search by ID, username, email",
|
||||||
|
"status": "Subscription status",
|
||||||
|
"tariff": "Tariff",
|
||||||
|
"promoGroup": "Promo group",
|
||||||
|
"allStatuses": "All statuses",
|
||||||
|
"allTariffs": "All tariffs",
|
||||||
|
"allGroups": "All groups",
|
||||||
|
"allCampaigns": "All campaigns",
|
||||||
|
"allPartners": "All partners",
|
||||||
|
"trialOnly": "Trial only",
|
||||||
|
"tariffsSelected": "{{count}} tariffs",
|
||||||
|
"selectAll": "Select all",
|
||||||
|
"deselectAll": "Deselect all"
|
||||||
|
},
|
||||||
|
"statuses": {
|
||||||
|
"active": "Active",
|
||||||
|
"expired": "Expired",
|
||||||
|
"trial": "Trial",
|
||||||
|
"limited": "Limited",
|
||||||
|
"disabled": "Disabled"
|
||||||
|
},
|
||||||
|
"columns": {
|
||||||
|
"user": "User",
|
||||||
|
"status": "Status",
|
||||||
|
"tariff": "Tariff",
|
||||||
|
"balance": "Balance",
|
||||||
|
"daysRemaining": "Days",
|
||||||
|
"promoGroup": "Group",
|
||||||
|
"spent": "Spent"
|
||||||
|
},
|
||||||
|
"subscriptionsSelected": "{{count}} subscriptions selected",
|
||||||
|
"usersSelected": "{{count}} users selected",
|
||||||
|
"expandSubscriptions": "Show subscriptions",
|
||||||
|
"collapseSubscriptions": "Hide subscriptions",
|
||||||
|
"noSubscriptions": "No subscriptions",
|
||||||
|
"subscriptionTarget": "Target: subscriptions",
|
||||||
|
"userTarget": "Target: users",
|
||||||
|
"daysUnit": "d",
|
||||||
|
"trafficOf": "of",
|
||||||
|
"trafficGbUnit": "GB",
|
||||||
|
"selectAllSubs": "Select all subscriptions",
|
||||||
|
"deselectAllSubs": "Deselect all subscriptions"
|
||||||
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"accentColor": "Accent color",
|
"accentColor": "Accent color",
|
||||||
"customizeColors": "Customize colors",
|
"customizeColors": "Customize colors",
|
||||||
@@ -3698,6 +3807,76 @@
|
|||||||
"prev": "Previous",
|
"prev": "Previous",
|
||||||
"next": "Next"
|
"next": "Next"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"infoPages": {
|
||||||
|
"title": "Information Pages",
|
||||||
|
"subtitle": "Manage static information pages",
|
||||||
|
"create": "Create Page",
|
||||||
|
"createFaq": "Create FAQ",
|
||||||
|
"edit": "Edit",
|
||||||
|
"save": "Save",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"saved": "Page saved",
|
||||||
|
"delete": "Delete",
|
||||||
|
"confirmDelete": "Delete this page?",
|
||||||
|
"saveError": "Failed to save page. Please try again.",
|
||||||
|
"noPages": "No information pages yet",
|
||||||
|
"notFound": "Page not found",
|
||||||
|
"active": "Active",
|
||||||
|
"inactive": "Inactive",
|
||||||
|
"typePage": "Page",
|
||||||
|
"localeLabel": "Content Language",
|
||||||
|
"fields": {
|
||||||
|
"slug": "URL Slug",
|
||||||
|
"title": "Title",
|
||||||
|
"content": "Content",
|
||||||
|
"isActive": "Active",
|
||||||
|
"sortOrder": "Sort Order",
|
||||||
|
"icon": "Icon (emoji)",
|
||||||
|
"pageType": "Page Type",
|
||||||
|
"replacesTab": "Replaces Tab"
|
||||||
|
},
|
||||||
|
"replacesTabNone": "None",
|
||||||
|
"replacesTabOptions": {
|
||||||
|
"faq": "FAQ",
|
||||||
|
"rules": "Rules",
|
||||||
|
"privacy": "Privacy",
|
||||||
|
"offer": "Offer"
|
||||||
|
},
|
||||||
|
"replacesTabConflict": "already assigned",
|
||||||
|
"replacesTabWarning": "Another page already replaces this tab. It will be unassigned on save.",
|
||||||
|
"filter": {
|
||||||
|
"all": "All",
|
||||||
|
"page": "Pages",
|
||||||
|
"faq": "FAQ"
|
||||||
|
},
|
||||||
|
"pageTypes": {
|
||||||
|
"page": "Page",
|
||||||
|
"faq": "FAQ"
|
||||||
|
},
|
||||||
|
"faq": {
|
||||||
|
"questions": "Questions & Answers",
|
||||||
|
"questionsCount": "questions",
|
||||||
|
"noQuestions": "No questions yet. Click the button below to add one.",
|
||||||
|
"questionNumber": "Question #{{n}}",
|
||||||
|
"question": "Question",
|
||||||
|
"answer": "Answer",
|
||||||
|
"questionPlaceholder": "Enter question...",
|
||||||
|
"answerPlaceholder": "Enter answer (HTML supported)...",
|
||||||
|
"htmlHint": "HTML markup is supported",
|
||||||
|
"addQuestion": "Add Question",
|
||||||
|
"removeQuestion": "Remove question",
|
||||||
|
"moveUp": "Move up",
|
||||||
|
"moveDown": "Move down",
|
||||||
|
"searchPlaceholder": "Search questions...",
|
||||||
|
"noResults": "No results found"
|
||||||
|
},
|
||||||
|
"locales": {
|
||||||
|
"ru": "Russian",
|
||||||
|
"en": "English",
|
||||||
|
"zh": "Chinese",
|
||||||
|
"fa": "Persian"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"adminUpdates": {
|
"adminUpdates": {
|
||||||
|
|||||||
@@ -949,7 +949,8 @@
|
|||||||
"salesStats": "آمار فروش",
|
"salesStats": "آمار فروش",
|
||||||
"landings": "صفحات فرود",
|
"landings": "صفحات فرود",
|
||||||
"referralNetwork": "شبکه ارجاع",
|
"referralNetwork": "شبکه ارجاع",
|
||||||
"news": "اخبار"
|
"news": "اخبار",
|
||||||
|
"infoPages": "صفحات اطلاعات"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "پنل مدیریت",
|
"title": "پنل مدیریت",
|
||||||
@@ -3366,6 +3367,183 @@
|
|||||||
"periodDaySuffix": "روز",
|
"periodDaySuffix": "روز",
|
||||||
"localeTab": "زبان",
|
"localeTab": "زبان",
|
||||||
"localeHint": "زبان را تغییر دهید تا متن را به آن زبان ویرایش کنید"
|
"localeHint": "زبان را تغییر دهید تا متن را به آن زبان ویرایش کنید"
|
||||||
|
},
|
||||||
|
"infoPages": {
|
||||||
|
"title": "صفحات اطلاعات",
|
||||||
|
"subtitle": "مدیریت صفحات اطلاعات ثابت",
|
||||||
|
"create": "ایجاد صفحه",
|
||||||
|
"createFaq": "ایجاد FAQ",
|
||||||
|
"edit": "ویرایش",
|
||||||
|
"save": "ذخیره",
|
||||||
|
"saving": "در حال ذخیره...",
|
||||||
|
"saved": "صفحه ذخیره شد",
|
||||||
|
"delete": "حذف",
|
||||||
|
"confirmDelete": "این صفحه حذف شود؟",
|
||||||
|
"saveError": "ذخیره صفحه ناموفق بود. لطفاً دوباره تلاش کنید.",
|
||||||
|
"noPages": "هنوز صفحه اطلاعاتی وجود ندارد",
|
||||||
|
"notFound": "صفحه پیدا نشد",
|
||||||
|
"active": "فعال",
|
||||||
|
"inactive": "غیرفعال",
|
||||||
|
"typePage": "صفحه",
|
||||||
|
"localeLabel": "زبان محتوا",
|
||||||
|
"fields": {
|
||||||
|
"slug": "شناسه URL",
|
||||||
|
"title": "عنوان",
|
||||||
|
"content": "محتوا",
|
||||||
|
"isActive": "فعال",
|
||||||
|
"sortOrder": "ترتیب",
|
||||||
|
"icon": "آیکون (ایموجی)",
|
||||||
|
"pageType": "نوع صفحه",
|
||||||
|
"replacesTab": "جایگزینی تب"
|
||||||
|
},
|
||||||
|
"replacesTabNone": "بدون جایگزینی",
|
||||||
|
"replacesTabOptions": {
|
||||||
|
"faq": "FAQ",
|
||||||
|
"rules": "قوانین",
|
||||||
|
"privacy": "حریم خصوصی",
|
||||||
|
"offer": "پیشنهاد"
|
||||||
|
},
|
||||||
|
"replacesTabConflict": "قبلا اختصاص داده شده",
|
||||||
|
"replacesTabWarning": "صفحه دیگری قبلا این تب را جایگزین کرده است. با ذخیره، آن صفحه حذف خواهد شد.",
|
||||||
|
"filter": {
|
||||||
|
"all": "همه",
|
||||||
|
"page": "صفحات",
|
||||||
|
"faq": "FAQ"
|
||||||
|
},
|
||||||
|
"pageTypes": {
|
||||||
|
"page": "صفحه",
|
||||||
|
"faq": "FAQ"
|
||||||
|
},
|
||||||
|
"faq": {
|
||||||
|
"questions": "سوالات و پاسخها",
|
||||||
|
"questionsCount": "سوال",
|
||||||
|
"noQuestions": "هنوز سوالی وجود ندارد. روی دکمه زیر کلیک کنید.",
|
||||||
|
"questionNumber": "سوال #{{n}}",
|
||||||
|
"question": "سوال",
|
||||||
|
"answer": "پاسخ",
|
||||||
|
"questionPlaceholder": "سوال را وارد کنید...",
|
||||||
|
"answerPlaceholder": "پاسخ را وارد کنید (HTML پشتیبانی میشود)...",
|
||||||
|
"htmlHint": "نشانهگذاری HTML پشتیبانی میشود",
|
||||||
|
"addQuestion": "افزودن سوال",
|
||||||
|
"removeQuestion": "حذف سوال",
|
||||||
|
"moveUp": "انتقال به بالا",
|
||||||
|
"moveDown": "انتقال به پایین",
|
||||||
|
"searchPlaceholder": "جستجو در سوالات...",
|
||||||
|
"noResults": "نتیجهای یافت نشد"
|
||||||
|
},
|
||||||
|
"locales": {
|
||||||
|
"ru": "روسی",
|
||||||
|
"en": "انگلیسی",
|
||||||
|
"zh": "چینی",
|
||||||
|
"fa": "فارسی"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bulkActions": {
|
||||||
|
"title": "عملیات دستهای",
|
||||||
|
"subtitle": "مدیریت کاربران",
|
||||||
|
"selectedCount": "انتخاب شده: {{count}}",
|
||||||
|
"selectAll": "انتخاب همه",
|
||||||
|
"deselectAll": "لغو انتخاب همه",
|
||||||
|
"selectUser": "انتخاب کاربر {{name}}",
|
||||||
|
"deselectUser": "لغو انتخاب کاربر {{name}}",
|
||||||
|
"noResults": "کاربری یافت نشد",
|
||||||
|
"perPage": "در هر صفحه",
|
||||||
|
"actions": {
|
||||||
|
"extend": "تمدید اشتراک",
|
||||||
|
"cancel": "غیرفعالسازی اشتراکها",
|
||||||
|
"activate": "فعالسازی اشتراکها",
|
||||||
|
"changeTariff": "تغییر تعرفه",
|
||||||
|
"addTraffic": "افزودن ترافیک",
|
||||||
|
"addBalance": "افزودن موجودی",
|
||||||
|
"assignPromoGroup": "اختصاص گروه تبلیغاتی",
|
||||||
|
"grantSubscription": "اعطای اشتراک",
|
||||||
|
"setDevices": "تنظیم دستگاهها",
|
||||||
|
"deleteSubscription": "حذف اشتراکها",
|
||||||
|
"deleteUser": "حذف کاربران"
|
||||||
|
},
|
||||||
|
"deleteSubscription": {
|
||||||
|
"warning": "اشتراکهای انتخاب شده برای همیشه از ربات و پنل RemnaWave حذف خواهند شد!",
|
||||||
|
"hint": "کاربران دسترسی VPN اشتراکهای حذف شده را از دست خواهند داد. این عملیات قابل بازگشت نیست."
|
||||||
|
},
|
||||||
|
"deleteUser": {
|
||||||
|
"warning": "کاربران انتخاب شده برای همیشه از ربات حذف خواهند شد! تمام اشتراکها، موجودی و دادههای آنها از بین خواهد رفت!",
|
||||||
|
"hint": "این عملیات قابل بازگشت نیست. کاربران باید ربات را دوباره راهاندازی کنند تا حساب جدید بسازند.",
|
||||||
|
"deleteFromPanel": "همچنین از پنل RemnaWave حذف شود"
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"days": "تعداد روز",
|
||||||
|
"tariff": "انتخاب تعرفه",
|
||||||
|
"trafficGb": "حجم ترافیک (GB)",
|
||||||
|
"balanceRub": "مبلغ (روبل)",
|
||||||
|
"promoGroup": "گروه تبلیغاتی",
|
||||||
|
"removePromoGroup": "حذف گروه تبلیغاتی",
|
||||||
|
"deviceLimit": "تعداد دستگاه"
|
||||||
|
},
|
||||||
|
"grantSubscription": {
|
||||||
|
"warning": "کاربرانی که قبلا اشتراک تعرفه انتخاب شده را دارند رد خواهند شد"
|
||||||
|
},
|
||||||
|
"confirm": "اعمال",
|
||||||
|
"cancel": "انصراف",
|
||||||
|
"executing": "در حال اجرا...",
|
||||||
|
"complete": "انجام شد",
|
||||||
|
"successCount": "موفق: {{count}}",
|
||||||
|
"errorCount": "خطا: {{count}}",
|
||||||
|
"progress": {
|
||||||
|
"processed": "پردازش شده: {{current}} / {{total}}",
|
||||||
|
"succeeded": "موفق",
|
||||||
|
"failed": "ناموفق",
|
||||||
|
"ok": "OK",
|
||||||
|
"errorGeneric": "خطا",
|
||||||
|
"doNotClose": "لطفا تا پایان عملیات این پنجره را نبندید",
|
||||||
|
"summarySuccess": "{{count}} موفق",
|
||||||
|
"summaryErrors": "{{count}} خطا"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"title": "{{count}} خطا — نمایش جزئیات"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"search": "جستجو بر اساس شناسه، نام کاربری، ایمیل",
|
||||||
|
"status": "وضعیت اشتراک",
|
||||||
|
"tariff": "تعرفه",
|
||||||
|
"promoGroup": "گروه تبلیغاتی",
|
||||||
|
"allStatuses": "همه وضعیتها",
|
||||||
|
"allTariffs": "همه تعرفهها",
|
||||||
|
"allGroups": "همه گروهها",
|
||||||
|
"allCampaigns": "همه کمپینها",
|
||||||
|
"allPartners": "همه شرکا",
|
||||||
|
"trialOnly": "فقط آزمایشی",
|
||||||
|
"tariffsSelected": "{{count}} تعرفه",
|
||||||
|
"selectAll": "انتخاب همه",
|
||||||
|
"deselectAll": "لغو انتخاب همه"
|
||||||
|
},
|
||||||
|
"statuses": {
|
||||||
|
"active": "فعال",
|
||||||
|
"expired": "منقضی",
|
||||||
|
"trial": "آزمایشی",
|
||||||
|
"limited": "محدود",
|
||||||
|
"disabled": "غیرفعال"
|
||||||
|
},
|
||||||
|
"columns": {
|
||||||
|
"user": "کاربر",
|
||||||
|
"status": "وضعیت",
|
||||||
|
"tariff": "تعرفه",
|
||||||
|
"balance": "موجودی",
|
||||||
|
"daysRemaining": "روز",
|
||||||
|
"promoGroup": "گروه",
|
||||||
|
"spent": "خرج شده"
|
||||||
|
},
|
||||||
|
"subscriptionsSelected": "{{count}} اشتراک انتخاب شده",
|
||||||
|
"usersSelected": "{{count}} کاربر انتخاب شده",
|
||||||
|
"expandSubscriptions": "نمایش اشتراکها",
|
||||||
|
"collapseSubscriptions": "پنهان کردن اشتراکها",
|
||||||
|
"noSubscriptions": "بدون اشتراک",
|
||||||
|
"subscriptionTarget": "هدف: اشتراکها",
|
||||||
|
"userTarget": "هدف: کاربران",
|
||||||
|
"daysUnit": "روز",
|
||||||
|
"trafficOf": "از",
|
||||||
|
"trafficGbUnit": "GB",
|
||||||
|
"selectAllSubs": "انتخاب همه اشتراکها",
|
||||||
|
"deselectAllSubs": "لغو انتخاب همه اشتراکها"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"adminUpdates": {
|
"adminUpdates": {
|
||||||
|
|||||||
@@ -1146,7 +1146,9 @@
|
|||||||
"salesStats": "Статистика продаж",
|
"salesStats": "Статистика продаж",
|
||||||
"landings": "Лендинги",
|
"landings": "Лендинги",
|
||||||
"referralNetwork": "Реферальная сеть",
|
"referralNetwork": "Реферальная сеть",
|
||||||
"news": "Новости"
|
"news": "Новости",
|
||||||
|
"bulkActions": "Массовые действия",
|
||||||
|
"infoPages": "Инфо-страницы"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "Панель администратора",
|
"title": "Панель администратора",
|
||||||
@@ -3769,6 +3771,113 @@
|
|||||||
"users": "Пользователи",
|
"users": "Пользователи",
|
||||||
"security": "Безопасность"
|
"security": "Безопасность"
|
||||||
},
|
},
|
||||||
|
"bulkActions": {
|
||||||
|
"title": "Массовые действия",
|
||||||
|
"subtitle": "Управление пользователями",
|
||||||
|
"selectedCount": "Выбрано: {{count}}",
|
||||||
|
"selectAll": "Выбрать всех",
|
||||||
|
"deselectAll": "Снять выделение",
|
||||||
|
"selectUser": "Выбрать пользователя {{name}}",
|
||||||
|
"deselectUser": "Снять выбор пользователя {{name}}",
|
||||||
|
"noResults": "Пользователи не найдены",
|
||||||
|
"perPage": "На странице",
|
||||||
|
"actions": {
|
||||||
|
"extend": "Продлить подписку",
|
||||||
|
"cancel": "Деактивировать подписки",
|
||||||
|
"activate": "Активировать подписки",
|
||||||
|
"changeTariff": "Сменить тариф",
|
||||||
|
"addTraffic": "Начислить трафик",
|
||||||
|
"addBalance": "Начислить баланс",
|
||||||
|
"assignPromoGroup": "Назначить промогруппу",
|
||||||
|
"grantSubscription": "Выдать подписку",
|
||||||
|
"setDevices": "Изменить устройства",
|
||||||
|
"deleteSubscription": "Удалить подписки",
|
||||||
|
"deleteUser": "Удалить пользователей"
|
||||||
|
},
|
||||||
|
"deleteSubscription": {
|
||||||
|
"warning": "Выбранные подписки будут безвозвратно удалены из бота и панели RemnaWave!",
|
||||||
|
"hint": "Пользователи потеряют доступ к VPN по удалённым подпискам. Это действие нельзя отменить."
|
||||||
|
},
|
||||||
|
"deleteUser": {
|
||||||
|
"warning": "Выбранные пользователи будут безвозвратно удалены из бота! Все их подписки, баланс и данные будут потеряны!",
|
||||||
|
"hint": "Это действие нельзя отменить. Пользователям придётся заново запустить бота для создания нового аккаунта.",
|
||||||
|
"deleteFromPanel": "Также удалить из панели RemnaWave"
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"days": "Количество дней",
|
||||||
|
"tariff": "Выберите тариф",
|
||||||
|
"trafficGb": "Объём трафика (ГБ)",
|
||||||
|
"balanceRub": "Сумма (руб.)",
|
||||||
|
"promoGroup": "Промогруппа",
|
||||||
|
"removePromoGroup": "Убрать промогруппу",
|
||||||
|
"deviceLimit": "Кол-во устройств"
|
||||||
|
},
|
||||||
|
"grantSubscription": {
|
||||||
|
"warning": "Пользователи, у которых уже есть подписка на выбранный тариф, будут пропущены"
|
||||||
|
},
|
||||||
|
"confirm": "Применить",
|
||||||
|
"cancel": "Отмена",
|
||||||
|
"executing": "Выполнение...",
|
||||||
|
"complete": "Выполнено",
|
||||||
|
"successCount": "Успешно: {{count}}",
|
||||||
|
"errorCount": "Ошибок: {{count}}",
|
||||||
|
"progress": {
|
||||||
|
"processed": "Обработано: {{current}} / {{total}}",
|
||||||
|
"succeeded": "успешно",
|
||||||
|
"failed": "ошибок",
|
||||||
|
"ok": "ОК",
|
||||||
|
"errorGeneric": "Ошибка",
|
||||||
|
"doNotClose": "Не закрывайте это окно, пока операция выполняется",
|
||||||
|
"summarySuccess": "{{count}} успешно",
|
||||||
|
"summaryErrors": "{{count}} ошибок"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"title": "{{count}} ошибок — показать детали"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"search": "Поиск по ID, нику, email",
|
||||||
|
"status": "Статус подписки",
|
||||||
|
"tariff": "Тариф",
|
||||||
|
"promoGroup": "Промогруппа",
|
||||||
|
"allStatuses": "Все статусы",
|
||||||
|
"allTariffs": "Все тарифы",
|
||||||
|
"allGroups": "Все группы",
|
||||||
|
"allCampaigns": "Все кампании",
|
||||||
|
"allPartners": "Все партнёры",
|
||||||
|
"trialOnly": "Только триал",
|
||||||
|
"tariffsSelected": "{{count}} тарифов",
|
||||||
|
"selectAll": "Выбрать все",
|
||||||
|
"deselectAll": "Снять все"
|
||||||
|
},
|
||||||
|
"statuses": {
|
||||||
|
"active": "Активна",
|
||||||
|
"expired": "Истекла",
|
||||||
|
"trial": "Триал",
|
||||||
|
"limited": "Ограничена",
|
||||||
|
"disabled": "Отключена"
|
||||||
|
},
|
||||||
|
"columns": {
|
||||||
|
"user": "Пользователь",
|
||||||
|
"status": "Статус",
|
||||||
|
"tariff": "Тариф",
|
||||||
|
"balance": "Баланс",
|
||||||
|
"daysRemaining": "Дни",
|
||||||
|
"promoGroup": "Группа",
|
||||||
|
"spent": "Потрачено"
|
||||||
|
},
|
||||||
|
"subscriptionsSelected": "{{count}} подписок выбрано",
|
||||||
|
"usersSelected": "{{count}} пользователей выбрано",
|
||||||
|
"expandSubscriptions": "Показать подписки",
|
||||||
|
"collapseSubscriptions": "Скрыть подписки",
|
||||||
|
"noSubscriptions": "Нет подписок",
|
||||||
|
"subscriptionTarget": "Цель: подписки",
|
||||||
|
"userTarget": "Цель: пользователи",
|
||||||
|
"daysUnit": "дн.",
|
||||||
|
"trafficOf": "из",
|
||||||
|
"trafficGbUnit": "ГБ",
|
||||||
|
"selectAllSubs": "Выбрать все подписки",
|
||||||
|
"deselectAllSubs": "Снять все подписки"
|
||||||
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"accentColor": "Акцентный цвет",
|
"accentColor": "Акцентный цвет",
|
||||||
"customizeColors": "Настроить цвета",
|
"customizeColors": "Настроить цвета",
|
||||||
@@ -4242,6 +4351,76 @@
|
|||||||
"prev": "Назад",
|
"prev": "Назад",
|
||||||
"next": "Далее"
|
"next": "Далее"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"infoPages": {
|
||||||
|
"title": "Информационные страницы",
|
||||||
|
"subtitle": "Управление статическими информационными страницами",
|
||||||
|
"create": "Создать страницу",
|
||||||
|
"createFaq": "Создать FAQ",
|
||||||
|
"edit": "Редактировать",
|
||||||
|
"save": "Сохранить",
|
||||||
|
"saving": "Сохранение...",
|
||||||
|
"saved": "Страница сохранена",
|
||||||
|
"delete": "Удалить",
|
||||||
|
"confirmDelete": "Удалить эту страницу?",
|
||||||
|
"saveError": "Не удалось сохранить страницу. Попробуйте ещё раз.",
|
||||||
|
"noPages": "Информационных страниц пока нет",
|
||||||
|
"notFound": "Страница не найдена",
|
||||||
|
"active": "Активна",
|
||||||
|
"inactive": "Неактивна",
|
||||||
|
"typePage": "Страница",
|
||||||
|
"localeLabel": "Язык контента",
|
||||||
|
"fields": {
|
||||||
|
"slug": "URL-адрес",
|
||||||
|
"title": "Заголовок",
|
||||||
|
"content": "Содержание",
|
||||||
|
"isActive": "Активна",
|
||||||
|
"sortOrder": "Порядок сортировки",
|
||||||
|
"icon": "Иконка (эмодзи)",
|
||||||
|
"pageType": "Тип страницы",
|
||||||
|
"replacesTab": "Заменяет таб"
|
||||||
|
},
|
||||||
|
"replacesTabNone": "Не заменяет",
|
||||||
|
"replacesTabOptions": {
|
||||||
|
"faq": "FAQ",
|
||||||
|
"rules": "Правила",
|
||||||
|
"privacy": "Конфиденциальность",
|
||||||
|
"offer": "Оферта"
|
||||||
|
},
|
||||||
|
"replacesTabConflict": "уже занят",
|
||||||
|
"replacesTabWarning": "Другая страница уже заменяет этот таб. При сохранении она будет снята.",
|
||||||
|
"filter": {
|
||||||
|
"all": "Все",
|
||||||
|
"page": "Страницы",
|
||||||
|
"faq": "FAQ"
|
||||||
|
},
|
||||||
|
"pageTypes": {
|
||||||
|
"page": "Страница",
|
||||||
|
"faq": "FAQ"
|
||||||
|
},
|
||||||
|
"faq": {
|
||||||
|
"questions": "Вопросы и ответы",
|
||||||
|
"questionsCount": "вопросов",
|
||||||
|
"noQuestions": "Вопросов пока нет. Нажмите кнопку ниже, чтобы добавить.",
|
||||||
|
"questionNumber": "Вопрос #{{n}}",
|
||||||
|
"question": "Вопрос",
|
||||||
|
"answer": "Ответ",
|
||||||
|
"questionPlaceholder": "Введите вопрос...",
|
||||||
|
"answerPlaceholder": "Введите ответ (поддерживается HTML)...",
|
||||||
|
"htmlHint": "Поддерживается HTML-разметка",
|
||||||
|
"addQuestion": "Добавить вопрос",
|
||||||
|
"removeQuestion": "Удалить вопрос",
|
||||||
|
"moveUp": "Переместить вверх",
|
||||||
|
"moveDown": "Переместить вниз",
|
||||||
|
"searchPlaceholder": "Поиск по вопросам...",
|
||||||
|
"noResults": "Ничего не найдено"
|
||||||
|
},
|
||||||
|
"locales": {
|
||||||
|
"ru": "Русский",
|
||||||
|
"en": "Английский",
|
||||||
|
"zh": "Китайский",
|
||||||
|
"fa": "Персидский"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"adminUpdates": {
|
"adminUpdates": {
|
||||||
|
|||||||
@@ -949,7 +949,8 @@
|
|||||||
"salesStats": "销售统计",
|
"salesStats": "销售统计",
|
||||||
"landings": "落地页",
|
"landings": "落地页",
|
||||||
"referralNetwork": "推荐网络",
|
"referralNetwork": "推荐网络",
|
||||||
"news": "新闻"
|
"news": "新闻",
|
||||||
|
"infoPages": "信息页面"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "管理面板",
|
"title": "管理面板",
|
||||||
@@ -3365,6 +3366,183 @@
|
|||||||
"periodDaySuffix": "天",
|
"periodDaySuffix": "天",
|
||||||
"localeTab": "语言",
|
"localeTab": "语言",
|
||||||
"localeHint": "切换语言以编辑该语言的文本"
|
"localeHint": "切换语言以编辑该语言的文本"
|
||||||
|
},
|
||||||
|
"infoPages": {
|
||||||
|
"title": "信息页面",
|
||||||
|
"subtitle": "管理静态信息页面",
|
||||||
|
"create": "创建页面",
|
||||||
|
"createFaq": "创建FAQ",
|
||||||
|
"edit": "编辑",
|
||||||
|
"save": "保存",
|
||||||
|
"saving": "保存中...",
|
||||||
|
"saved": "页面已保存",
|
||||||
|
"delete": "删除",
|
||||||
|
"confirmDelete": "确定删除此页面?",
|
||||||
|
"saveError": "保存页面失败,请重试。",
|
||||||
|
"noPages": "暂无信息页面",
|
||||||
|
"notFound": "页面未找到",
|
||||||
|
"active": "已激活",
|
||||||
|
"inactive": "未激活",
|
||||||
|
"typePage": "页面",
|
||||||
|
"localeLabel": "内容语言",
|
||||||
|
"fields": {
|
||||||
|
"slug": "URL标识",
|
||||||
|
"title": "标题",
|
||||||
|
"content": "内容",
|
||||||
|
"isActive": "已激活",
|
||||||
|
"sortOrder": "排序",
|
||||||
|
"icon": "图标(表情)",
|
||||||
|
"pageType": "页面类型",
|
||||||
|
"replacesTab": "替换标签"
|
||||||
|
},
|
||||||
|
"replacesTabNone": "不替换",
|
||||||
|
"replacesTabOptions": {
|
||||||
|
"faq": "FAQ",
|
||||||
|
"rules": "规则",
|
||||||
|
"privacy": "隐私",
|
||||||
|
"offer": "条款"
|
||||||
|
},
|
||||||
|
"replacesTabConflict": "已被占用",
|
||||||
|
"replacesTabWarning": "另一个页面已替换此标签。保存后该页面将被取消替换。",
|
||||||
|
"filter": {
|
||||||
|
"all": "全部",
|
||||||
|
"page": "页面",
|
||||||
|
"faq": "FAQ"
|
||||||
|
},
|
||||||
|
"pageTypes": {
|
||||||
|
"page": "页面",
|
||||||
|
"faq": "FAQ"
|
||||||
|
},
|
||||||
|
"faq": {
|
||||||
|
"questions": "问答",
|
||||||
|
"questionsCount": "个问题",
|
||||||
|
"noQuestions": "暂无问题。点击下方按钮添加。",
|
||||||
|
"questionNumber": "问题 #{{n}}",
|
||||||
|
"question": "问题",
|
||||||
|
"answer": "答案",
|
||||||
|
"questionPlaceholder": "输入问题...",
|
||||||
|
"answerPlaceholder": "输入答案(支持HTML)...",
|
||||||
|
"htmlHint": "支持HTML标记",
|
||||||
|
"addQuestion": "添加问题",
|
||||||
|
"removeQuestion": "删除问题",
|
||||||
|
"moveUp": "上移",
|
||||||
|
"moveDown": "下移",
|
||||||
|
"searchPlaceholder": "搜索问题...",
|
||||||
|
"noResults": "未找到结果"
|
||||||
|
},
|
||||||
|
"locales": {
|
||||||
|
"ru": "俄语",
|
||||||
|
"en": "英语",
|
||||||
|
"zh": "中文",
|
||||||
|
"fa": "波斯语"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bulkActions": {
|
||||||
|
"title": "批量操作",
|
||||||
|
"subtitle": "用户管理",
|
||||||
|
"selectedCount": "已选择: {{count}}",
|
||||||
|
"selectAll": "全选",
|
||||||
|
"deselectAll": "取消全选",
|
||||||
|
"selectUser": "选择用户 {{name}}",
|
||||||
|
"deselectUser": "取消选择用户 {{name}}",
|
||||||
|
"noResults": "未找到用户",
|
||||||
|
"perPage": "每页",
|
||||||
|
"actions": {
|
||||||
|
"extend": "延长订阅",
|
||||||
|
"cancel": "停用订阅",
|
||||||
|
"activate": "激活订阅",
|
||||||
|
"changeTariff": "更改套餐",
|
||||||
|
"addTraffic": "添加流量",
|
||||||
|
"addBalance": "添加余额",
|
||||||
|
"assignPromoGroup": "分配促销组",
|
||||||
|
"grantSubscription": "授予订阅",
|
||||||
|
"setDevices": "设置设备数",
|
||||||
|
"deleteSubscription": "删除订阅",
|
||||||
|
"deleteUser": "删除用户"
|
||||||
|
},
|
||||||
|
"deleteSubscription": {
|
||||||
|
"warning": "选中的订阅将从机器人和RemnaWave面板中永久删除!",
|
||||||
|
"hint": "用户将失去已删除订阅的VPN访问权限。此操作无法撤销。"
|
||||||
|
},
|
||||||
|
"deleteUser": {
|
||||||
|
"warning": "选中的用户将从机器人中永久删除!他们的所有订阅、余额和数据将丢失!",
|
||||||
|
"hint": "此操作无法撤销。用户需要重新启动机器人以创建新账户。",
|
||||||
|
"deleteFromPanel": "同时从RemnaWave面板删除"
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"days": "天数",
|
||||||
|
"tariff": "选择套餐",
|
||||||
|
"trafficGb": "流量 (GB)",
|
||||||
|
"balanceRub": "金额 (卢布)",
|
||||||
|
"promoGroup": "促销组",
|
||||||
|
"removePromoGroup": "移除促销组",
|
||||||
|
"deviceLimit": "设备数量"
|
||||||
|
},
|
||||||
|
"grantSubscription": {
|
||||||
|
"warning": "已拥有所选套餐订阅的用户将被跳过"
|
||||||
|
},
|
||||||
|
"confirm": "应用",
|
||||||
|
"cancel": "取消",
|
||||||
|
"executing": "执行中...",
|
||||||
|
"complete": "完成",
|
||||||
|
"successCount": "成功: {{count}}",
|
||||||
|
"errorCount": "错误: {{count}}",
|
||||||
|
"progress": {
|
||||||
|
"processed": "已处理: {{current}} / {{total}}",
|
||||||
|
"succeeded": "成功",
|
||||||
|
"failed": "失败",
|
||||||
|
"ok": "OK",
|
||||||
|
"errorGeneric": "错误",
|
||||||
|
"doNotClose": "操作进行中请勿关闭此窗口",
|
||||||
|
"summarySuccess": "{{count}} 成功",
|
||||||
|
"summaryErrors": "{{count}} 错误"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"title": "{{count}} 个错误 — 显示详情"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"search": "按ID、用户名、邮箱搜索",
|
||||||
|
"status": "订阅状态",
|
||||||
|
"tariff": "套餐",
|
||||||
|
"promoGroup": "促销组",
|
||||||
|
"allStatuses": "所有状态",
|
||||||
|
"allTariffs": "所有套餐",
|
||||||
|
"allGroups": "所有组",
|
||||||
|
"allCampaigns": "所有活动",
|
||||||
|
"allPartners": "所有合作伙伴",
|
||||||
|
"trialOnly": "仅试用",
|
||||||
|
"tariffsSelected": "{{count}} 个套餐",
|
||||||
|
"selectAll": "全选",
|
||||||
|
"deselectAll": "取消全选"
|
||||||
|
},
|
||||||
|
"statuses": {
|
||||||
|
"active": "活跃",
|
||||||
|
"expired": "已过期",
|
||||||
|
"trial": "试用",
|
||||||
|
"limited": "受限",
|
||||||
|
"disabled": "已禁用"
|
||||||
|
},
|
||||||
|
"columns": {
|
||||||
|
"user": "用户",
|
||||||
|
"status": "状态",
|
||||||
|
"tariff": "套餐",
|
||||||
|
"balance": "余额",
|
||||||
|
"daysRemaining": "天数",
|
||||||
|
"promoGroup": "组",
|
||||||
|
"spent": "已消费"
|
||||||
|
},
|
||||||
|
"subscriptionsSelected": "已选择 {{count}} 个订阅",
|
||||||
|
"usersSelected": "已选择 {{count}} 个用户",
|
||||||
|
"expandSubscriptions": "显示订阅",
|
||||||
|
"collapseSubscriptions": "隐藏订阅",
|
||||||
|
"noSubscriptions": "无订阅",
|
||||||
|
"subscriptionTarget": "目标: 订阅",
|
||||||
|
"userTarget": "目标: 用户",
|
||||||
|
"daysUnit": "天",
|
||||||
|
"trafficOf": "/",
|
||||||
|
"trafficGbUnit": "GB",
|
||||||
|
"selectAllSubs": "选择所有订阅",
|
||||||
|
"deselectAllSubs": "取消选择所有订阅"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"adminUpdates": {
|
"adminUpdates": {
|
||||||
|
|||||||
2425
src/pages/AdminBulkActions.tsx
Normal file
2425
src/pages/AdminBulkActions.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1458
src/pages/AdminInfoPageEditor.tsx
Normal file
1458
src/pages/AdminInfoPageEditor.tsx
Normal file
File diff suppressed because it is too large
Load Diff
396
src/pages/AdminInfoPages.tsx
Normal file
396
src/pages/AdminInfoPages.tsx
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
import { useCallback, useState, memo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { infoPagesApi } from '../api/infoPages';
|
||||||
|
import { AdminBackButton } from '../components/admin';
|
||||||
|
import { Toggle } from '../components/admin/Toggle';
|
||||||
|
import { useHapticFeedback } from '../platform/hooks/useHaptic';
|
||||||
|
import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog';
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
import type { InfoPageListItem, InfoPageType } from '../api/infoPages';
|
||||||
|
|
||||||
|
type FilterTab = 'all' | 'page' | 'faq';
|
||||||
|
|
||||||
|
// Icons
|
||||||
|
const PlusIcon = () => (
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const RefreshIcon = () => (
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PencilIcon = () => (
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const TrashIcon = () => (
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const FileTextIcon = () => (
|
||||||
|
<svg
|
||||||
|
className="h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Page Row ---
|
||||||
|
|
||||||
|
const PageRow = memo(function PageRow({
|
||||||
|
page,
|
||||||
|
locale,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onToggleActive,
|
||||||
|
}: {
|
||||||
|
page: InfoPageListItem;
|
||||||
|
locale: string;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onToggleActive: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const resolvedTitle = page.title[locale] || page.title['ru'] || page.title['en'] || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4 transition-all hover:border-dark-600">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="mb-1.5 flex flex-wrap items-center gap-2">
|
||||||
|
{page.icon && <span className="text-base">{page.icon}</span>}
|
||||||
|
<span className="rounded-full bg-dark-700 px-2 py-0.5 font-mono text-[10px] font-medium text-dark-300">
|
||||||
|
/{page.slug}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
||||||
|
page.page_type === 'faq'
|
||||||
|
? 'bg-warning-500/20 text-warning-400'
|
||||||
|
: 'bg-accent-500/20 text-accent-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{page.page_type === 'faq' ? 'FAQ' : t('admin.infoPages.typePage')}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
||||||
|
page.is_active
|
||||||
|
? 'bg-success-500/20 text-success-400'
|
||||||
|
: 'bg-dark-500/20 text-dark-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{page.is_active ? t('admin.infoPages.active') : t('admin.infoPages.inactive')}
|
||||||
|
</span>
|
||||||
|
{page.replaces_tab && (
|
||||||
|
<span className="rounded-full bg-purple-500/20 px-2 py-0.5 text-[10px] font-medium text-purple-400">
|
||||||
|
{t(`admin.infoPages.replacesTabOptions.${page.replaces_tab}`)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-dark-500">#{page.id}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="truncate text-sm font-medium text-dark-100">{resolvedTitle}</p>
|
||||||
|
|
||||||
|
<div className="mt-2 flex items-center gap-4 text-xs text-dark-500">
|
||||||
|
<span>
|
||||||
|
{t('admin.infoPages.fields.sortOrder')}: {page.sort_order}
|
||||||
|
</span>
|
||||||
|
{page.updated_at && <span>{new Date(page.updated_at).toLocaleDateString()}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
|
<Toggle
|
||||||
|
checked={page.is_active}
|
||||||
|
onChange={onToggleActive}
|
||||||
|
aria-label={t('admin.infoPages.fields.isActive')}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEdit}
|
||||||
|
className="min-h-[44px] min-w-[44px] rounded-lg p-2.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
||||||
|
title={t('admin.infoPages.edit')}
|
||||||
|
aria-label={t('admin.infoPages.edit')}
|
||||||
|
>
|
||||||
|
<PencilIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDelete}
|
||||||
|
className="min-h-[44px] min-w-[44px] rounded-lg p-2.5 text-dark-400 transition-colors hover:bg-error-500/10 hover:text-error-400"
|
||||||
|
title={t('admin.infoPages.delete')}
|
||||||
|
aria-label={t('admin.infoPages.delete')}
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Row Wrapper (stable callbacks for memo) ---
|
||||||
|
|
||||||
|
interface PageRowWrapperProps {
|
||||||
|
page: InfoPageListItem;
|
||||||
|
locale: string;
|
||||||
|
onNavigate: (path: string) => void;
|
||||||
|
onDelete: (id: number) => void;
|
||||||
|
onToggleActive: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PageRowWrapper = memo(function PageRowWrapper({
|
||||||
|
page,
|
||||||
|
locale,
|
||||||
|
onNavigate,
|
||||||
|
onDelete,
|
||||||
|
onToggleActive,
|
||||||
|
}: PageRowWrapperProps) {
|
||||||
|
const handleEdit = useCallback(
|
||||||
|
() => onNavigate(`/admin/info-pages/${page.id}/edit`),
|
||||||
|
[page.id, onNavigate],
|
||||||
|
);
|
||||||
|
const handleDelete = useCallback(() => onDelete(page.id), [page.id, onDelete]);
|
||||||
|
const handleToggleActive = useCallback(() => onToggleActive(page.id), [page.id, onToggleActive]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageRow
|
||||||
|
page={page}
|
||||||
|
locale={locale}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onToggleActive={handleToggleActive}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function AdminInfoPages() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const haptic = useHapticFeedback();
|
||||||
|
const confirm = useDestructiveConfirm();
|
||||||
|
const currentLocale = i18n.language.split('-')[0];
|
||||||
|
const [activeFilter, setActiveFilter] = useState<FilterTab>('all');
|
||||||
|
|
||||||
|
const filterParam: InfoPageType | undefined = activeFilter === 'all' ? undefined : activeFilter;
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: pages,
|
||||||
|
isLoading,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['admin', 'info-pages', 'list', activeFilter],
|
||||||
|
queryFn: () => infoPagesApi.getAdminPages(filterParam),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = pages ?? [];
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: infoPagesApi.deletePage,
|
||||||
|
onSuccess: () => {
|
||||||
|
haptic.success();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'info-pages'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['info-pages'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleActiveMutation = useMutation({
|
||||||
|
mutationFn: infoPagesApi.toggleActive,
|
||||||
|
onSuccess: () => {
|
||||||
|
haptic.success();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'info-pages'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['info-pages'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
async (id: number) => {
|
||||||
|
const confirmed = await confirm(t('admin.infoPages.confirmDelete'));
|
||||||
|
if (confirmed) {
|
||||||
|
deleteMutation.mutate(id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[confirm, deleteMutation, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggleActive = useCallback(
|
||||||
|
(id: number) => {
|
||||||
|
toggleActiveMutation.mutate(id);
|
||||||
|
},
|
||||||
|
[toggleActiveMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<AdminBackButton />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-xl font-bold text-dark-100">{t('admin.infoPages.title')}</h1>
|
||||||
|
{items.length > 0 && (
|
||||||
|
<span className="rounded-full bg-dark-700 px-2 py-0.5 text-xs font-medium text-dark-300">
|
||||||
|
{items.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="min-h-[44px] min-w-[44px] rounded-lg bg-dark-800 p-2.5 text-dark-400 transition-colors hover:text-dark-100"
|
||||||
|
aria-label={t('common.refresh')}
|
||||||
|
>
|
||||||
|
<RefreshIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
haptic.buttonPress();
|
||||||
|
navigate('/admin/info-pages/create?type=faq');
|
||||||
|
}}
|
||||||
|
className="flex min-h-[44px] items-center gap-2 rounded-lg bg-warning-500/80 px-4 py-2.5 text-white transition-colors hover:bg-warning-500"
|
||||||
|
aria-label={t('admin.infoPages.createFaq')}
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
<span className="hidden sm:inline">{t('admin.infoPages.createFaq')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
haptic.buttonPress();
|
||||||
|
navigate('/admin/info-pages/create');
|
||||||
|
}}
|
||||||
|
className="flex min-h-[44px] items-center gap-2 rounded-lg bg-accent-500 px-4 py-2.5 text-white transition-colors hover:bg-accent-600"
|
||||||
|
aria-label={t('admin.infoPages.create')}
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
<span className="hidden sm:inline">{t('admin.infoPages.create')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter tabs */}
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(['all', 'page', 'faq'] as const).map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveFilter(tab)}
|
||||||
|
className={cn(
|
||||||
|
'min-h-[44px] rounded-lg px-4 py-2.5 text-sm font-medium transition-colors',
|
||||||
|
activeFilter === tab
|
||||||
|
? 'bg-accent-500 text-white'
|
||||||
|
: 'bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-dark-100',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t(`admin.infoPages.filter.${tab}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pages list */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="animate-pulse rounded-xl border border-dark-700 bg-dark-800/50 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="h-4 w-16 rounded bg-dark-700" />
|
||||||
|
<div className="h-4 w-12 rounded bg-dark-700" />
|
||||||
|
</div>
|
||||||
|
<div className="h-5 w-3/4 rounded bg-dark-700" />
|
||||||
|
<div className="h-3 w-1/2 rounded bg-dark-700" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="h-8 w-14 rounded-full bg-dark-700" />
|
||||||
|
<div className="h-8 w-8 rounded-lg bg-dark-700" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center rounded-xl border border-dark-700 bg-dark-800/50 p-8 text-center text-dark-400">
|
||||||
|
<FileTextIcon />
|
||||||
|
<p className="mt-2">{t('admin.infoPages.noPages')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{items.map((page) => (
|
||||||
|
<PageRowWrapper
|
||||||
|
key={page.id}
|
||||||
|
page={page}
|
||||||
|
locale={currentLocale}
|
||||||
|
onNavigate={navigate}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onToggleActive={handleToggleActive}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -333,12 +333,27 @@ const icons = {
|
|||||||
<path d="M15 8h-5M15 12h-5" />
|
<path d="M15 8h-5M15 12h-5" />
|
||||||
</SvgIcon>
|
</SvgIcon>
|
||||||
),
|
),
|
||||||
|
'list-checks': (
|
||||||
|
<SvgIcon>
|
||||||
|
<path d="M10 6h11M10 12h11M10 18h11" />
|
||||||
|
<path d="m3 6 1 1 2-2M3 12l1 1 2-2M3 18l1 1 2-2" />
|
||||||
|
</SvgIcon>
|
||||||
|
),
|
||||||
search: (
|
search: (
|
||||||
<SvgIcon>
|
<SvgIcon>
|
||||||
<circle cx="11" cy="11" r="8" />
|
<circle cx="11" cy="11" r="8" />
|
||||||
<path d="m21 21-4.3-4.3" />
|
<path d="m21 21-4.3-4.3" />
|
||||||
</SvgIcon>
|
</SvgIcon>
|
||||||
),
|
),
|
||||||
|
'file-text': (
|
||||||
|
<SvgIcon>
|
||||||
|
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" />
|
||||||
|
<line x1="10" y1="9" x2="8" y2="9" />
|
||||||
|
</SvgIcon>
|
||||||
|
),
|
||||||
chevron: (
|
chevron: (
|
||||||
<SvgIcon>
|
<SvgIcon>
|
||||||
<path d="m9 18 6-6-6-6" />
|
<path d="m9 18 6-6-6-6" />
|
||||||
@@ -412,6 +427,12 @@ const sections: AdminSection[] = [
|
|||||||
gradient: 'linear-gradient(135deg, rgb(var(--color-accent-400)), rgb(var(--color-error-400)))',
|
gradient: 'linear-gradient(135deg, rgb(var(--color-accent-400)), rgb(var(--color-error-400)))',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'admin.nav.users', icon: 'users', to: '/admin/users', permission: 'users:read' },
|
{ name: 'admin.nav.users', icon: 'users', to: '/admin/users', permission: 'users:read' },
|
||||||
|
{
|
||||||
|
name: 'admin.nav.bulkActions',
|
||||||
|
icon: 'list-checks',
|
||||||
|
to: '/admin/bulk-actions',
|
||||||
|
permission: 'users:read',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'admin.nav.tickets',
|
name: 'admin.nav.tickets',
|
||||||
icon: 'ticket',
|
icon: 'ticket',
|
||||||
@@ -549,6 +570,12 @@ const sections: AdminSection[] = [
|
|||||||
to: '/admin/email-templates',
|
to: '/admin/email-templates',
|
||||||
permission: 'email_templates:read',
|
permission: 'email_templates:read',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'admin.nav.infoPages',
|
||||||
|
icon: 'file-text',
|
||||||
|
to: '/admin/info-pages',
|
||||||
|
permission: 'settings:read',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'admin.nav.updates',
|
name: 'admin.nav.updates',
|
||||||
icon: 'refresh',
|
icon: 'refresh',
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import { infoApi, FaqPage } from '../api/info';
|
import { infoApi, FaqPage } from '../api/info';
|
||||||
|
import { infoPagesApi } from '../api/infoPages';
|
||||||
import { promoApi, LoyaltyTierInfo } from '../api/promo';
|
import { promoApi, LoyaltyTierInfo } from '../api/promo';
|
||||||
|
import type { FaqItem, ReplacesTab } from '../api/infoPages';
|
||||||
|
|
||||||
const InfoIcon = () => (
|
const InfoIcon = () => (
|
||||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
@@ -67,7 +69,7 @@ const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
type TabType = 'faq' | 'rules' | 'privacy' | 'offer' | 'loyalty';
|
const BUILTIN_TABS = new Set<string>(['faq', 'rules', 'privacy', 'offer', 'loyalty']);
|
||||||
|
|
||||||
// Sanitize HTML content to prevent XSS
|
// Sanitize HTML content to prevent XSS
|
||||||
const sanitizeHtml = (html: string): string => {
|
const sanitizeHtml = (html: string): string => {
|
||||||
@@ -105,6 +107,136 @@ const sanitizeHtml = (html: string): string => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Rich sanitizer for custom InfoPage content (TipTap editor output with media)
|
||||||
|
const ALLOWED_IFRAME_HOSTS = new Set([
|
||||||
|
'www.youtube.com',
|
||||||
|
'youtube.com',
|
||||||
|
'player.vimeo.com',
|
||||||
|
'www.youtube-nocookie.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const infoPagePurify = DOMPurify(window);
|
||||||
|
|
||||||
|
infoPagePurify.addHook('afterSanitizeAttributes', (node) => {
|
||||||
|
if (node.tagName === 'IFRAME') {
|
||||||
|
const src = node.getAttribute('src') ?? '';
|
||||||
|
try {
|
||||||
|
const url = new URL(src);
|
||||||
|
if (url.protocol !== 'https:' || !ALLOWED_IFRAME_HOSTS.has(url.hostname)) {
|
||||||
|
node.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
node.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
node.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-presentation');
|
||||||
|
node.setAttribute('allow', 'autoplay; encrypted-media; picture-in-picture');
|
||||||
|
}
|
||||||
|
if (node.tagName === 'VIDEO') {
|
||||||
|
const src = node.getAttribute('src') ?? '';
|
||||||
|
try {
|
||||||
|
const url = new URL(src);
|
||||||
|
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||||
|
node.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
node.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
node.setAttribute('controls', '');
|
||||||
|
node.setAttribute('preload', 'metadata');
|
||||||
|
}
|
||||||
|
if (node.tagName === 'A') {
|
||||||
|
node.setAttribute('target', '_blank');
|
||||||
|
node.setAttribute('rel', 'noopener noreferrer');
|
||||||
|
}
|
||||||
|
if (node.hasAttribute('style')) {
|
||||||
|
const style = node.getAttribute('style') ?? '';
|
||||||
|
const match = style.match(/text-align\s*:\s*(left|center|right|justify)/i);
|
||||||
|
if (match) {
|
||||||
|
node.setAttribute('style', `text-align: ${match[1]}`);
|
||||||
|
} else {
|
||||||
|
node.removeAttribute('style');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const RICH_SANITIZE_CONFIG = {
|
||||||
|
ALLOWED_TAGS: [
|
||||||
|
'p',
|
||||||
|
'div',
|
||||||
|
'br',
|
||||||
|
'hr',
|
||||||
|
'h1',
|
||||||
|
'h2',
|
||||||
|
'h3',
|
||||||
|
'h4',
|
||||||
|
'h5',
|
||||||
|
'h6',
|
||||||
|
'blockquote',
|
||||||
|
'pre',
|
||||||
|
'code',
|
||||||
|
'ul',
|
||||||
|
'ol',
|
||||||
|
'li',
|
||||||
|
'table',
|
||||||
|
'thead',
|
||||||
|
'tbody',
|
||||||
|
'tr',
|
||||||
|
'th',
|
||||||
|
'td',
|
||||||
|
'a',
|
||||||
|
'strong',
|
||||||
|
'b',
|
||||||
|
'em',
|
||||||
|
'i',
|
||||||
|
'u',
|
||||||
|
's',
|
||||||
|
'del',
|
||||||
|
'ins',
|
||||||
|
'span',
|
||||||
|
'mark',
|
||||||
|
'sub',
|
||||||
|
'sup',
|
||||||
|
'small',
|
||||||
|
'img',
|
||||||
|
'video',
|
||||||
|
'iframe',
|
||||||
|
'figure',
|
||||||
|
'figcaption',
|
||||||
|
],
|
||||||
|
ALLOWED_ATTR: [
|
||||||
|
'href',
|
||||||
|
'target',
|
||||||
|
'rel',
|
||||||
|
'src',
|
||||||
|
'alt',
|
||||||
|
'title',
|
||||||
|
'width',
|
||||||
|
'height',
|
||||||
|
'loading',
|
||||||
|
'class',
|
||||||
|
'start',
|
||||||
|
'reversed',
|
||||||
|
'type',
|
||||||
|
'controls',
|
||||||
|
'preload',
|
||||||
|
'frameborder',
|
||||||
|
'allowfullscreen',
|
||||||
|
'allow',
|
||||||
|
'sandbox',
|
||||||
|
'style',
|
||||||
|
],
|
||||||
|
ALLOW_DATA_ATTR: false,
|
||||||
|
ADD_ATTR: ['target'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeRichHtml = (html: string): string => {
|
||||||
|
return infoPagePurify.sanitize(html, RICH_SANITIZE_CONFIG);
|
||||||
|
};
|
||||||
|
|
||||||
// Convert content to formatted HTML (handles Telegram HTML + plain text)
|
// Convert content to formatted HTML (handles Telegram HTML + plain text)
|
||||||
const formatContent = (content: string): string => {
|
const formatContent = (content: string): string => {
|
||||||
if (!content) return '';
|
if (!content) return '';
|
||||||
@@ -154,15 +286,165 @@ const formatContent = (content: string): string => {
|
|||||||
return sanitizeHtml(result);
|
return sanitizeHtml(result);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- FAQ Accordion for tab replacements ---
|
||||||
|
|
||||||
|
function ReplacementFaqItem({
|
||||||
|
item,
|
||||||
|
isOpen,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
item: FaqItem;
|
||||||
|
isOpen: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [height, setHeight] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (contentRef.current) {
|
||||||
|
setHeight(isOpen ? contentRef.current.scrollHeight : 0);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !contentRef.current) return;
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
if (contentRef.current) setHeight(contentRef.current.scrollHeight);
|
||||||
|
});
|
||||||
|
observer.observe(contentRef.current);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const sanitizedAnswer = useMemo(() => sanitizeRichHtml(item.a), [item.a]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-xl border border-dark-700 bg-dark-800/50 transition-all hover:border-dark-600">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
className="flex min-h-[52px] w-full items-center justify-between gap-3 px-5 py-4 text-left"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium text-dark-100 sm:text-base">{item.q}</span>
|
||||||
|
<ChevronIcon expanded={isOpen} />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
style={{ height }}
|
||||||
|
className="overflow-hidden transition-[height] duration-300 ease-in-out"
|
||||||
|
>
|
||||||
|
<div ref={contentRef} className="border-t border-dark-700/50 px-5 pb-4 pt-3">
|
||||||
|
<div
|
||||||
|
className="prose prose-sm max-w-none text-dark-300"
|
||||||
|
dangerouslySetInnerHTML={{ __html: sanitizedAnswer }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReplacementFaqView({ items }: { items: FaqItem[] }) {
|
||||||
|
const [openKey, setOpenKey] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleToggle = useCallback((key: string) => {
|
||||||
|
setOpenKey((prev) => (prev === key ? null : key));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const key = `${index}-${item.q.slice(0, 50)}`;
|
||||||
|
return (
|
||||||
|
<ReplacementFaqItem
|
||||||
|
key={key}
|
||||||
|
item={item}
|
||||||
|
isOpen={openKey === key}
|
||||||
|
onToggle={() => handleToggle(key)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Info() {
|
export default function Info() {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('faq');
|
const [activeTab, setActiveTab] = useState<string>('faq');
|
||||||
const [expandedFaq, setExpandedFaq] = useState<number | null>(null);
|
const [expandedFaq, setExpandedFaq] = useState<number | null>(null);
|
||||||
|
const locale = i18n.language.split('-')[0];
|
||||||
|
|
||||||
|
// Fetch tab replacements
|
||||||
|
const { data: tabReplacements, isError: replacementsError } = useQuery({
|
||||||
|
queryKey: ['info-pages', 'tab-replacements'],
|
||||||
|
queryFn: infoPagesApi.getTabReplacements,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch custom InfoPages (active pages without replaces_tab — shown as extra tabs)
|
||||||
|
const { data: customPages } = useQuery({
|
||||||
|
queryKey: ['info-pages', 'list'],
|
||||||
|
queryFn: () => infoPagesApi.getPages(),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter to only pages that don't replace a built-in tab and don't collide with built-in IDs
|
||||||
|
const extraPages = useMemo(
|
||||||
|
() => (customPages ?? []).filter((p) => !p.replaces_tab && !BUILTIN_TABS.has(p.slug)),
|
||||||
|
[customPages],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Determine if we're on a built-in tab or a custom page tab
|
||||||
|
const isCustomTab = !BUILTIN_TABS.has(activeTab);
|
||||||
|
const customTabSlug = isCustomTab ? activeTab : null;
|
||||||
|
|
||||||
|
// Check if current built-in tab has a replacement
|
||||||
|
const currentTabSlug =
|
||||||
|
!isCustomTab && activeTab !== 'loyalty'
|
||||||
|
? (tabReplacements?.[activeTab as ReplacesTab] ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Slug to fetch: either a custom page tab or a tab replacement
|
||||||
|
const pageSlugToFetch = customTabSlug ?? currentTabSlug;
|
||||||
|
|
||||||
|
// Wait for tab replacements before firing built-in queries (also proceed on error — graceful degradation)
|
||||||
|
const replacementsLoaded = tabReplacements !== undefined || replacementsError;
|
||||||
|
|
||||||
|
// Fetch the InfoPage when needed (replacement or custom tab)
|
||||||
|
const { data: infoPage, isLoading: infoPageLoading } = useQuery({
|
||||||
|
queryKey: ['info-pages', 'page', pageSlugToFetch],
|
||||||
|
queryFn: () => {
|
||||||
|
if (!pageSlugToFetch) throw new Error('No slug');
|
||||||
|
return infoPagesApi.getPageBySlug(pageSlugToFetch);
|
||||||
|
},
|
||||||
|
enabled: !!pageSlugToFetch,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parse FAQ items from InfoPage content
|
||||||
|
const infoPageFaqItems = useMemo((): FaqItem[] => {
|
||||||
|
if (!infoPage || infoPage.page_type !== 'faq') return [];
|
||||||
|
const raw =
|
||||||
|
infoPage.content[locale] || infoPage.content['ru'] || infoPage.content['en'] || '[]';
|
||||||
|
try {
|
||||||
|
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [infoPage, locale]);
|
||||||
|
|
||||||
|
// Sanitize regular InfoPage HTML content
|
||||||
|
const infoPageHtml = useMemo(() => {
|
||||||
|
if (!infoPage || infoPage.page_type === 'faq') return '';
|
||||||
|
const rawContent =
|
||||||
|
infoPage.content[locale] || infoPage.content['ru'] || infoPage.content['en'] || '';
|
||||||
|
return sanitizeRichHtml(rawContent);
|
||||||
|
}, [infoPage, locale]);
|
||||||
|
|
||||||
const { data: faqPages, isLoading: faqLoading } = useQuery({
|
const { data: faqPages, isLoading: faqLoading } = useQuery({
|
||||||
queryKey: ['faq-pages'],
|
queryKey: ['faq-pages'],
|
||||||
queryFn: infoApi.getFaqPages,
|
queryFn: infoApi.getFaqPages,
|
||||||
enabled: activeTab === 'faq',
|
enabled: activeTab === 'faq' && !currentTabSlug && replacementsLoaded,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
refetchOnMount: 'always',
|
refetchOnMount: 'always',
|
||||||
});
|
});
|
||||||
@@ -170,7 +452,7 @@ export default function Info() {
|
|||||||
const { data: rules, isLoading: rulesLoading } = useQuery({
|
const { data: rules, isLoading: rulesLoading } = useQuery({
|
||||||
queryKey: ['rules'],
|
queryKey: ['rules'],
|
||||||
queryFn: infoApi.getRules,
|
queryFn: infoApi.getRules,
|
||||||
enabled: activeTab === 'rules',
|
enabled: activeTab === 'rules' && !currentTabSlug && replacementsLoaded,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
refetchOnMount: 'always',
|
refetchOnMount: 'always',
|
||||||
});
|
});
|
||||||
@@ -178,7 +460,7 @@ export default function Info() {
|
|||||||
const { data: privacy, isLoading: privacyLoading } = useQuery({
|
const { data: privacy, isLoading: privacyLoading } = useQuery({
|
||||||
queryKey: ['privacy-policy'],
|
queryKey: ['privacy-policy'],
|
||||||
queryFn: infoApi.getPrivacyPolicy,
|
queryFn: infoApi.getPrivacyPolicy,
|
||||||
enabled: activeTab === 'privacy',
|
enabled: activeTab === 'privacy' && !currentTabSlug && replacementsLoaded,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
refetchOnMount: 'always',
|
refetchOnMount: 'always',
|
||||||
});
|
});
|
||||||
@@ -186,7 +468,7 @@ export default function Info() {
|
|||||||
const { data: offer, isLoading: offerLoading } = useQuery({
|
const { data: offer, isLoading: offerLoading } = useQuery({
|
||||||
queryKey: ['public-offer'],
|
queryKey: ['public-offer'],
|
||||||
queryFn: infoApi.getPublicOffer,
|
queryFn: infoApi.getPublicOffer,
|
||||||
enabled: activeTab === 'offer',
|
enabled: activeTab === 'offer' && !currentTabSlug && replacementsLoaded,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
refetchOnMount: 'always',
|
refetchOnMount: 'always',
|
||||||
});
|
});
|
||||||
@@ -199,19 +481,76 @@ export default function Info() {
|
|||||||
refetchOnMount: 'always',
|
refetchOnMount: 'always',
|
||||||
});
|
});
|
||||||
|
|
||||||
const tabs = [
|
const builtinTabs: Array<{ id: string; label: string; icon: React.FC; emoji?: string }> = [
|
||||||
{ id: 'faq' as TabType, label: t('info.faq'), icon: QuestionIcon },
|
{ id: 'faq', label: t('info.faq'), icon: QuestionIcon },
|
||||||
{ id: 'rules' as TabType, label: t('info.rules'), icon: DocumentIcon },
|
{ id: 'rules', label: t('info.rules'), icon: DocumentIcon },
|
||||||
{ id: 'privacy' as TabType, label: t('info.privacy'), icon: ShieldIcon },
|
{ id: 'privacy', label: t('info.privacy'), icon: ShieldIcon },
|
||||||
{ id: 'offer' as TabType, label: t('info.offer'), icon: DocumentIcon },
|
{ id: 'offer', label: t('info.offer'), icon: DocumentIcon },
|
||||||
{ id: 'loyalty' as TabType, label: t('info.loyalty'), icon: StarIcon },
|
{ id: 'loyalty', label: t('info.loyalty'), icon: StarIcon },
|
||||||
];
|
];
|
||||||
|
|
||||||
const toggleFaq = (id: number) => {
|
const customTabs = extraPages.map((p) => {
|
||||||
setExpandedFaq(expandedFaq === id ? null : id);
|
const label = p.title[locale] || p.title['ru'] || p.title['en'] || p.slug;
|
||||||
|
return { id: p.slug, label, icon: DocumentIcon, emoji: p.icon ?? undefined };
|
||||||
|
});
|
||||||
|
|
||||||
|
const tabs = [...builtinTabs, ...customTabs];
|
||||||
|
|
||||||
|
const toggleFaq = useCallback((id: number) => {
|
||||||
|
setExpandedFaq((prev) => (prev === id ? null : id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderInfoPageContent = () => {
|
||||||
|
if (infoPageLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!infoPage) {
|
||||||
|
return <div className="py-8 text-center text-dark-400">{t('info.noContent')}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (infoPage.page_type === 'faq') {
|
||||||
|
if (infoPageFaqItems.length === 0) {
|
||||||
|
return <div className="py-8 text-center text-dark-400">{t('info.noFaq')}</div>;
|
||||||
|
}
|
||||||
|
return <ReplacementFaqView items={infoPageFaqItems} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!infoPageHtml) {
|
||||||
|
return <div className="py-8 text-center text-dark-400">{t('info.noContent')}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bento-card prose prose-invert max-w-none">
|
||||||
|
<div className="overflow-x-auto" dangerouslySetInnerHTML={{ __html: infoPageHtml }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
|
// Custom page tab — always render InfoPage content
|
||||||
|
if (isCustomTab) {
|
||||||
|
return renderInfoPageContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show spinner while tab replacements are loading (prevents flash of wrong content)
|
||||||
|
if (!replacementsLoaded) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built-in tab replaced by an InfoPage
|
||||||
|
if (currentTabSlug) {
|
||||||
|
return renderInfoPageContent();
|
||||||
|
}
|
||||||
|
|
||||||
if (activeTab === 'faq') {
|
if (activeTab === 'faq') {
|
||||||
if (faqLoading) {
|
if (faqLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -231,7 +570,7 @@ export default function Info() {
|
|||||||
<div key={faq.id} className="bento-card overflow-hidden p-0">
|
<div key={faq.id} className="bento-card overflow-hidden p-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleFaq(faq.id)}
|
onClick={() => toggleFaq(faq.id)}
|
||||||
className="flex w-full items-center justify-between px-4 py-3 text-left transition-colors hover:bg-dark-800/50"
|
className="flex min-h-[52px] w-full items-center justify-between px-4 py-3 text-left transition-colors hover:bg-dark-800/50"
|
||||||
>
|
>
|
||||||
<span className="font-medium">{faq.title}</span>
|
<span className="font-medium">{faq.title}</span>
|
||||||
<ChevronIcon expanded={expandedFaq === faq.id} />
|
<ChevronIcon expanded={expandedFaq === faq.id} />
|
||||||
@@ -262,7 +601,10 @@ export default function Info() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bento-card prose prose-invert max-w-none">
|
<div className="bento-card prose prose-invert max-w-none">
|
||||||
<div dangerouslySetInnerHTML={{ __html: formatContent(rules.content) }} />
|
<div
|
||||||
|
className="overflow-x-auto"
|
||||||
|
dangerouslySetInnerHTML={{ __html: formatContent(rules.content) }}
|
||||||
|
/>
|
||||||
{rules.updated_at && (
|
{rules.updated_at && (
|
||||||
<p className="mt-6 border-t border-dark-700 pt-4 text-sm text-dark-400">
|
<p className="mt-6 border-t border-dark-700 pt-4 text-sm text-dark-400">
|
||||||
{t('info.updatedAt')}: {new Date(rules.updated_at).toLocaleDateString()}
|
{t('info.updatedAt')}: {new Date(rules.updated_at).toLocaleDateString()}
|
||||||
@@ -287,7 +629,10 @@ export default function Info() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bento-card prose prose-invert max-w-none">
|
<div className="bento-card prose prose-invert max-w-none">
|
||||||
<div dangerouslySetInnerHTML={{ __html: formatContent(privacy.content) }} />
|
<div
|
||||||
|
className="overflow-x-auto"
|
||||||
|
dangerouslySetInnerHTML={{ __html: formatContent(privacy.content) }}
|
||||||
|
/>
|
||||||
{privacy.updated_at && (
|
{privacy.updated_at && (
|
||||||
<p className="mt-6 border-t border-dark-700 pt-4 text-sm text-dark-400">
|
<p className="mt-6 border-t border-dark-700 pt-4 text-sm text-dark-400">
|
||||||
{t('info.updatedAt')}: {new Date(privacy.updated_at).toLocaleDateString()}
|
{t('info.updatedAt')}: {new Date(privacy.updated_at).toLocaleDateString()}
|
||||||
@@ -312,7 +657,10 @@ export default function Info() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bento-card prose prose-invert max-w-none">
|
<div className="bento-card prose prose-invert max-w-none">
|
||||||
<div dangerouslySetInnerHTML={{ __html: formatContent(offer.content) }} />
|
<div
|
||||||
|
className="overflow-x-auto"
|
||||||
|
dangerouslySetInnerHTML={{ __html: formatContent(offer.content) }}
|
||||||
|
/>
|
||||||
{offer.updated_at && (
|
{offer.updated_at && (
|
||||||
<p className="mt-6 border-t border-dark-700 pt-4 text-sm text-dark-400">
|
<p className="mt-6 border-t border-dark-700 pt-4 text-sm text-dark-400">
|
||||||
{t('info.updatedAt')}: {new Date(offer.updated_at).toLocaleDateString()}
|
{t('info.updatedAt')}: {new Date(offer.updated_at).toLocaleDateString()}
|
||||||
@@ -384,13 +732,13 @@ export default function Info() {
|
|||||||
<div className="mb-4 grid grid-cols-2 gap-4">
|
<div className="mb-4 grid grid-cols-2 gap-4">
|
||||||
<div className="rounded-xl bg-dark-800/50 p-3">
|
<div className="rounded-xl bg-dark-800/50 p-3">
|
||||||
<div className="mb-1 text-xs text-dark-400">{t('info.totalSpent')}</div>
|
<div className="mb-1 text-xs text-dark-400">{t('info.totalSpent')}</div>
|
||||||
<div className="text-lg font-bold text-dark-50">
|
<div className="truncate text-base font-bold text-dark-50 sm:text-lg">
|
||||||
{formatCurrency(loyaltyData.current_spent_rubles)}
|
{formatCurrency(loyaltyData.current_spent_rubles)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl bg-dark-800/50 p-3">
|
<div className="rounded-xl bg-dark-800/50 p-3">
|
||||||
<div className="mb-1 text-xs text-dark-400">{t('info.currentStatus')}</div>
|
<div className="mb-1 text-xs text-dark-400">{t('info.currentStatus')}</div>
|
||||||
<div className="text-lg font-bold text-accent-400">
|
<div className="truncate text-base font-bold text-accent-400 sm:text-lg">
|
||||||
{loyaltyData.current_tier_name || '-'}
|
{loyaltyData.current_tier_name || '-'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -399,14 +747,17 @@ export default function Info() {
|
|||||||
{/* Progress bar to next tier */}
|
{/* Progress bar to next tier */}
|
||||||
{loyaltyData.next_tier_name && loyaltyData.next_tier_threshold_rubles ? (
|
{loyaltyData.next_tier_name && loyaltyData.next_tier_threshold_rubles ? (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 flex justify-between text-xs text-dark-400">
|
<div className="mb-2 flex flex-col gap-1 text-xs text-dark-400 sm:flex-row sm:justify-between">
|
||||||
<span>
|
<span>
|
||||||
{t('info.nextStatus')}: {loyaltyData.next_tier_name}
|
{t('info.nextStatus')}: {loyaltyData.next_tier_name}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{t('info.toNextStatus')}:{' '}
|
{t('info.toNextStatus')}:{' '}
|
||||||
{formatCurrency(
|
{formatCurrency(
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
loyaltyData.next_tier_threshold_rubles - loyaltyData.current_spent_rubles,
|
loyaltyData.next_tier_threshold_rubles - loyaltyData.current_spent_rubles,
|
||||||
|
),
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -453,14 +804,14 @@ export default function Info() {
|
|||||||
>
|
>
|
||||||
<StarIcon />
|
<StarIcon />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h4 className="font-semibold text-dark-50">{tier.name}</h4>
|
<h4 className="truncate font-semibold text-dark-50">{tier.name}</h4>
|
||||||
<p className="text-xs text-dark-400">
|
<p className="text-xs text-dark-400">
|
||||||
{t('info.threshold')}: {formatCurrency(tier.threshold_rubles)}
|
{t('info.threshold')}: {formatCurrency(tier.threshold_rubles)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{getStatusBadge(tier)}
|
<span className="shrink-0">{getStatusBadge(tier)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Discounts */}
|
{/* Discounts */}
|
||||||
@@ -514,19 +865,19 @@ export default function Info() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="scrollbar-hide flex gap-2 overflow-x-auto pb-1 sm:flex-wrap sm:overflow-x-visible">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={`flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
|
className={`flex min-h-[44px] shrink-0 items-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium transition-colors ${
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'bg-accent-500 text-white'
|
? 'bg-accent-500 text-white'
|
||||||
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
|
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<tab.icon />
|
{tab.emoji ? <span className="text-base">{tab.emoji}</span> : <tab.icon />}
|
||||||
{tab.label}
|
<span className="max-w-[140px] truncate">{tab.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
444
src/pages/InfoPageView.tsx
Normal file
444
src/pages/InfoPageView.tsx
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
import { infoPagesApi } from '../api/infoPages';
|
||||||
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
|
import type { FaqItem } from '../api/infoPages';
|
||||||
|
|
||||||
|
// Icons
|
||||||
|
const BackIcon = () => (
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 text-dark-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitization config — same strict allowlist as NewsArticlePage.
|
||||||
|
* All HTML content is sanitized with DOMPurify before rendering.
|
||||||
|
*/
|
||||||
|
const ALLOWED_IFRAME_HOSTS = new Set([
|
||||||
|
'www.youtube.com',
|
||||||
|
'youtube.com',
|
||||||
|
'player.vimeo.com',
|
||||||
|
'www.youtube-nocookie.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isAllowedIframeSrc(src: string): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(src);
|
||||||
|
return url.protocol === 'https:' && ALLOWED_IFRAME_HOSTS.has(url.hostname);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SANITIZE_CONFIG = {
|
||||||
|
ALLOWED_TAGS: [
|
||||||
|
'p',
|
||||||
|
'div',
|
||||||
|
'br',
|
||||||
|
'hr',
|
||||||
|
'h1',
|
||||||
|
'h2',
|
||||||
|
'h3',
|
||||||
|
'h4',
|
||||||
|
'h5',
|
||||||
|
'h6',
|
||||||
|
'blockquote',
|
||||||
|
'pre',
|
||||||
|
'code',
|
||||||
|
'ul',
|
||||||
|
'ol',
|
||||||
|
'li',
|
||||||
|
'table',
|
||||||
|
'thead',
|
||||||
|
'tbody',
|
||||||
|
'tr',
|
||||||
|
'th',
|
||||||
|
'td',
|
||||||
|
'a',
|
||||||
|
'strong',
|
||||||
|
'b',
|
||||||
|
'em',
|
||||||
|
'i',
|
||||||
|
'u',
|
||||||
|
's',
|
||||||
|
'del',
|
||||||
|
'ins',
|
||||||
|
'span',
|
||||||
|
'mark',
|
||||||
|
'sub',
|
||||||
|
'sup',
|
||||||
|
'small',
|
||||||
|
'img',
|
||||||
|
'video',
|
||||||
|
'iframe',
|
||||||
|
'figure',
|
||||||
|
'figcaption',
|
||||||
|
],
|
||||||
|
ALLOWED_ATTR: [
|
||||||
|
'href',
|
||||||
|
'target',
|
||||||
|
'rel',
|
||||||
|
'src',
|
||||||
|
'alt',
|
||||||
|
'title',
|
||||||
|
'width',
|
||||||
|
'height',
|
||||||
|
'loading',
|
||||||
|
'class',
|
||||||
|
'start',
|
||||||
|
'reversed',
|
||||||
|
'type',
|
||||||
|
'controls',
|
||||||
|
'preload',
|
||||||
|
'frameborder',
|
||||||
|
'allowfullscreen',
|
||||||
|
'allow',
|
||||||
|
'sandbox',
|
||||||
|
'style',
|
||||||
|
],
|
||||||
|
ALLOW_DATA_ATTR: false,
|
||||||
|
ADD_ATTR: ['target'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Isolated DOMPurify instance for info page content sanitization.
|
||||||
|
* All user-generated HTML is sanitized before being rendered.
|
||||||
|
*/
|
||||||
|
const infoPagePurify = DOMPurify(window);
|
||||||
|
|
||||||
|
infoPagePurify.addHook('afterSanitizeAttributes', (node) => {
|
||||||
|
if (node.tagName === 'IFRAME') {
|
||||||
|
const src = node.getAttribute('src') ?? '';
|
||||||
|
if (!isAllowedIframeSrc(src)) {
|
||||||
|
node.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
node.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-presentation');
|
||||||
|
node.setAttribute('allow', 'autoplay; encrypted-media; picture-in-picture');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
infoPagePurify.addHook('afterSanitizeAttributes', (node) => {
|
||||||
|
if (node.tagName === 'VIDEO') {
|
||||||
|
const src = node.getAttribute('src') ?? '';
|
||||||
|
try {
|
||||||
|
const url = new URL(src);
|
||||||
|
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||||
|
node.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
node.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
node.setAttribute('controls', '');
|
||||||
|
node.setAttribute('preload', 'metadata');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
infoPagePurify.addHook('afterSanitizeAttributes', (node) => {
|
||||||
|
if (node.tagName === 'A') {
|
||||||
|
node.setAttribute('target', '_blank');
|
||||||
|
node.setAttribute('rel', 'noopener noreferrer');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
infoPagePurify.addHook('afterSanitizeAttributes', (node) => {
|
||||||
|
if (node.hasAttribute('style')) {
|
||||||
|
const style = node.getAttribute('style') ?? '';
|
||||||
|
const match = style.match(/text-align\s*:\s*(left|center|right|justify)/i);
|
||||||
|
if (match) {
|
||||||
|
node.setAttribute('style', `text-align: ${match[1]}`);
|
||||||
|
} else {
|
||||||
|
node.removeAttribute('style');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function sanitizeHtml(html: string): string {
|
||||||
|
return infoPagePurify.sanitize(html, SANITIZE_CONFIG);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FAQ Accordion ---
|
||||||
|
const ChevronIcon = ({ open }: { open: boolean }) => (
|
||||||
|
<svg
|
||||||
|
className={`h-5 w-5 text-dark-400 transition-transform duration-300 ${open ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SearchIcon = () => (
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4 text-dark-500"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
function FaqAccordionItem({
|
||||||
|
item,
|
||||||
|
isOpen,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
item: FaqItem;
|
||||||
|
isOpen: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [height, setHeight] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (contentRef.current) {
|
||||||
|
setHeight(isOpen ? contentRef.current.scrollHeight : 0);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Update height when content resizes (images loading, viewport rotation)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !contentRef.current) return;
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
if (contentRef.current) setHeight(contentRef.current.scrollHeight);
|
||||||
|
});
|
||||||
|
observer.observe(contentRef.current);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const sanitizedAnswer = useMemo(() => sanitizeHtml(item.a), [item.a]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-xl border border-dark-700 bg-dark-800/50 transition-all hover:border-dark-600">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
className="flex min-h-[52px] w-full items-center justify-between gap-3 px-5 py-4 text-left"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium text-dark-100 sm:text-base">{item.q}</span>
|
||||||
|
<ChevronIcon open={isOpen} />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
style={{ height }}
|
||||||
|
className="overflow-hidden transition-[height] duration-300 ease-in-out"
|
||||||
|
>
|
||||||
|
<div ref={contentRef} className="border-t border-dark-700/50 px-5 pb-4 pt-3">
|
||||||
|
<div
|
||||||
|
className="prose prose-sm max-w-none text-dark-300"
|
||||||
|
dangerouslySetInnerHTML={{ __html: sanitizedAnswer }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FaqView({ items }: { items: FaqItem[] }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [openKey, setOpenKey] = useState<string | null>(null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
const handleToggle = useCallback((key: string) => {
|
||||||
|
setOpenKey((prev) => (prev === key ? null : key));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
if (!search.trim()) return items;
|
||||||
|
const lower = search.toLowerCase();
|
||||||
|
return items.filter((item) => item.q.toLowerCase().includes(lower));
|
||||||
|
}, [items, search]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Search */}
|
||||||
|
{items.length > 3 && (
|
||||||
|
<div className="relative">
|
||||||
|
<div className="pointer-events-none absolute inset-y-0 left-3 flex items-center">
|
||||||
|
<SearchIcon />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
setOpenKey(null);
|
||||||
|
}}
|
||||||
|
placeholder={t('admin.infoPages.faq.searchPlaceholder')}
|
||||||
|
className="input pl-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Accordion items */}
|
||||||
|
{filteredItems.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-6 text-center text-sm text-dark-400">
|
||||||
|
{search ? t('admin.infoPages.faq.noResults') : t('admin.infoPages.faq.noQuestions')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filteredItems.map((item, index) => {
|
||||||
|
const key = `${index}-${item.q.slice(0, 50)}`;
|
||||||
|
return (
|
||||||
|
<FaqAccordionItem
|
||||||
|
key={key}
|
||||||
|
item={item}
|
||||||
|
isOpen={openKey === key}
|
||||||
|
onToggle={() => handleToggle(key)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InfoPageView() {
|
||||||
|
const { slug } = useParams<{ slug: string }>();
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { capabilities, backButton } = usePlatform();
|
||||||
|
|
||||||
|
const navigateRef = useRef(navigate);
|
||||||
|
useEffect(() => {
|
||||||
|
navigateRef.current = navigate;
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!capabilities.hasBackButton) return;
|
||||||
|
backButton.show(() => navigateRef.current(-1));
|
||||||
|
return () => backButton.hide();
|
||||||
|
}, [capabilities.hasBackButton, backButton]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: page,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['info-pages', 'page', slug],
|
||||||
|
queryFn: () => {
|
||||||
|
if (!slug) throw new Error('Missing slug parameter');
|
||||||
|
return infoPagesApi.getPageBySlug(slug);
|
||||||
|
},
|
||||||
|
enabled: !!slug,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const locale = i18n.language.split('-')[0];
|
||||||
|
|
||||||
|
const resolvedTitle = useMemo(() => {
|
||||||
|
if (!page) return '';
|
||||||
|
return page.title[locale] || page.title['ru'] || page.title['en'] || '';
|
||||||
|
}, [page, locale]);
|
||||||
|
|
||||||
|
const isFaq = page?.page_type === 'faq';
|
||||||
|
|
||||||
|
// Parse FAQ items from content
|
||||||
|
const faqItems = useMemo((): FaqItem[] => {
|
||||||
|
if (!page || !isFaq) return [];
|
||||||
|
const raw = page.content[locale] || page.content['ru'] || page.content['en'] || '[]';
|
||||||
|
try {
|
||||||
|
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [page, locale, isFaq]);
|
||||||
|
|
||||||
|
// Content is sanitized with DOMPurify before rendering
|
||||||
|
const sanitizedContent = useMemo(() => {
|
||||||
|
if (!page || isFaq) return '';
|
||||||
|
const rawContent = page.content[locale] || page.content['ru'] || page.content['en'] || '';
|
||||||
|
return sanitizeHtml(rawContent);
|
||||||
|
}, [page, locale, isFaq]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="skeleton h-8 w-32 rounded-lg" />
|
||||||
|
<div className="skeleton h-10 w-3/4 rounded-lg" />
|
||||||
|
<div className="skeleton h-64 w-full rounded-xl" />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="skeleton h-4 w-full rounded" />
|
||||||
|
<div className="skeleton h-4 w-5/6 rounded" />
|
||||||
|
<div className="skeleton h-4 w-4/6 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !page) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{!capabilities.hasBackButton && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/info')}
|
||||||
|
className="flex min-h-[44px] min-w-[44px] items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||||
|
aria-label={t('common.back')}
|
||||||
|
>
|
||||||
|
<BackIcon />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-8 text-center text-dark-400">
|
||||||
|
{t('admin.infoPages.notFound')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Back button */}
|
||||||
|
{!capabilities.hasBackButton && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="flex min-h-[44px] items-center gap-2 rounded-xl border border-dark-700 bg-dark-800 px-4 text-sm text-dark-400 transition-colors hover:border-dark-600 hover:text-dark-200"
|
||||||
|
aria-label={t('common.back')}
|
||||||
|
>
|
||||||
|
<BackIcon />
|
||||||
|
<span>{t('common.back')}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Page header */}
|
||||||
|
<div>
|
||||||
|
{page.icon && <span className="mb-2 inline-block text-3xl">{page.icon}</span>}
|
||||||
|
<h1 className="text-2xl font-extrabold leading-tight text-dark-50 sm:text-3xl">
|
||||||
|
{resolvedTitle}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Page content */}
|
||||||
|
{isFaq ? (
|
||||||
|
<FaqView items={faqItems} />
|
||||||
|
) : (
|
||||||
|
/* Regular page content - sanitized with DOMPurify (strict allowlist) */
|
||||||
|
<div
|
||||||
|
className="prose max-w-none overflow-x-auto lg:max-w-3xl"
|
||||||
|
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -967,6 +967,9 @@ img.twemoji {
|
|||||||
|
|
||||||
.prose table {
|
.prose table {
|
||||||
@apply mb-4 w-full border-collapse;
|
@apply mb-4 w-full border-collapse;
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose th {
|
.prose th {
|
||||||
@@ -978,7 +981,7 @@ img.twemoji {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.prose img {
|
.prose img {
|
||||||
@apply my-4 max-w-full rounded-xl;
|
@apply my-4 h-auto max-w-full rounded-xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose mark {
|
.prose mark {
|
||||||
@@ -989,6 +992,10 @@ img.twemoji {
|
|||||||
@apply my-4 block aspect-video w-full max-w-2xl rounded-xl;
|
@apply my-4 block aspect-video w-full max-w-2xl rounded-xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prose video {
|
||||||
|
@apply my-4 block h-auto w-full max-w-full rounded-xl;
|
||||||
|
}
|
||||||
|
|
||||||
/* Light theme prose styles */
|
/* Light theme prose styles */
|
||||||
.light .prose {
|
.light .prose {
|
||||||
@apply text-champagne-800;
|
@apply text-champagne-800;
|
||||||
@@ -1070,6 +1077,14 @@ img.twemoji {
|
|||||||
@apply border border-champagne-300;
|
@apply border border-champagne-300;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.light .prose video {
|
||||||
|
@apply border border-champagne-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light .prose img {
|
||||||
|
@apply border border-champagne-300;
|
||||||
|
}
|
||||||
|
|
||||||
/* Support for plain text with line breaks */
|
/* Support for plain text with line breaks */
|
||||||
.prose-plain {
|
.prose-plain {
|
||||||
@apply whitespace-pre-line;
|
@apply whitespace-pre-line;
|
||||||
|
|||||||
Reference in New Issue
Block a user