diff --git a/src/App.tsx b/src/App.tsx index b030a73..096c55c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -123,6 +123,7 @@ const AdminRemnawave = lazyWithRetry(() => import('./pages/AdminRemnawave')); const AdminRemnawaveSquadDetail = lazyWithRetry(() => import('./pages/AdminRemnawaveSquadDetail')); const AdminEmailTemplates = lazyWithRetry(() => import('./pages/AdminEmailTemplates')); const AdminTrafficUsage = lazyWithRetry(() => import('./pages/AdminTrafficUsage')); +const AdminBulkActions = lazyWithRetry(() => import('./pages/AdminBulkActions')); const AdminSalesStats = lazyWithRetry(() => import('./pages/AdminSalesStats')); const AdminUpdates = lazyWithRetry(() => import('./pages/AdminUpdates')); const AdminUserDetail = lazyWithRetry(() => import('./pages/AdminUserDetail')); @@ -147,6 +148,11 @@ const NewsArticlePage = lazyWithRetry(() => import('./pages/NewsArticle')); const AdminNews = lazyWithRetry(() => import('./pages/AdminNews')); 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({ children, withLayout = true, @@ -554,6 +560,16 @@ function App() { } /> + + + + + + } + /> {/* Admin routes */} } /> + + + + + + } + /> + {/* Info pages admin routes */} + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + => { + 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 => { + 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, + }); + } + }, +}; diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index 3a0a23a..8697196 100644 --- a/src/api/adminUsers.ts +++ b/src/api/adminUsers.ts @@ -33,6 +33,18 @@ export interface UserPromoGroupInfo { 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 { id: number; telegram_id: number; @@ -49,6 +61,12 @@ export interface UserListItem { subscription_status: string | null; subscription_is_trial: boolean; 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_name: string | null; total_spent_kopeks: number; @@ -56,6 +74,7 @@ export interface UserListItem { has_restrictions: boolean; restriction_topup: boolean; restriction_subscription: boolean; + subscriptions?: UserListItemSubscription[]; } export interface UsersListResponse { @@ -392,6 +411,11 @@ export const adminUsersApi = { search?: string; email?: string; status?: 'active' | 'blocked' | 'deleted'; + subscription_status?: string; + tariff_id?: string; + promo_group_id?: number; + campaign_id?: number; + partner_id?: number; sort_by?: | 'created_at' | 'balance' diff --git a/src/api/infoPages.ts b/src/api/infoPages.ts new file mode 100644 index 0000000..39a39a4 --- /dev/null +++ b/src/api/infoPages.ts @@ -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; + content: Record; + 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; + 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; + content: Record; + page_type: InfoPageType; + is_active: boolean; + sort_order: number; + icon: string | null; + replaces_tab: ReplacesTab | null; +} + +export interface InfoPageUpdateRequest { + slug?: string; + title?: Record; + content?: Record; + page_type?: InfoPageType; + is_active?: boolean; + sort_order?: number; + icon?: string | null; + replaces_tab?: ReplacesTab | null; +} + +export type TabReplacements = Record; + +/** 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 => { + const response = await apiClient.get('/cabinet/info-pages/tab-replacements'); + return response.data; + }, + + getPages: async (pageType?: InfoPageType): Promise => { + const params = pageType ? { page_type: pageType } : undefined; + const response = await apiClient.get('/cabinet/info-pages', { params }); + return response.data; + }, + + getPageBySlug: async (slug: string): Promise => { + const response = await apiClient.get( + `/cabinet/info-pages/${encodeURIComponent(slug)}`, + ); + return response.data; + }, + + // Admin endpoints + getAdminPages: async (pageType?: InfoPageType): Promise => { + const params = pageType ? { page_type: pageType } : undefined; + const response = await apiClient.get('/cabinet/admin/info-pages', { + params, + }); + return response.data; + }, + + getAdminPage: async (id: number): Promise => { + const response = await apiClient.get(`/cabinet/admin/info-pages/${id}`); + return response.data; + }, + + createPage: async (data: InfoPageCreateRequest): Promise => { + const response = await apiClient.post('/cabinet/admin/info-pages', data); + return response.data; + }, + + updatePage: async (id: number, data: InfoPageUpdateRequest): Promise => { + const response = await apiClient.put(`/cabinet/admin/info-pages/${id}`, data); + return response.data; + }, + + deletePage: async (id: number): Promise => { + await apiClient.delete(`/cabinet/admin/info-pages/${id}`); + }, + + toggleActive: async (id: number): Promise => { + const response = await apiClient.post( + `/cabinet/admin/info-pages/${id}/toggle-active`, + ); + return response.data; + }, + + reorder: async (data: InfoPageReorderRequest): Promise => { + await apiClient.post('/cabinet/admin/info-pages/reorder', data); + }, +}; diff --git a/src/locales/en.json b/src/locales/en.json index 9fb58e4..4263969 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1125,7 +1125,9 @@ "salesStats": "Sales Statistics", "landings": "Landings", "referralNetwork": "Referral Network", - "news": "News" + "news": "News", + "bulkActions": "Bulk Actions", + "infoPages": "Info Pages" }, "panel": { "title": "Admin Panel", @@ -2021,6 +2023,113 @@ "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": { "accentColor": "Accent color", "customizeColors": "Customize colors", @@ -3698,6 +3807,76 @@ "prev": "Previous", "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": { diff --git a/src/locales/fa.json b/src/locales/fa.json index fbe1cde..3c29a4a 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -949,7 +949,8 @@ "salesStats": "آمار فروش", "landings": "صفحات فرود", "referralNetwork": "شبکه ارجاع", - "news": "اخبار" + "news": "اخبار", + "infoPages": "صفحات اطلاعات" }, "panel": { "title": "پنل مدیریت", @@ -3366,6 +3367,183 @@ "periodDaySuffix": "روز", "localeTab": "زبان", "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": { diff --git a/src/locales/ru.json b/src/locales/ru.json index 2c69a25..6de3b43 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1146,7 +1146,9 @@ "salesStats": "Статистика продаж", "landings": "Лендинги", "referralNetwork": "Реферальная сеть", - "news": "Новости" + "news": "Новости", + "bulkActions": "Массовые действия", + "infoPages": "Инфо-страницы" }, "panel": { "title": "Панель администратора", @@ -3769,6 +3771,113 @@ "users": "Пользователи", "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": { "accentColor": "Акцентный цвет", "customizeColors": "Настроить цвета", @@ -4242,6 +4351,76 @@ "prev": "Назад", "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": { diff --git a/src/locales/zh.json b/src/locales/zh.json index 5d775b2..8661527 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -949,7 +949,8 @@ "salesStats": "销售统计", "landings": "落地页", "referralNetwork": "推荐网络", - "news": "新闻" + "news": "新闻", + "infoPages": "信息页面" }, "panel": { "title": "管理面板", @@ -3365,6 +3366,183 @@ "periodDaySuffix": "天", "localeTab": "语言", "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": { diff --git a/src/pages/AdminBulkActions.tsx b/src/pages/AdminBulkActions.tsx new file mode 100644 index 0000000..0a9c286 --- /dev/null +++ b/src/pages/AdminBulkActions.tsx @@ -0,0 +1,2425 @@ +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { useNavigate } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { + useReactTable, + getCoreRowModel, + flexRender, + type ColumnDef, + type RowSelectionState, +} from '@tanstack/react-table'; +import { adminUsersApi, type UserListItem, type UserListItemSubscription } from '../api/adminUsers'; +import { tariffsApi, type TariffListItem } from '../api/tariffs'; +import { promocodesApi, type PromoGroup } from '../api/promocodes'; +import { campaignsApi, type CampaignListItem } from '../api/campaigns'; +import { partnerApi, type AdminPartnerItem } from '../api/partners'; +import { + adminBulkActionsApi, + type BulkActionType, + type BulkActionParams, + type BulkActionResult, + type BulkProgressEvent, +} from '../api/adminBulkActions'; +import { usePlatform } from '../platform/hooks/usePlatform'; +import { useCurrency } from '../hooks/useCurrency'; +import { cn } from '@/lib/utils'; + +// ============ Types ============ + +type SubscriptionStatusFilter = '' | 'active' | 'expired' | 'trial' | 'limited' | 'disabled'; + +interface ActionConfig { + type: BulkActionType; + labelKey: string; + icon: React.ReactNode; + colorClass: string; +} + +interface ProgressState { + current: number; + total: number; + successCount: number; + errorCount: number; + log: BulkProgressEvent[]; +} + +interface ModalState { + open: boolean; + action: BulkActionType | null; + loading: boolean; + result: BulkActionResult | null; + progress: ProgressState | null; +} + +// ============ Icons ============ + +const BackIcon = () => ( + + + +); + +const SearchIcon = () => ( + + + +); + +const ChevronLeftIcon = () => ( + + + +); + +const ChevronRightIcon = () => ( + + + +); + +const RefreshIcon = ({ className = 'w-5 h-5' }: { className?: string }) => ( + + + +); + +const CheckIcon = () => ( + + + +); + +const XCloseIcon = () => ( + + + +); + +const ChevronDownIcon = () => ( + + + +); + +const ChevronExpandIcon = ({ expanded }: { expanded: boolean }) => ( + + + +); + +// ============ Action target helpers ============ + +const SUBSCRIPTION_LEVEL_ACTIONS: Set = new Set([ + 'extend_subscription', + 'add_days', + 'cancel_subscription', + 'activate_subscription', + 'change_tariff', + 'add_traffic', + 'set_devices', + 'delete_subscription', +]); + +function isSubscriptionLevelAction(action: BulkActionType): boolean { + return SUBSCRIPTION_LEVEL_ACTIONS.has(action); +} + +// ============ Subscription sub-row ============ + +interface SubscriptionSubRowProps { + subscription: UserListItemSubscription; + isSelected: boolean; + onToggleSelect: () => void; + isMultiTariff: boolean; +} + +function SubscriptionSubRow({ + subscription, + isSelected, + onToggleSelect, + isMultiTariff, +}: SubscriptionSubRowProps) { + const { t } = useTranslation(); + + const days = subscription.days_remaining; + const daysColor = + days === 0 ? 'text-error-400' : days <= 7 ? 'text-amber-400' : 'text-success-400'; + + const trafficPercent = + subscription.traffic_limit_gb > 0 + ? Math.min(100, (subscription.traffic_used_gb / subscription.traffic_limit_gb) * 100) + : 0; + const trafficBarColor = + trafficPercent >= 90 ? 'bg-error-400' : trafficPercent >= 70 ? 'bg-amber-400' : 'bg-accent-400'; + + return ( + + {/* Checkbox cell */} + + {isMultiTariff && ( +
+ +
+ )} + + + {/* Subscription info — spans remaining columns */} + +
+ {/* Tariff name */} +
+ + + {subscription.tariff_name || '—'} + +
+ + {/* Status */} + + + {/* Days remaining */} + + {days} + + {t('admin.bulkActions.daysUnit')} + + + + {/* Traffic progress */} +
+ + {subscription.traffic_used_gb.toFixed(1)} {t('admin.bulkActions.trafficOf')}{' '} + {subscription.traffic_limit_gb} {t('admin.bulkActions.trafficGbUnit')} + +
+
+
+
+ + {/* Devices */} + + + + + {subscription.device_limit} + +
+ + + ); +} + +// ============ Status badge ============ + +function StatusBadge({ status }: { status: string | null }) { + const { t } = useTranslation(); + + const config: Record = { + active: { + class: 'border-success-500/30 bg-success-500/15 text-success-400', + labelKey: 'admin.bulkActions.statuses.active', + }, + expired: { + class: 'border-error-500/30 bg-error-500/15 text-error-400', + labelKey: 'admin.bulkActions.statuses.expired', + }, + trial: { + class: 'border-amber-500/30 bg-amber-500/15 text-amber-400', + labelKey: 'admin.bulkActions.statuses.trial', + }, + limited: { + class: 'border-amber-500/30 bg-amber-500/15 text-amber-400', + labelKey: 'admin.bulkActions.statuses.limited', + }, + disabled: { + class: 'border-dark-500/30 bg-dark-500/15 text-dark-400', + labelKey: 'admin.bulkActions.statuses.disabled', + }, + }; + + const c = config[status || ''] || config.disabled; + + return ( + + {t(c.labelKey, status || '')} + + ); +} + +// ============ Progress Bar ============ + +function ProgressBar({ loading }: { loading: boolean }) { + const [progress, setProgress] = useState(0); + const [visible, setVisible] = useState(false); + const intervalRef = useRef>(undefined); + + useEffect(() => { + if (loading) { + setProgress(0); + setVisible(true); + intervalRef.current = setInterval(() => { + setProgress((prev) => { + if (prev < 30) return prev + 8; + if (prev < 60) return prev + 3; + if (prev < 85) return prev + 1; + if (prev < 95) return prev + 0.3; + return prev; + }); + }, 100); + } else { + if (visible) { + setProgress(100); + clearInterval(intervalRef.current); + const timer = setTimeout(() => { + setVisible(false); + setProgress(0); + }, 300); + return () => clearTimeout(timer); + } + } + return () => clearInterval(intervalRef.current); + }, [loading, visible]); + + if (!visible) return null; + + return ( +
+
+
+ ); +} + +// ============ Dropdown Select ============ + +interface DropdownOption { + value: string; + label: string; +} + +function DropdownSelect({ + value, + options, + onChange, + className, +}: { + value: string; + options: DropdownOption[]; + onChange: (v: string) => void; + className?: string; +}) { + return ( +
+ +
+ +
+
+ ); +} + +// ============ Multi-Select Dropdown ============ + +interface MultiSelectOption { + value: number; + label: string; +} + +interface MultiSelectDropdownProps { + options: MultiSelectOption[]; + selected: number[]; + onChange: (ids: number[]) => void; + placeholder: string; + className?: string; +} + +function MultiSelectDropdown({ + options, + selected, + onChange, + placeholder, + className, +}: MultiSelectDropdownProps) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [open]); + + const buttonLabel = useMemo(() => { + if (selected.length === 0) return placeholder; + if (selected.length <= 2) { + return selected + .map((id) => options.find((o) => o.value === id)?.label) + .filter(Boolean) + .join(', '); + } + return t('admin.bulkActions.filters.tariffsSelected', { count: selected.length }); + }, [selected, options, placeholder, t]); + + const handleToggle = (value: number) => { + if (selected.includes(value)) { + onChange(selected.filter((id) => id !== value)); + } else { + onChange([...selected, value]); + } + }; + + const handleSelectAll = () => { + onChange(options.map((o) => o.value)); + }; + + const handleDeselectAll = () => { + onChange([]); + }; + + return ( +
+ + + {open && ( +
+ {/* Select all / Deselect all */} +
+ + / + +
+ + {/* Options */} + {options.map((option) => { + const isChecked = selected.includes(option.value); + return ( + + ); + })} +
+ )} +
+ ); +} + +// ============ Progress View ============ + +function ProgressView({ progress }: { progress: ProgressState }) { + const { t } = useTranslation(); + const logEndRef = useRef(null); + const percentage = progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0; + + useEffect(() => { + logEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [progress.log.length]); + + return ( +
+ {/* Progress bar */} +
+
+ + {t('admin.bulkActions.progress.processed', { + current: progress.current, + total: progress.total, + })} + + {percentage}% +
+
+
+
+
+ + {/* Counters */} +
+
+
+ {progress.successCount} + {t('admin.bulkActions.progress.succeeded')} +
+
+
+ {progress.errorCount} + {t('admin.bulkActions.progress.failed')} +
+
+ + {/* Live log */} + {progress.log.length > 0 && ( +
+
+ {progress.log.slice(-10).map((entry, idx) => ( +
+ {entry.success ? ( + + ) : ( + + )} + + {entry.username ? `@${entry.username}` : `#${entry.user_id}`} + + + {entry.success + ? entry.message || t('admin.bulkActions.progress.ok') + : entry.message || entry.error || t('admin.bulkActions.progress.errorGeneric')} + +
+ ))} +
+
+
+ )} +
+ ); +} + +// ============ Error Details ============ + +function ErrorDetails({ result }: { result: BulkActionResult }) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + + if (result.error_count === 0 || result.errors.length === 0) return null; + + return ( +
+ + {expanded && ( +
+
+ {result.errors.map((err, idx) => ( +
+ + + {err.username ? `@${err.username}` : `#${err.user_id}`} + + {err.error} +
+ ))} +
+
+ )} +
+ ); +} + +// ============ Action Modal ============ + +interface ActionModalProps { + modal: ModalState; + selectedCount: number; + tariffs: TariffListItem[]; + promoGroups: PromoGroup[]; + onClose: () => void; + onExecute: (params: BulkActionParams) => void; +} + +function ActionModal({ + modal, + selectedCount, + tariffs, + promoGroups, + onClose, + onExecute, +}: ActionModalProps) { + const { t } = useTranslation(); + const [days, setDays] = useState(30); + const [tariffId, setTariffId] = useState(tariffs[0]?.id ?? 0); + const [trafficGb, setTrafficGb] = useState(10); + const [balanceRub, setBalanceRub] = useState(100); + const [promoGroupId, setPromoGroupId] = useState(promoGroups[0]?.id ?? null); + const [grantTariffId, setGrantTariffId] = useState(tariffs[0]?.id ?? 0); + const [grantDays, setGrantDays] = useState(30); + const [deviceLimit, setDeviceLimit] = useState(1); + const [deleteFromPanel, setDeleteFromPanel] = useState(true); + + useEffect(() => { + if (tariffs.length > 0 && tariffId === 0) { + setTariffId(tariffs[0].id); + } + if (tariffs.length > 0 && grantTariffId === 0) { + setGrantTariffId(tariffs[0].id); + } + }, [tariffs, tariffId, grantTariffId]); + + useEffect(() => { + if (promoGroups.length > 0 && promoGroupId === null) { + setPromoGroupId(promoGroups[0].id); + } + }, [promoGroups, promoGroupId]); + + // Reset deleteFromPanel to default when modal opens + useEffect(() => { + if (modal.open && modal.action === 'delete_user') { + setDeleteFromPanel(true); + } + }, [modal.open, modal.action]); + + // Escape key handler — only when not loading + useEffect(() => { + if (!modal.open) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !modal.loading) { + onClose(); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [modal.open, modal.loading, onClose]); + + if (!modal.open || !modal.action) return null; + + const actionLabelKeys: Record = { + extend_subscription: 'admin.bulkActions.actions.extend', + add_days: 'admin.bulkActions.actions.extend', + cancel_subscription: 'admin.bulkActions.actions.cancel', + activate_subscription: 'admin.bulkActions.actions.activate', + change_tariff: 'admin.bulkActions.actions.changeTariff', + add_traffic: 'admin.bulkActions.actions.addTraffic', + add_balance: 'admin.bulkActions.actions.addBalance', + assign_promo_group: 'admin.bulkActions.actions.assignPromoGroup', + grant_subscription: 'admin.bulkActions.actions.grantSubscription', + set_devices: 'admin.bulkActions.actions.setDevices', + delete_subscription: 'admin.bulkActions.actions.deleteSubscription', + delete_user: 'admin.bulkActions.actions.deleteUser', + }; + + const handleSubmit = () => { + const params: BulkActionParams = {}; + switch (modal.action) { + case 'extend_subscription': + params.days = days; + break; + case 'change_tariff': + params.tariff_id = tariffId; + break; + case 'add_traffic': + params.traffic_gb = trafficGb; + break; + case 'add_balance': + params.amount_kopeks = Math.round(balanceRub * 100); + break; + case 'assign_promo_group': + params.promo_group_id = promoGroupId; + break; + case 'grant_subscription': + params.tariff_id = grantTariffId; + params.days = grantDays; + break; + case 'set_devices': + params.device_limit = deviceLimit; + break; + case 'delete_user': + params.delete_from_panel = deleteFromPanel; + break; + } + onExecute(params); + }; + + const handleBackdropClick = () => { + if (!modal.loading) { + onClose(); + } + }; + + const renderInputs = () => { + switch (modal.action) { + case 'extend_subscription': + return ( +
+ + setDays(Number(e.target.value))} + className="w-full rounded-xl border border-dark-700 bg-dark-800 px-3 py-2.5 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500/40" + /> +
+ ); + case 'change_tariff': + return ( +
+ + ({ value: String(tt.id), label: tt.name }))} + onChange={(v) => setTariffId(Number(v))} + /> +
+ ); + case 'add_traffic': + return ( +
+ + setTrafficGb(Number(e.target.value))} + className="w-full rounded-xl border border-dark-700 bg-dark-800 px-3 py-2.5 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500/40" + /> +
+ ); + case 'add_balance': + return ( +
+ + setBalanceRub(Number(e.target.value))} + className="w-full rounded-xl border border-dark-700 bg-dark-800 px-3 py-2.5 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500/40" + /> +
+ ); + case 'assign_promo_group': + return ( +
+ + ({ value: String(pg.id), label: pg.name })), + ]} + onChange={(v) => setPromoGroupId(v === 'null' ? null : Number(v))} + /> +
+ ); + case 'set_devices': + return ( +
+ + setDeviceLimit(Number(e.target.value))} + className="w-full rounded-xl border border-dark-700 bg-dark-800 px-3 py-2.5 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500/40" + /> +
+ ); + case 'grant_subscription': + return ( +
+
+ + ({ value: String(tt.id), label: tt.name }))} + onChange={(v) => setGrantTariffId(Number(v))} + /> +
+
+ + setGrantDays(Number(e.target.value))} + className="w-full rounded-xl border border-dark-700 bg-dark-800 px-3 py-2.5 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500/40" + /> +
+ {/* Warning about users with existing subscriptions */} +
+

+ {t('admin.bulkActions.grantSubscription.warning')} +

+
+
+ ); + case 'delete_subscription': + return ( +
+

+ {t('admin.bulkActions.deleteSubscription.warning')} +

+

+ {t('admin.bulkActions.deleteSubscription.hint')} +

+
+ ); + case 'delete_user': + return ( +
+
+

+ {t('admin.bulkActions.deleteUser.warning')} +

+

+ {t('admin.bulkActions.deleteUser.hint')} +

+
+ +
+ ); + default: + return null; + } + }; + + return createPortal( +
+ {/* Backdrop — no close on click during loading */} +
+ + {/* Modal */} +
+ {/* Header */} +
+

{t(actionLabelKeys[modal.action])}

+ {!modal.loading && ( + + )} +
+ + {/* Progress view during execution */} + {modal.loading && modal.progress ? ( +
+ +

+ {t('admin.bulkActions.progress.doNotClose')} +

+
+ ) : modal.result ? ( + /* Result view */ +
+ {/* Summary line */} +
+
+ {t('admin.bulkActions.complete')} +
+ {/* Colored summary */} +
+ + {t('admin.bulkActions.progress.summarySuccess', { + count: modal.result.success_count, + })} + + {modal.result.error_count > 0 && ( + <> + + + {t('admin.bulkActions.progress.summaryErrors', { + count: modal.result.error_count, + })} + + + )} +
+
+
+
+ {modal.result.success_count} +
+
+ {t('admin.bulkActions.successCount', { count: modal.result.success_count })} +
+
+ {modal.result.error_count > 0 && ( +
+
+ {modal.result.error_count} +
+
+ {t('admin.bulkActions.errorCount', { count: modal.result.error_count })} +
+
+ )} +
+
+ + {/* Collapsible error details */} + + + +
+ ) : ( + <> + {/* Affected count */} +
+

+ {t('admin.bulkActions.selectedCount', { count: selectedCount })} +

+
+ + {/* Action-specific inputs */} +
{renderInputs()}
+ + {/* Buttons */} +
+ + +
+ + )} +
+
, + document.body, + ); +} + +// ============ Floating Action Bar ============ + +interface FloatingActionBarProps { + selectedUserCount: number; + selectedSubscriptionCount: number; + isMultiTariff: boolean; + totalVisibleSubscriptionCount: number; + onAction: (type: BulkActionType) => void; + onToggleAllSubscriptions: () => void; +} + +function FloatingActionBar({ + selectedUserCount, + selectedSubscriptionCount, + isMultiTariff, + totalVisibleSubscriptionCount, + onAction, + onToggleAllSubscriptions, +}: FloatingActionBarProps) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + const hasAnySelection = selectedUserCount > 0 || selectedSubscriptionCount > 0; + if (!hasAnySelection) return null; + + const actions: ActionConfig[] = [ + { + type: 'extend_subscription', + labelKey: 'admin.bulkActions.actions.extend', + icon: , + colorClass: 'text-success-400 hover:bg-success-500/10', + }, + { + type: 'activate_subscription', + labelKey: 'admin.bulkActions.actions.activate', + icon: , + colorClass: 'text-success-400 hover:bg-success-500/10', + }, + { + type: 'cancel_subscription', + labelKey: 'admin.bulkActions.actions.cancel', + icon: , + colorClass: 'text-error-400 hover:bg-error-500/10', + }, + { + type: 'change_tariff', + labelKey: 'admin.bulkActions.actions.changeTariff', + icon: , + colorClass: 'text-accent-400 hover:bg-accent-500/10', + }, + { + type: 'add_traffic', + labelKey: 'admin.bulkActions.actions.addTraffic', + icon: , + colorClass: 'text-accent-400 hover:bg-accent-500/10', + }, + { + type: 'set_devices', + labelKey: 'admin.bulkActions.actions.setDevices', + icon: ( + + + + ), + colorClass: 'text-accent-400 hover:bg-accent-500/10', + }, + { + type: 'delete_subscription', + labelKey: 'admin.bulkActions.actions.deleteSubscription', + icon: ( + + + + ), + colorClass: 'text-error-400 hover:bg-error-500/10', + }, + { + type: 'add_balance', + labelKey: 'admin.bulkActions.actions.addBalance', + icon: , + colorClass: 'text-warning-400 hover:bg-warning-500/10', + }, + { + type: 'grant_subscription', + labelKey: 'admin.bulkActions.actions.grantSubscription', + icon: , + colorClass: 'text-success-400 hover:bg-success-500/10', + }, + { + type: 'assign_promo_group', + labelKey: 'admin.bulkActions.actions.assignPromoGroup', + icon: , + colorClass: 'text-accent-300 hover:bg-accent-300/10', + }, + { + type: 'delete_user', + labelKey: 'admin.bulkActions.actions.deleteUser', + icon: ( + + + + ), + colorClass: 'text-error-400 hover:bg-error-500/10', + }, + ]; + + return ( +
+
+ {/* Selection counts */} +
+ {selectedUserCount > 0 && ( +
+
+ {selectedUserCount} +
+ + {t('admin.bulkActions.usersSelected', { count: selectedUserCount })} + +
+ )} + {isMultiTariff && selectedSubscriptionCount > 0 && ( +
+ {selectedUserCount > 0 &&
} +
+ {selectedSubscriptionCount} +
+ + {t('admin.bulkActions.subscriptionsSelected', { + count: selectedSubscriptionCount, + })} + +
+ )} +
+ + {/* Toggle all subscriptions button */} + {isMultiTariff && totalVisibleSubscriptionCount > 0 && ( + <> +
+ + + )} + +
+ + {/* Actions dropdown */} +
+ + + {open && ( +
+ {/* Subscription-level actions */} + {isMultiTariff && ( +
+ + {t('admin.bulkActions.subscriptionTarget')} + +
+ )} + {actions + .filter((a) => isSubscriptionLevelAction(a.type)) + .map((a) => { + const count = isMultiTariff ? selectedSubscriptionCount : selectedUserCount; + const disabled = count === 0; + return ( + + ); + })} + + {/* User-level actions */} + {isMultiTariff && ( +
+ + {t('admin.bulkActions.userTarget')} + +
+ )} + {actions + .filter((a) => !isSubscriptionLevelAction(a.type)) + .map((a) => { + const disabled = selectedUserCount === 0; + return ( + + ); + })} +
+ )} +
+
+
+ ); +} + +// ============ Helpers ============ + +// ============ Main Page ============ + +export default function AdminBulkActions() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { capabilities } = usePlatform(); + const { formatWithCurrency } = useCurrency(); + + // Data + const [users, setUsers] = useState([]); + const [total, setTotal] = useState(0); + const [tariffs, setTariffs] = useState([]); + const [promoGroups, setPromoGroups] = useState([]); + const [campaigns, setCampaigns] = useState([]); + const [partners, setPartners] = useState([]); + const [loading, setLoading] = useState(true); + + // Filters + const [searchInput, setSearchInput] = useState(''); + const [committedSearch, setCommittedSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [trialOnly, setTrialOnly] = useState(false); + const [tariffFilter, setTariffFilter] = useState([]); + const [promoGroupFilter, setPromoGroupFilter] = useState(''); + const [campaignFilter, setCampaignFilter] = useState(''); + const [partnerFilter, setPartnerFilter] = useState(''); + + // Pagination + const [offset, setOffset] = useState(0); + const [limit, setLimit] = useState(50); + + // Selection + const [rowSelection, setRowSelection] = useState({}); + const [subscriptionSelection, setSubscriptionSelection] = useState>({}); + const [expandedRows, setExpandedRows] = useState>({}); + + // Modal + const [modal, setModal] = useState({ + open: false, + action: null, + loading: false, + result: null, + progress: null, + }); + + // Debounce timer ref + const searchTimerRef = useRef>(undefined); + + // ---- Multi-tariff detection ---- + const isMultiTariff = useMemo( + () => users.some((u) => (u.subscriptions?.length ?? 0) > 1), + [users], + ); + + // ---- Data loading ---- + + const loadUsers = useCallback(async () => { + try { + setLoading(true); + const params: Record = { + offset, + limit, + }; + if (committedSearch) params.search = committedSearch; + if (statusFilter) params.subscription_status = statusFilter; + if (tariffFilter.length > 0) params.tariff_id = tariffFilter.join(','); + if (promoGroupFilter) params.promo_group_id = Number(promoGroupFilter); + if (campaignFilter) params.campaign_id = Number(campaignFilter); + if (partnerFilter) params.partner_id = Number(partnerFilter); + + const data = await adminUsersApi.getUsers( + params as Parameters[0], + ); + setUsers(data.users); + setTotal(data.total); + // Auto-expand all users who have subscriptions + const autoExpand: Record = {}; + for (const u of data.users) { + if ((u.subscriptions?.length ?? 0) > 0) { + autoExpand[u.id] = true; + } + } + setExpandedRows(autoExpand); + } catch { + // keep stale data + } finally { + setLoading(false); + } + }, [ + offset, + limit, + committedSearch, + statusFilter, + tariffFilter, + promoGroupFilter, + campaignFilter, + partnerFilter, + ]); + + useEffect(() => { + loadUsers(); + }, [loadUsers]); + + // Load tariffs, promo groups, campaigns, and partners once (independent — one failure won't block others) + useEffect(() => { + tariffsApi + .getTariffs(true) + .then((d) => setTariffs(d.tariffs)) + .catch(() => {}); + promocodesApi + .getPromoGroups({ limit: 200 }) + .then((d) => setPromoGroups(d.items)) + .catch(() => {}); + campaignsApi + .getCampaigns(true, 0, 100) + .then((d) => setCampaigns(d.campaigns)) + .catch(() => {}); + partnerApi + .getPartners({ limit: 100 }) + .then((d) => setPartners(d.items)) + .catch(() => {}); + }, []); + + // ---- Handlers ---- + + const clearAllSelections = useCallback(() => { + setRowSelection({}); + setSubscriptionSelection({}); + setExpandedRows({}); + }, []); + + const handleSearchChange = (value: string) => { + setSearchInput(value); + clearTimeout(searchTimerRef.current); + searchTimerRef.current = setTimeout(() => { + setOffset(0); + setCommittedSearch(value); + }, 400); + }; + + // Cleanup debounce on unmount + useEffect(() => { + return () => clearTimeout(searchTimerRef.current); + }, []); + + const handleSearchSubmit = (e: React.FormEvent) => { + e.preventDefault(); + clearTimeout(searchTimerRef.current); + setOffset(0); + setCommittedSearch(searchInput); + }; + + const handleStatusFilterChange = (v: string) => { + setStatusFilter(v as SubscriptionStatusFilter); + setOffset(0); + }; + + const handleTariffFilterChange = (ids: number[]) => { + setTariffFilter(ids); + setOffset(0); + }; + + const handlePromoGroupFilterChange = (v: string) => { + setPromoGroupFilter(v); + setOffset(0); + }; + + const handleCampaignFilterChange = (v: string) => { + setCampaignFilter(v); + setOffset(0); + }; + + const handlePartnerFilterChange = (v: string) => { + setPartnerFilter(v); + setOffset(0); + }; + + const handleRefresh = () => { + loadUsers(); + }; + + const selectedUserIds = useMemo(() => { + return Object.keys(rowSelection) + .filter((k) => rowSelection[k]) + .map(Number) + .filter((id) => id > 0); + }, [rowSelection]); + + const selectedSubscriptionIds = useMemo(() => { + return Object.keys(subscriptionSelection) + .filter((k) => subscriptionSelection[Number(k)]) + .map(Number); + }, [subscriptionSelection]); + + const toggleExpandRow = useCallback((userId: number) => { + setExpandedRows((prev) => ({ + ...prev, + [userId]: !prev[userId], + })); + }, []); + + const toggleSubscriptionSelection = useCallback((subscriptionId: number) => { + setSubscriptionSelection((prev) => ({ + ...prev, + [subscriptionId]: !prev[subscriptionId], + })); + }, []); + + const getFilteredSubs = useCallback( + (subs: UserListItemSubscription[]): UserListItemSubscription[] => { + if (tariffFilter.length === 0) return subs; + return subs.filter((s) => s.tariff_id !== null && tariffFilter.includes(s.tariff_id)); + }, + [tariffFilter], + ); + + const allVisibleSubscriptionIds = useMemo(() => { + const ids: number[] = []; + for (const user of users) { + const subs = user.subscriptions ?? []; + const filtered = getFilteredSubs(subs); + for (const sub of filtered) { + ids.push(sub.id); + } + } + return ids; + }, [users, getFilteredSubs]); + + const toggleAllSubscriptions = useCallback(() => { + const allSelected = + allVisibleSubscriptionIds.length > 0 && + allVisibleSubscriptionIds.every((id) => subscriptionSelection[id]); + + if (allSelected) { + // Deselect all + const next: Record = {}; + for (const key of Object.keys(subscriptionSelection)) { + const id = Number(key); + if (!allVisibleSubscriptionIds.includes(id)) { + next[id] = subscriptionSelection[id]; + } + } + setSubscriptionSelection(next); + } else { + // Select all visible + const next = { ...subscriptionSelection }; + for (const id of allVisibleSubscriptionIds) { + next[id] = true; + } + setSubscriptionSelection(next); + } + + // Auto-expand rows that have filtered subs + const expanded: Record = { ...expandedRows }; + for (const user of users) { + const subs = user.subscriptions ?? []; + const filtered = getFilteredSubs(subs); + if (filtered.length > 1) { + expanded[user.id] = true; + } + } + setExpandedRows(expanded); + }, [allVisibleSubscriptionIds, subscriptionSelection, expandedRows, users, getFilteredSubs]); + + const handleOpenAction = (type: BulkActionType) => { + setModal({ open: true, action: type, loading: false, result: null, progress: null }); + }; + + const handleExecuteAction = async (params: BulkActionParams) => { + if (!modal.action) return; + + const isSubAction = isMultiTariff && isSubscriptionLevelAction(modal.action); + const targetIds = isSubAction ? selectedSubscriptionIds : selectedUserIds; + if (targetIds.length === 0) return; + + const totalCount = targetIds.length; + setModal((prev) => ({ + ...prev, + loading: true, + progress: { + current: 0, + total: totalCount, + successCount: 0, + errorCount: 0, + log: [], + }, + })); + + const requestPayload = isSubAction + ? { action: modal.action, subscription_ids: targetIds, params } + : { action: modal.action, user_ids: targetIds, params }; + + try { + await adminBulkActionsApi.executeWithStream(requestPayload, (event) => { + if (event.type === 'progress') { + setModal((prev) => ({ + ...prev, + progress: prev.progress + ? { + current: event.current, + total: event.total, + successCount: prev.progress.successCount + (event.success ? 1 : 0), + errorCount: prev.progress.errorCount + (event.success ? 0 : 1), + log: [...prev.progress.log, event], + } + : prev.progress, + })); + } else if (event.type === 'complete') { + setModal((prev) => { + // Build errors from accumulated progress log + const errors = (prev.progress?.log ?? []) + .filter((e) => !e.success) + .map((e) => ({ + user_id: e.user_id, + username: e.username, + error: e.message || e.error || '', + })); + return { + ...prev, + loading: false, + progress: null, + result: { + success: event.error_count === 0, + total: event.total, + success_count: event.success_count, + error_count: event.error_count, + skipped_count: event.skipped_count || 0, + errors, + }, + }; + }); + loadUsers(); + } + }); + + // If stream ended without a complete event, finalize from progress + setModal((prev) => { + if (prev.loading && prev.progress) { + return { + ...prev, + loading: false, + result: { + success: prev.progress.errorCount === 0, + total: prev.progress.total, + success_count: prev.progress.successCount, + error_count: prev.progress.errorCount, + skipped_count: 0, + errors: prev.progress.log + .filter((e) => !e.success) + .map((e) => ({ + user_id: e.user_id, + username: e.username, + error: e.error || '', + })), + }, + progress: null, + }; + } + return prev; + }); + + loadUsers(); + } catch { + setModal((prev) => ({ + ...prev, + loading: false, + progress: null, + result: { + success: false, + total: totalCount, + success_count: prev.progress?.successCount ?? 0, + error_count: totalCount - (prev.progress?.successCount ?? 0), + skipped_count: 0, + errors: [], + }, + })); + } + }; + + const handleCloseModal = () => { + if (modal.loading) return; + if (modal.result) { + clearAllSelections(); + } + setModal({ open: false, action: null, loading: false, result: null, progress: null }); + }; + + // ---- TanStack Table ---- + + const columns = useMemo[]>( + () => [ + { + id: 'select', + size: 56, + header: ({ table }) => { + const allSubsSelected = + allVisibleSubscriptionIds.length > 0 && + allVisibleSubscriptionIds.every((id) => subscriptionSelection[id]); + const someSubsSelected = + !allSubsSelected && allVisibleSubscriptionIds.some((id) => subscriptionSelection[id]); + return ( +
+ {/* Select all users */} + + {/* Select all subscriptions */} + {isMultiTariff && allVisibleSubscriptionIds.length > 0 && ( + + )} +
+ ); + }, + cell: ({ row }) => { + const userName = + row.original.full_name || row.original.username || String(row.original.telegram_id); + return ( +
+ +
+ ); + }, + enableSorting: false, + }, + { + id: 'user', + accessorFn: (row) => row.full_name, + header: t('admin.bulkActions.columns.user'), + size: 200, + cell: ({ row }) => { + const user = row.original; + const filteredSubs = getFilteredSubs(user.subscriptions ?? []); + const subCount = filteredSubs.length; + const canExpand = isMultiTariff && subCount > 0; + const isExpanded = expandedRows[user.id] ?? false; + return ( +
+ {canExpand ? ( + + ) : ( +
+ {user.first_name?.[0] || user.username?.[0] || '?'} +
+ )} +
+
+ + {user.full_name} + + {canExpand && ( + + {subCount} + + )} +
+
+ {user.username ? `@${user.username}` : `ID: ${user.telegram_id}`} +
+
+
+ ); + }, + }, + { + id: 'subscription_status', + accessorFn: (row) => row.subscription_status, + header: t('admin.bulkActions.columns.status'), + size: 100, + cell: ({ row }) => { + const user = row.original; + if (!user.has_subscription) { + return -; + } + return ; + }, + }, + { + id: 'tariff', + accessorFn: (row) => row.tariff_name, + header: t('admin.bulkActions.columns.tariff'), + size: 160, + cell: ({ row }) => { + const user = row.original; + const subs = user.subscriptions ?? []; + const tariffNames = subs + .map((s) => s.tariff_name) + .filter((name): name is string => !!name); + const uniqueNames = [...new Set(tariffNames)]; + + if (uniqueNames.length === 0) { + if (!user.tariff_name) { + return ; + } + return {user.tariff_name}; + } + + return ( +
+ {uniqueNames.map((name) => ( + + {name} + + ))} +
+ ); + }, + }, + { + id: 'balance', + accessorKey: 'balance_rubles', + header: t('admin.bulkActions.columns.balance'), + size: 100, + cell: ({ getValue }) => ( + + {formatWithCurrency(getValue() as number)} + + ), + }, + { + id: 'promo_group', + accessorFn: (row) => row.promo_group_name, + header: t('admin.bulkActions.columns.promoGroup'), + size: 120, + cell: ({ getValue }) => { + const name = getValue() as string | null; + return name ? ( + + {name} + + ) : ( + - + ); + }, + }, + { + id: 'total_spent', + accessorKey: 'total_spent_kopeks', + header: t('admin.bulkActions.columns.spent'), + size: 100, + cell: ({ getValue }) => { + const kopeks = getValue() as number; + return ( + + {kopeks > 0 ? formatWithCurrency(kopeks / 100) : '-'} + + ); + }, + }, + ], + [t, formatWithCurrency, expandedRows, toggleExpandRow, getFilteredSubs], + ); + + // When multiple tariffs are selected, filter users client-side + // (server only supports single tariff_id filter) + const filteredUsers = useMemo(() => { + let result = users; + + // Trial-only filter: show users with trial subscription + if (trialOnly) { + result = result.filter( + (u) => u.subscription_is_trial || (u.subscriptions ?? []).some((s) => s.status === 'trial'), + ); + } + + return result; + }, [users, trialOnly]); + + const table = useReactTable({ + data: filteredUsers, + columns, + state: { rowSelection }, + onRowSelectionChange: setRowSelection, + getCoreRowModel: getCoreRowModel(), + enableRowSelection: true, + getRowId: (row) => String(row.id), + }); + + const totalPages = Math.ceil(total / limit); + const currentPage = Math.floor(offset / limit) + 1; + + // ---- Status filter options ---- + const statusOptions: DropdownOption[] = [ + { value: '', label: t('admin.bulkActions.filters.allStatuses') }, + { value: 'active', label: t('admin.bulkActions.statuses.active') }, + { value: 'expired', label: t('admin.bulkActions.statuses.expired') }, + { value: 'limited', label: t('admin.bulkActions.statuses.limited') }, + { value: 'disabled', label: t('admin.bulkActions.statuses.disabled') }, + ]; + + const tariffMultiOptions: MultiSelectOption[] = tariffs.map((tt) => ({ + value: tt.id, + label: tt.name, + })); + + const promoGroupOptions: DropdownOption[] = [ + { value: '', label: t('admin.bulkActions.filters.allGroups') }, + ...promoGroups.map((pg) => ({ value: String(pg.id), label: pg.name })), + ]; + + const campaignOptions: DropdownOption[] = [ + { value: '', label: t('admin.bulkActions.filters.allCampaigns') }, + ...campaigns.map((c) => ({ value: String(c.id), label: c.name })), + ]; + + const partnerOptions: DropdownOption[] = [ + { value: '', label: t('admin.bulkActions.filters.allPartners') }, + ...partners.map((p) => ({ + value: String(p.user_id), + label: p.username ? `@${p.username}` : p.first_name || String(p.telegram_id), + })), + ]; + + return ( +
+ + + {/* Header */} +
+
+ {!capabilities.hasBackButton && ( + + )} +
+
+

{t('admin.bulkActions.title')}

+ + {total.toLocaleString()} + +
+

{t('admin.bulkActions.subtitle')}

+
+
+ +
+ + {/* Filters */} +
+ {/* Search */} +
+
+ handleSearchChange(e.target.value)} + placeholder={t('admin.bulkActions.filters.search')} + className="w-full rounded-xl border border-dark-700 bg-dark-800 py-2.5 pl-10 pr-4 text-sm text-dark-100 outline-none transition-colors placeholder:text-dark-500 focus:border-accent-500/40 focus:shadow-[0_0_0_3px_rgba(var(--color-accent-500),0.08)]" + /> +
+ +
+
+
+ + {/* Filter dropdowns + trial toggle */} +
+ + + +
+ + {/* Campaign & Partner filters */} +
+ + +
+ + {/* Trial filter checkbox */} + +
+ + {/* Table */} + {loading && users.length === 0 ? ( +
+
+
+ ) : users.length === 0 ? ( +
+
+ +
+

{t('admin.bulkActions.noResults')}

+
+ ) : ( +
0 && 'opacity-60', + )} + > +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => { + const user = row.original; + const filteredSubs = getFilteredSubs(user.subscriptions ?? []); + const canExpand = isMultiTariff && filteredSubs.length > 0; + const isExpanded = expandedRows[user.id] ?? false; + + return ( + + {/* User row */} + + {row.getVisibleCells().map((cell) => ( + + ))} + + + {/* Subscription sub-rows (only when expanded and multi-sub) */} + {canExpand && + isExpanded && + filteredSubs.map((sub) => ( + toggleSubscriptionSelection(sub.id)} + isMultiTariff={isMultiTariff} + /> + ))} + + {/* Single subscription — auto-select with user (no separate sub-row) */} + + ); + })} + +
+ {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+
+ )} + + {/* Pagination + per-page selector */} +
+
+ + {offset + 1}–{Math.min(offset + limit, total)} / {total} + +
+ {t('admin.bulkActions.perPage')} + +
+
+ {totalPages > 1 && ( +
+ + + {currentPage} / {totalPages} + + +
+ )} +
+ + {/* Bottom spacer to prevent floating bar from covering pagination */} + {(selectedUserIds.length > 0 || selectedSubscriptionIds.length > 0) && ( +
+ )} + + {/* Floating action bar — portal to body for correct fixed positioning */} + {createPortal( + , + document.body, + )} + + {/* Action modal */} + +
+ ); +} diff --git a/src/pages/AdminInfoPageEditor.tsx b/src/pages/AdminInfoPageEditor.tsx new file mode 100644 index 0000000..3b885de --- /dev/null +++ b/src/pages/AdminInfoPageEditor.tsx @@ -0,0 +1,1458 @@ +import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; +import { useNavigate, useParams, useSearchParams } from 'react-router'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { useEditor, EditorContent } from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import ImageExtension from '@tiptap/extension-image'; +import LinkExtension from '@tiptap/extension-link'; +import PlaceholderExtension from '@tiptap/extension-placeholder'; +import TextAlignExtension from '@tiptap/extension-text-align'; +import UnderlineExtension from '@tiptap/extension-underline'; +import HighlightExtension from '@tiptap/extension-highlight'; +import { VideoExtension } from '../lib/tiptap-video'; +import { infoPagesApi } from '../api/infoPages'; +import { newsApi } from '../api/news'; +import { AdminBackButton } from '../components/admin'; +import { Toggle } from '../components/admin/Toggle'; +import { useHapticFeedback } from '../platform/hooks/useHaptic'; +import { cn } from '../lib/utils'; +import type { InfoPageType, FaqItem, ReplacesTab } from '../api/infoPages'; + +const AVAILABLE_LOCALES = ['ru', 'en', 'zh', 'fa'] as const; +type LocaleCode = (typeof AVAILABLE_LOCALES)[number]; + +// --- Icons --- +const BoldIcon = () => ( + + + +); + +const ItalicIcon = () => ( + + + +); + +const UnderlineIcon = () => ( + + + +); + +const StrikeIcon = () => ( + + + +); + +const H1Icon = () => H1; +const H2Icon = () => H2; +const H3Icon = () => H3; + +const ListBulletIcon = () => ( + + + +); + +const ListOrderedIcon = () => ( + + + +); + +const QuoteIcon = () => ( + + + +); + +const CodeBlockIcon = () => ( + + + +); + +const ImageIcon = () => ( + + + +); + +const LinkIcon = () => ( + + + +); + +const AlignLeftIcon = () => ( + + + +); + +const AlignCenterIcon = () => ( + + + +); + +const HighlightIcon = () => ( + + + +); + +// --- Toolbar Button --- +interface ToolbarButtonProps { + onClick: () => void; + isActive?: boolean; + disabled?: boolean; + title: string; + children: React.ReactNode; +} + +function ToolbarButton({ onClick, isActive, disabled, title, children }: ToolbarButtonProps) { + return ( + + ); +} + +// --- Security: URL scheme validation --- +function isSafeUrl(url: string | null | undefined): boolean { + if (!url) return false; + try { + const parsed = new URL(url); + return parsed.protocol === 'https:' || parsed.protocol === 'http:'; + } catch { + return false; + } +} + +// --- Slug utility --- +const TRANSLIT_MAP: Record = { + а: 'a', + б: 'b', + в: 'v', + г: 'g', + д: 'd', + е: 'e', + ё: 'yo', + ж: 'zh', + з: 'z', + и: 'i', + й: 'y', + к: 'k', + л: 'l', + м: 'm', + н: 'n', + о: 'o', + п: 'p', + р: 'r', + с: 's', + т: 't', + у: 'u', + ф: 'f', + х: 'kh', + ц: 'ts', + ч: 'ch', + ш: 'sh', + щ: 'shch', + ъ: '', + ы: 'y', + ь: '', + э: 'e', + ю: 'yu', + я: 'ya', +}; + +function generateSlug(title: string): string { + const lower = title.toLowerCase(); + const transliterated = Array.from(lower) + .map((ch) => TRANSLIT_MAP[ch] ?? ch) + .join(''); + return transliterated + .replace(/[^a-z0-9\s-]/g, '') + .replace(/[\s_]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') + .substring(0, 100); +} + +// --- FAQ Q&A Item Icons --- +const ChevronUpIcon = () => ( + + + +); + +const ChevronDownIcon = () => ( + + + +); + +const TrashSmallIcon = () => ( + + + +); + +const PlusSmallIcon = () => ( + + + +); + +// --- FAQ Answer Rich Editor --- + +const FAQ_EDITOR_EXTENSIONS = [ + StarterKit.configure({ + heading: { levels: [2, 3] }, + link: false, + underline: false, + }), + UnderlineExtension, + LinkExtension.configure({ openOnClick: false, HTMLAttributes: { class: 'link' } }), + ImageExtension.configure({ HTMLAttributes: { class: 'rounded-xl max-w-full' } }), + PlaceholderExtension.configure({ placeholder: '' }), + TextAlignExtension.configure({ types: ['heading', 'paragraph'] }), + HighlightExtension, + VideoExtension, +]; + +function FaqAnswerEditor({ value, onChange }: { value: string; onChange: (html: string) => void }) { + const { t } = useTranslation(); + const haptic = useHapticFeedback(); + const mediaRef = useRef(null); + const [uploadCount, setUploadCount] = useState(0); + const isUploading = uploadCount > 0; + const [isDragging, setIsDragging] = useState(false); + const activeUploadsRef = useRef(new Set()); + + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + + const editor = useEditor({ + extensions: FAQ_EDITOR_EXTENSIONS, + content: value, + editorProps: { + attributes: { + class: 'prose prose-sm max-w-none min-h-[120px] p-3 focus:outline-none', + }, + }, + onUpdate: ({ editor: e }) => { + const html = e.getHTML(); + const isEmpty = html === '

' || html === ''; + onChangeRef.current(isEmpty ? '' : html); + }, + }); + + // Sync external value changes (e.g., reorder, locale switch) + useEffect(() => { + if (!editor || editor.isDestroyed) return; + const currentHtml = editor.getHTML(); + const normalizedCurrent = currentHtml === '

' ? '' : currentHtml; + if (normalizedCurrent !== value) { + editor.commands.setContent(value || '', { emitUpdate: false }); + } + }, [value, editor]); + + // Cleanup uploads on unmount + useEffect(() => { + const uploads = activeUploadsRef.current; + return () => { + for (const c of uploads) c.abort(); + uploads.clear(); + }; + }, []); + + const handleMediaUpload = useCallback( + async (file: File) => { + if (!editor) return; + const isImage = file.type.startsWith('image/'); + const isVideo = file.type.startsWith('video/'); + if (!isImage && !isVideo) return; + const maxSize = isImage ? 10 * 1024 * 1024 : 50 * 1024 * 1024; + if (file.size > maxSize) { + haptic.error(); + return; + } + const controller = new AbortController(); + activeUploadsRef.current.add(controller); + setUploadCount((c) => c + 1); + try { + const result = await newsApi.uploadMedia(file, controller.signal); + if (controller.signal.aborted) return; + if (!isSafeUrl(result.url)) return; + if (result.media_type === 'image') { + editor.chain().focus().setImage({ src: result.url, alt: file.name }).run(); + } else { + editor + .chain() + .focus() + .insertContent({ + type: 'video', + attrs: { src: result.url, class: 'w-full rounded-xl max-h-96' }, + }) + .run(); + } + haptic.success(); + } catch { + if (!controller.signal.aborted) haptic.error(); + } finally { + activeUploadsRef.current.delete(controller); + setUploadCount((c) => c - 1); + } + }, + [editor, haptic], + ); + + const handleMediaUploadRef = useRef(handleMediaUpload); + useEffect(() => { + handleMediaUploadRef.current = handleMediaUpload; + }, [handleMediaUpload]); + + // Register paste/drop handlers after editor is created + useEffect(() => { + if (!editor) return; + const handlePaste = (_view: unknown, event: ClipboardEvent) => { + const items = event.clipboardData?.items; + if (!items) return false; + for (const item of Array.from(items)) { + if (item.type.startsWith('image/') || item.type.startsWith('video/')) { + const file = item.getAsFile(); + if (file) { + handleMediaUploadRef.current(file); + return true; + } + } + } + return false; + }; + const handleDrop = (_view: unknown, event: DragEvent) => { + const file = event.dataTransfer?.files[0]; + if (file && (file.type.startsWith('image/') || file.type.startsWith('video/'))) { + event.preventDefault(); + handleMediaUploadRef.current(file); + return true; + } + return false; + }; + editor.setOptions({ editorProps: { ...editor.options.editorProps, handlePaste, handleDrop } }); + }, [editor]); + + const addLink = useCallback(() => { + const url = window.prompt(t('news.admin.toolbar.linkUrlPrompt')); + if (url && editor) { + try { + const parsed = new URL(url); + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return; + } catch { + return; + } + editor.chain().focus().setLink({ href: url }).run(); + } + }, [editor, t]); + + if (!editor) return null; + + return ( +
+ {/* Upload overlay */} + {isUploading && ( +
+
+ {t('news.admin.uploading')} +
+ )} + {isDragging && !isUploading && ( +
+ {t('news.admin.dropMedia')} +
+ )} + + {/* Compact toolbar */} +
+ editor.chain().focus().toggleBold().run()} + isActive={editor.isActive('bold')} + title={t('news.admin.toolbar.bold')} + > + + + editor.chain().focus().toggleItalic().run()} + isActive={editor.isActive('italic')} + title={t('news.admin.toolbar.italic')} + > + + + editor.chain().focus().toggleUnderline().run()} + isActive={editor.isActive('underline')} + title={t('news.admin.toolbar.underline')} + > + + + editor.chain().focus().toggleStrike().run()} + isActive={editor.isActive('strike')} + title={t('news.admin.toolbar.strikethrough')} + > + + +
+ editor.chain().focus().toggleHeading({ level: 2 }).run()} + isActive={editor.isActive('heading', { level: 2 })} + title={t('news.admin.toolbar.heading2')} + > + + + editor.chain().focus().toggleBulletList().run()} + isActive={editor.isActive('bulletList')} + title={t('news.admin.toolbar.bulletList')} + > + + + editor.chain().focus().toggleOrderedList().run()} + isActive={editor.isActive('orderedList')} + title={t('news.admin.toolbar.orderedList')} + > + + + editor.chain().focus().toggleBlockquote().run()} + isActive={editor.isActive('blockquote')} + title={t('news.admin.toolbar.blockquote')} + > + + +
+ editor.chain().focus().toggleHighlight().run()} + isActive={editor.isActive('highlight')} + title={t('news.admin.toolbar.highlight')} + > + + + + + + mediaRef.current?.click()} + disabled={isUploading} + title={t('news.admin.toolbar.image')} + > + {isUploading ? ( +
+ ) : ( + + )} + +
+ + {/* Editor */} +
{ + e.preventDefault(); + setIsDragging(true); + }} + onDragLeave={(e) => { + e.preventDefault(); + setIsDragging(false); + }} + onDrop={(e) => { + setIsDragging(false); + if (e.defaultPrevented) return; + const file = e.dataTransfer.files[0]; + if (file && (file.type.startsWith('image/') || file.type.startsWith('video/'))) { + e.preventDefault(); + handleMediaUpload(file); + } + }} + > + +
+ + {/* Hidden file input */} + { + const file = e.target.files?.[0]; + if (file) handleMediaUpload(file); + e.target.value = ''; + }} + className="hidden" + aria-hidden="true" + /> +
+ ); +} + +// --- FAQ Q&A Builder --- +interface FaqBuilderProps { + items: FaqItem[]; + onChange: (items: FaqItem[]) => void; + locale: string; + localeLabel: string; +} + +function FaqBuilder({ items, onChange, locale, localeLabel }: FaqBuilderProps) { + const { t } = useTranslation(); + + // Stable keys for React — travel with items through reorder/delete + const keyCounter = useRef(0); + const [itemKeys, setItemKeys] = useState(() => items.map(() => keyCounter.current++)); + + // Regenerate all keys on locale switch (items are semantically different) + useEffect(() => { + setItemKeys(items.map(() => keyCounter.current++)); + }, [locale]); // eslint-disable-line react-hooks/exhaustive-deps + + // Sync key count when items added/removed within the same locale + useEffect(() => { + setItemKeys((prev) => { + if (prev.length === items.length) return prev; + if (items.length > prev.length) { + const newKeys = [...prev]; + for (let i = prev.length; i < items.length; i++) { + newKeys.push(keyCounter.current++); + } + return newKeys; + } + return prev.slice(0, items.length); + }); + }, [items.length]); + + const handleQuestionChange = useCallback( + (index: number, value: string) => { + const updated = items.map((item, i) => (i === index ? { ...item, q: value } : item)); + onChange(updated); + }, + [items, onChange], + ); + + const handleAnswerChange = useCallback( + (index: number, value: string) => { + const updated = items.map((item, i) => (i === index ? { ...item, a: value } : item)); + onChange(updated); + }, + [items, onChange], + ); + + const handleRemove = useCallback( + (index: number) => { + setItemKeys((prev) => prev.filter((_, i) => i !== index)); + onChange(items.filter((_, i) => i !== index)); + }, + [items, onChange], + ); + + const handleAdd = useCallback(() => { + const newKey = keyCounter.current++; + setItemKeys((prev) => [...prev, newKey]); + onChange([...items, { q: '', a: '' }]); + }, [items, onChange]); + + const handleMoveUp = useCallback( + (index: number) => { + if (index === 0) return; + const updated = [...items]; + [updated[index - 1], updated[index]] = [updated[index], updated[index - 1]]; + setItemKeys((prev) => { + const k = [...prev]; + [k[index - 1], k[index]] = [k[index], k[index - 1]]; + return k; + }); + onChange(updated); + }, + [items, onChange], + ); + + const handleMoveDown = useCallback( + (index: number) => { + if (index >= items.length - 1) return; + const updated = [...items]; + [updated[index], updated[index + 1]] = [updated[index + 1], updated[index]]; + setItemKeys((prev) => { + const k = [...prev]; + [k[index], k[index + 1]] = [k[index + 1], k[index]]; + return k; + }); + onChange(updated); + }, + [items, onChange], + ); + + return ( +
+
+ + + {items.length} {t('admin.infoPages.faq.questionsCount')} + +
+ + {items.length === 0 && ( +
+ {t('admin.infoPages.faq.noQuestions')} +
+ )} + + {items.map((item, index) => ( +
+
+ + {t('admin.infoPages.faq.questionNumber', { n: index + 1 })} + +
+ + + +
+
+ +
+
+ + handleQuestionChange(index, e.target.value)} + className="input text-sm" + placeholder={t('admin.infoPages.faq.questionPlaceholder')} + /> +
+
+ + handleAnswerChange(index, html)} + /> +
+
+
+ ))} + + +
+ ); +} + +export default function AdminInfoPageEditor() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { id: rawId } = useParams<{ id: string }>(); + const [searchParams] = useSearchParams(); + const queryClient = useQueryClient(); + const haptic = useHapticFeedback(); + const pageId = rawId != null ? Number(rawId) : undefined; + const isEdit = pageId != null && !Number.isNaN(pageId); + const initialPageType = (searchParams.get('type') === 'faq' ? 'faq' : 'page') as InfoPageType; + + // Form state + const [slug, setSlug] = useState(''); + const [slugManuallyEdited, setSlugManuallyEdited] = useState(false); + const [icon, setIcon] = useState(''); + const [isActive, setIsActive] = useState(true); + const [sortOrder, setSortOrder] = useState(0); + const [pageType, setPageType] = useState(initialPageType); + const [replacesTab, setReplacesTab] = useState(null); + const [saveError, setSaveError] = useState(null); + + // FAQ Q&A state per locale + const [faqItems, setFaqItems] = useState>({}); + + // Multi-locale state + const [activeLocale, setActiveLocale] = useState('ru'); + const [titles, setTitles] = useState>({}); + const [contents, setContents] = useState>({}); + + // Media upload state + const mediaInputRef = useRef(null); + const [uploadCount, setUploadCount] = useState(0); + const isUploading = uploadCount > 0; + const [isDragging, setIsDragging] = useState(false); + const activeUploadsRef = useRef(new Set()); + + const handleMediaUploadRef = useRef<(file: File) => void>(() => {}); + + // TipTap editor + const extensions = useMemo( + () => [ + StarterKit.configure({ + heading: { levels: [1, 2, 3] }, + link: false, + underline: false, + }), + UnderlineExtension, + LinkExtension.configure({ + openOnClick: false, + HTMLAttributes: { class: 'link' }, + }), + ImageExtension.configure({ + HTMLAttributes: { class: 'rounded-xl max-w-full' }, + }), + PlaceholderExtension.configure({ + placeholder: t('admin.infoPages.fields.content'), + }), + TextAlignExtension.configure({ + types: ['heading', 'paragraph'], + }), + HighlightExtension, + VideoExtension, + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + const editor = useEditor({ + extensions, + editorProps: { + attributes: { + class: 'prose max-w-none min-h-[300px] p-4 focus:outline-none', + }, + handlePaste: (_view, event) => { + const items = event.clipboardData?.items; + if (!items) return false; + + for (const item of Array.from(items)) { + if (item.type.startsWith('image/') || item.type.startsWith('video/')) { + const file = item.getAsFile(); + if (file) { + handleMediaUploadRef.current(file); + return true; + } + } + } + return false; + }, + handleDrop: (_view, event) => { + const file = event.dataTransfer?.files[0]; + if (file && (file.type.startsWith('image/') || file.type.startsWith('video/'))) { + event.preventDefault(); + handleMediaUploadRef.current(file); + return true; + } + return false; + }, + }, + }); + + // --- Media upload handlers --- + + const handleMediaUpload = useCallback( + async (file: File) => { + if (!editor) return; + + const isImage = file.type.startsWith('image/'); + const isVideo = file.type.startsWith('video/'); + if (!isImage && !isVideo) return; + + const maxSize = isImage ? 10 * 1024 * 1024 : 50 * 1024 * 1024; + if (file.size > maxSize) { + haptic.error(); + setSaveError(t('news.admin.fileTooLarge')); + return; + } + + const controller = new AbortController(); + activeUploadsRef.current.add(controller); + setUploadCount((c) => c + 1); + + try { + const result = await newsApi.uploadMedia(file, controller.signal); + if (controller.signal.aborted) return; + + if (!isSafeUrl(result.url)) { + haptic.error(); + setSaveError(t('news.admin.uploadError')); + return; + } + + if (result.media_type === 'image') { + editor.chain().focus().setImage({ src: result.url, alt: file.name }).run(); + } else { + editor + .chain() + .focus() + .insertContent({ + type: 'video', + attrs: { src: result.url, class: 'w-full rounded-xl max-h-96' }, + }) + .run(); + } + haptic.success(); + } catch { + if (controller.signal.aborted) return; + haptic.error(); + setSaveError(t('news.admin.uploadError')); + } finally { + activeUploadsRef.current.delete(controller); + setUploadCount((c) => c - 1); + } + }, + [editor, haptic, t], + ); + + useEffect(() => { + handleMediaUploadRef.current = handleMediaUpload; + }, [handleMediaUpload]); + + useEffect(() => { + const uploads = activeUploadsRef.current; + return () => { + for (const controller of uploads) { + controller.abort(); + } + uploads.clear(); + }; + }, []); + + const handleFileInputChange = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) handleMediaUpload(file); + e.target.value = ''; + }, + [handleMediaUpload], + ); + + const handleEditorDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }, []); + + const handleEditorDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }, []); + + const handleEditorDrop = useCallback( + (e: React.DragEvent) => { + setIsDragging(false); + if (e.defaultPrevented) return; + e.preventDefault(); + const file = e.dataTransfer.files[0]; + if (file) handleMediaUpload(file); + }, + [handleMediaUpload], + ); + + // Fetch all admin pages to detect tab conflicts + const { data: allAdminPages } = useQuery({ + queryKey: ['admin', 'info-pages', 'list', 'all'], + queryFn: () => infoPagesApi.getAdminPages(), + staleTime: 30_000, + }); + + // Fetch page for editing + const { data: pageData, isLoading: isLoadingPage } = useQuery({ + queryKey: ['admin', 'info-pages', 'page', pageId], + queryFn: () => { + if (pageId == null) throw new Error('Missing page id parameter'); + return infoPagesApi.getAdminPage(pageId); + }, + enabled: isEdit, + staleTime: 0, + gcTime: 0, + refetchOnMount: true, + refetchOnWindowFocus: false, + }); + + // Populate form when page data loads (once only -- not on locale switch) + const editorPopulated = useRef(false); + const formPopulated = useRef(false); + useEffect(() => { + if (!pageData || formPopulated.current) return; + setSlug(pageData.slug); + setSlugManuallyEdited(true); + setIcon(pageData.icon ?? ''); + setIsActive(pageData.is_active); + setSortOrder(pageData.sort_order); + setPageType(pageData.page_type ?? 'page'); + setReplacesTab(pageData.replaces_tab ?? null); + setTitles(pageData.title); + + if (pageData.page_type === 'faq') { + // For FAQ pages, content stores Q&A arrays per locale + const parsed: Record = {}; + for (const [loc, val] of Object.entries(pageData.content)) { + try { + const arr = typeof val === 'string' ? JSON.parse(val) : val; + parsed[loc] = Array.isArray(arr) ? arr : []; + } catch { + parsed[loc] = []; + } + } + setFaqItems(parsed); + setContents({}); + } else { + setContents(pageData.content); + } + + formPopulated.current = true; + }, [pageData]); + + // Set editor content once when editor is ready + useEffect(() => { + if (!pageData || !editor || editorPopulated.current) return; + const initialContent = pageData.content[activeLocale] ?? pageData.content['ru'] ?? ''; + editor.commands.setContent(initialContent); + editorPopulated.current = true; + }, [pageData, editor]); // activeLocale intentionally omitted + + // Auto-generate slug from Russian title + useEffect(() => { + if (!slugManuallyEdited && titles['ru']) { + setSlug(generateSlug(titles['ru'])); + } + }, [titles, slugManuallyEdited]); + + // --- Locale switching --- + const switchLocale = useCallback( + (newLocale: LocaleCode) => { + if (!editor) return; + // Save current editor content + const currentHtml = editor.getHTML(); + const isEmpty = currentHtml === '

' || currentHtml === ''; + setContents((prev) => ({ + ...prev, + [activeLocale]: isEmpty ? '' : currentHtml, + })); + // Load new locale content + const newContent = contents[newLocale] ?? ''; + editor.commands.setContent(newContent); + setActiveLocale(newLocale); + }, + [editor, activeLocale, contents], + ); + + // Save mutation + const saveMutation = useMutation({ + mutationFn: (data: { + slug: string; + title: Record; + content: Record; + page_type: InfoPageType; + is_active: boolean; + sort_order: number; + icon: string | null; + replaces_tab: ReplacesTab | null; + }) => { + if (isEdit && pageId != null) { + return infoPagesApi.updatePage(pageId, data); + } + return infoPagesApi.createPage(data); + }, + onSuccess: () => { + haptic.success(); + queryClient.invalidateQueries({ queryKey: ['admin', 'info-pages'] }); + queryClient.invalidateQueries({ queryKey: ['info-pages'] }); + navigate('/admin/info-pages'); + }, + onError: (error: Error) => { + haptic.error(); + setSaveError(error.message || t('admin.infoPages.saveError')); + }, + }); + + const handleSave = () => { + setSaveError(null); + if (!slug.trim()) return; + + let finalContents: Record; + + if (pageType === 'faq') { + // Serialize FAQ items as JSON strings per locale + finalContents = {}; + for (const [loc, items] of Object.entries(faqItems)) { + finalContents[loc] = JSON.stringify(items); + } + } else { + // Capture current editor content for the active locale + const currentHtml = editor?.getHTML() ?? ''; + const isEmpty = currentHtml === '

' || currentHtml === ''; + finalContents = { + ...contents, + [activeLocale]: isEmpty ? '' : currentHtml, + }; + } + + const data = { + slug: slug.trim(), + title: titles, + content: finalContents, + page_type: pageType, + is_active: isActive, + sort_order: sortOrder, + icon: icon.trim() || null, + replaces_tab: replacesTab, + }; + + haptic.buttonPress(); + saveMutation.mutate(data); + }; + + // Toolbar actions + const addLink = () => { + const url = window.prompt(t('news.admin.toolbar.linkUrlPrompt')); + if (url && editor) { + try { + const parsed = new URL(url); + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return; + } catch { + return; + } + editor.chain().focus().setLink({ href: url }).run(); + } + }; + + if (isEdit && isLoadingPage) { + return ( +
+
+
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +

+ {isEdit ? t('admin.infoPages.edit') : t('admin.infoPages.create')} +

+
+ +
+ + {/* Form */} +
+ {/* Slug */} +
+ + { + setSlug(e.target.value); + setSlugManuallyEdited(true); + }} + className="input font-mono text-sm" + required + /> +
+ + {/* Icon + Sort Order row */} +
+
+ + setIcon(e.target.value)} + className="input" + placeholder="📄" + /> +
+
+ + setSortOrder(Number(e.target.value) || 0)} + min={0} + className="input max-w-xs" + /> +
+
+ + {/* Active toggle */} +
+ setIsActive((v) => !v)} + aria-label={t('admin.infoPages.fields.isActive')} + /> + {t('admin.infoPages.fields.isActive')} +
+ + {/* Page type selector */} +
+ +
+ {(['page', 'faq'] as const).map((pt) => ( + + ))} +
+
+ + {/* Replaces tab selector */} +
+ + + {replacesTab && + allAdminPages?.some((p) => p.replaces_tab === replacesTab && p.id !== pageId) && ( +

+ {t('admin.infoPages.replacesTabWarning')} +

+ )} +
+ + {/* Locale tabs */} +
+ +
+ {AVAILABLE_LOCALES.map((loc) => ( + + ))} +
+
+ + {/* Title for current locale */} +
+ + setTitles((prev) => ({ ...prev, [activeLocale]: e.target.value }))} + className="input" + /> +
+ + {/* Content: TipTap editor for pages, FAQ builder for FAQ */} + {pageType === 'faq' ? ( + setFaqItems((prev) => ({ ...prev, [activeLocale]: items }))} + locale={activeLocale} + localeLabel={t(`admin.infoPages.locales.${activeLocale}`)} + /> + ) : ( +
+ +
+ {/* Upload progress overlay */} + {isUploading && ( +
+
+
+ + {t('news.admin.uploading')} + +
+
+ )} + + {/* Drag overlay */} + {isDragging && !isUploading && ( +
+ + {t('news.admin.dropMedia')} + +
+ )} + + {/* Toolbar */} + {editor && ( +
+ editor.chain().focus().toggleBold().run()} + isActive={editor.isActive('bold')} + title={t('news.admin.toolbar.bold')} + > + + + editor.chain().focus().toggleItalic().run()} + isActive={editor.isActive('italic')} + title={t('news.admin.toolbar.italic')} + > + + + editor.chain().focus().toggleUnderline().run()} + isActive={editor.isActive('underline')} + title={t('news.admin.toolbar.underline')} + > + + + editor.chain().focus().toggleStrike().run()} + isActive={editor.isActive('strike')} + title={t('news.admin.toolbar.strikethrough')} + > + + + +
+ + editor.chain().focus().toggleHeading({ level: 1 }).run()} + isActive={editor.isActive('heading', { level: 1 })} + title={t('news.admin.toolbar.heading1')} + > + + + editor.chain().focus().toggleHeading({ level: 2 }).run()} + isActive={editor.isActive('heading', { level: 2 })} + title={t('news.admin.toolbar.heading2')} + > + + + editor.chain().focus().toggleHeading({ level: 3 }).run()} + isActive={editor.isActive('heading', { level: 3 })} + title={t('news.admin.toolbar.heading3')} + > + + + +
+ + editor.chain().focus().toggleBulletList().run()} + isActive={editor.isActive('bulletList')} + title={t('news.admin.toolbar.bulletList')} + > + + + editor.chain().focus().toggleOrderedList().run()} + isActive={editor.isActive('orderedList')} + title={t('news.admin.toolbar.orderedList')} + > + + + editor.chain().focus().toggleBlockquote().run()} + isActive={editor.isActive('blockquote')} + title={t('news.admin.toolbar.blockquote')} + > + + + editor.chain().focus().toggleCodeBlock().run()} + isActive={editor.isActive('codeBlock')} + title={t('news.admin.toolbar.codeBlock')} + > + + + +
+ + editor.chain().focus().setTextAlign('left').run()} + isActive={editor.isActive({ textAlign: 'left' })} + title={t('news.admin.toolbar.alignLeft')} + > + + + editor.chain().focus().setTextAlign('center').run()} + isActive={editor.isActive({ textAlign: 'center' })} + title={t('news.admin.toolbar.alignCenter')} + > + + + +
+ + editor.chain().focus().toggleHighlight().run()} + isActive={editor.isActive('highlight')} + title={t('news.admin.toolbar.highlight')} + > + + + + + + mediaInputRef.current?.click()} + disabled={isUploading} + title={t('news.admin.toolbar.image')} + > + {isUploading ? ( +
+ ) : ( + + )} + +
+ )} + + {/* Editor content */} + +
+ + {/* Hidden file inputs */} + +
+ )} + + {/* Error feedback */} + {saveError && ( +
+ {saveError} +
+ )} + + {/* Bottom save button */} + +
+
+ ); +} diff --git a/src/pages/AdminInfoPages.tsx b/src/pages/AdminInfoPages.tsx new file mode 100644 index 0000000..be774f8 --- /dev/null +++ b/src/pages/AdminInfoPages.tsx @@ -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 = () => ( + +); + +const RefreshIcon = () => ( + +); + +const PencilIcon = () => ( + +); + +const TrashIcon = () => ( + +); + +const FileTextIcon = () => ( + +); + +// --- 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 ( +
+
+
+
+ {page.icon && {page.icon}} + + /{page.slug} + + + {page.page_type === 'faq' ? 'FAQ' : t('admin.infoPages.typePage')} + + + {page.is_active ? t('admin.infoPages.active') : t('admin.infoPages.inactive')} + + {page.replaces_tab && ( + + {t(`admin.infoPages.replacesTabOptions.${page.replaces_tab}`)} + + )} + #{page.id} +
+ +

{resolvedTitle}

+ +
+ + {t('admin.infoPages.fields.sortOrder')}: {page.sort_order} + + {page.updated_at && {new Date(page.updated_at).toLocaleDateString()}} +
+
+ +
+ + + +
+
+
+ ); +}); + +// --- 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 ( + + ); +}); + +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('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 ( +
+ {/* Header */} +
+
+ +
+

{t('admin.infoPages.title')}

+ {items.length > 0 && ( + + {items.length} + + )} +
+
+
+ + + +
+
+ + {/* Filter tabs */} +
+ {(['all', 'page', 'faq'] as const).map((tab) => ( + + ))} +
+ + {/* Pages list */} + {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ) : items.length === 0 ? ( +
+ +

{t('admin.infoPages.noPages')}

+
+ ) : ( +
+ {items.map((page) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index f8b8f81..24db4c2 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -333,12 +333,27 @@ const icons = { ), + 'list-checks': ( + + + + + ), search: ( ), + 'file-text': ( + + + + + + + + ), chevron: ( @@ -412,6 +427,12 @@ const sections: AdminSection[] = [ gradient: 'linear-gradient(135deg, rgb(var(--color-accent-400)), rgb(var(--color-error-400)))', items: [ { 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', icon: 'ticket', @@ -549,6 +570,12 @@ const sections: AdminSection[] = [ to: '/admin/email-templates', permission: 'email_templates:read', }, + { + name: 'admin.nav.infoPages', + icon: 'file-text', + to: '/admin/info-pages', + permission: 'settings:read', + }, { name: 'admin.nav.updates', icon: 'refresh', diff --git a/src/pages/Info.tsx b/src/pages/Info.tsx index 24b73b5..af34bb4 100644 --- a/src/pages/Info.tsx +++ b/src/pages/Info.tsx @@ -1,9 +1,11 @@ -import { useState } from 'react'; +import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import DOMPurify from 'dompurify'; import { infoApi, FaqPage } from '../api/info'; +import { infoPagesApi } from '../api/infoPages'; import { promoApi, LoyaltyTierInfo } from '../api/promo'; +import type { FaqItem, ReplacesTab } from '../api/infoPages'; const InfoIcon = () => ( @@ -67,7 +69,7 @@ const ChevronIcon = ({ expanded }: { expanded: boolean }) => ( ); -type TabType = 'faq' | 'rules' | 'privacy' | 'offer' | 'loyalty'; +const BUILTIN_TABS = new Set(['faq', 'rules', 'privacy', 'offer', 'loyalty']); // Sanitize HTML content to prevent XSS 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) const formatContent = (content: string): string => { if (!content) return ''; @@ -154,15 +286,165 @@ const formatContent = (content: string): string => { return sanitizeHtml(result); }; +// --- FAQ Accordion for tab replacements --- + +function ReplacementFaqItem({ + item, + isOpen, + onToggle, +}: { + item: FaqItem; + isOpen: boolean; + onToggle: () => void; +}) { + const contentRef = useRef(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 ( +
+ +
+
+
+
+
+
+ ); +} + +function ReplacementFaqView({ items }: { items: FaqItem[] }) { + const [openKey, setOpenKey] = useState(null); + + const handleToggle = useCallback((key: string) => { + setOpenKey((prev) => (prev === key ? null : key)); + }, []); + + return ( +
+ {items.map((item, index) => { + const key = `${index}-${item.q.slice(0, 50)}`; + return ( + handleToggle(key)} + /> + ); + })} +
+ ); +} + export default function Info() { - const { t } = useTranslation(); - const [activeTab, setActiveTab] = useState('faq'); + const { t, i18n } = useTranslation(); + const [activeTab, setActiveTab] = useState('faq'); const [expandedFaq, setExpandedFaq] = useState(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({ queryKey: ['faq-pages'], queryFn: infoApi.getFaqPages, - enabled: activeTab === 'faq', + enabled: activeTab === 'faq' && !currentTabSlug && replacementsLoaded, staleTime: 0, refetchOnMount: 'always', }); @@ -170,7 +452,7 @@ export default function Info() { const { data: rules, isLoading: rulesLoading } = useQuery({ queryKey: ['rules'], queryFn: infoApi.getRules, - enabled: activeTab === 'rules', + enabled: activeTab === 'rules' && !currentTabSlug && replacementsLoaded, staleTime: 0, refetchOnMount: 'always', }); @@ -178,7 +460,7 @@ export default function Info() { const { data: privacy, isLoading: privacyLoading } = useQuery({ queryKey: ['privacy-policy'], queryFn: infoApi.getPrivacyPolicy, - enabled: activeTab === 'privacy', + enabled: activeTab === 'privacy' && !currentTabSlug && replacementsLoaded, staleTime: 0, refetchOnMount: 'always', }); @@ -186,7 +468,7 @@ export default function Info() { const { data: offer, isLoading: offerLoading } = useQuery({ queryKey: ['public-offer'], queryFn: infoApi.getPublicOffer, - enabled: activeTab === 'offer', + enabled: activeTab === 'offer' && !currentTabSlug && replacementsLoaded, staleTime: 0, refetchOnMount: 'always', }); @@ -199,19 +481,76 @@ export default function Info() { refetchOnMount: 'always', }); - const tabs = [ - { id: 'faq' as TabType, label: t('info.faq'), icon: QuestionIcon }, - { id: 'rules' as TabType, label: t('info.rules'), icon: DocumentIcon }, - { id: 'privacy' as TabType, label: t('info.privacy'), icon: ShieldIcon }, - { id: 'offer' as TabType, label: t('info.offer'), icon: DocumentIcon }, - { id: 'loyalty' as TabType, label: t('info.loyalty'), icon: StarIcon }, + const builtinTabs: Array<{ id: string; label: string; icon: React.FC; emoji?: string }> = [ + { id: 'faq', label: t('info.faq'), icon: QuestionIcon }, + { id: 'rules', label: t('info.rules'), icon: DocumentIcon }, + { id: 'privacy', label: t('info.privacy'), icon: ShieldIcon }, + { id: 'offer', label: t('info.offer'), icon: DocumentIcon }, + { id: 'loyalty', label: t('info.loyalty'), icon: StarIcon }, ]; - const toggleFaq = (id: number) => { - setExpandedFaq(expandedFaq === id ? null : id); + const customTabs = extraPages.map((p) => { + 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 ( +
+
+
+ ); + } + + if (!infoPage) { + return
{t('info.noContent')}
; + } + + if (infoPage.page_type === 'faq') { + if (infoPageFaqItems.length === 0) { + return
{t('info.noFaq')}
; + } + return ; + } + + if (!infoPageHtml) { + return
{t('info.noContent')}
; + } + + return ( +
+
+
+ ); }; 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 ( +
+
+
+ ); + } + + // Built-in tab replaced by an InfoPage + if (currentTabSlug) { + return renderInfoPageContent(); + } + if (activeTab === 'faq') { if (faqLoading) { return ( @@ -231,7 +570,7 @@ export default function Info() {
))}
diff --git a/src/pages/InfoPageView.tsx b/src/pages/InfoPageView.tsx new file mode 100644 index 0000000..d146b87 --- /dev/null +++ b/src/pages/InfoPageView.tsx @@ -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 = () => ( + + + +); + +/** + * 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 }) => ( + + + +); + +const SearchIcon = () => ( + + + +); + +function FaqAccordionItem({ + item, + isOpen, + onToggle, +}: { + item: FaqItem; + isOpen: boolean; + onToggle: () => void; +}) { + const contentRef = useRef(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 ( +
+ +
+
+
+
+
+
+ ); +} + +function FaqView({ items }: { items: FaqItem[] }) { + const { t } = useTranslation(); + const [openKey, setOpenKey] = useState(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 ( +
+ {/* Search */} + {items.length > 3 && ( +
+
+ +
+ { + setSearch(e.target.value); + setOpenKey(null); + }} + placeholder={t('admin.infoPages.faq.searchPlaceholder')} + className="input pl-9 text-sm" + /> +
+ )} + + {/* Accordion items */} + {filteredItems.length === 0 ? ( +
+ {search ? t('admin.infoPages.faq.noResults') : t('admin.infoPages.faq.noQuestions')} +
+ ) : ( +
+ {filteredItems.map((item, index) => { + const key = `${index}-${item.q.slice(0, 50)}`; + return ( + handleToggle(key)} + /> + ); + })} +
+ )} +
+ ); +} + +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 ( +
+
+
+
+
+
+
+
+
+
+ ); + } + + if (isError || !page) { + return ( +
+ {!capabilities.hasBackButton && ( + + )} +
+ {t('admin.infoPages.notFound')} +
+
+ ); + } + + return ( +
+ {/* Back button */} + {!capabilities.hasBackButton && ( + + )} + + {/* Page header */} +
+ {page.icon && {page.icon}} +

+ {resolvedTitle} +

+
+ + {/* Page content */} + {isFaq ? ( + + ) : ( + /* Regular page content - sanitized with DOMPurify (strict allowlist) */ +
+ )} +
+ ); +} diff --git a/src/styles/globals.css b/src/styles/globals.css index ba512f9..cab9a2c 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -967,6 +967,9 @@ img.twemoji { .prose table { @apply mb-4 w-full border-collapse; + display: block; + overflow-x: auto; + -webkit-overflow-scrolling: touch; } .prose th { @@ -978,7 +981,7 @@ img.twemoji { } .prose img { - @apply my-4 max-w-full rounded-xl; + @apply my-4 h-auto max-w-full rounded-xl; } .prose mark { @@ -989,6 +992,10 @@ img.twemoji { @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 .prose { @apply text-champagne-800; @@ -1070,6 +1077,14 @@ img.twemoji { @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 */ .prose-plain { @apply whitespace-pre-line;