From 1772d9632e868f1f780b5722b04b1954225d591f Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 24 Apr 2026 04:15:28 +0300 Subject: [PATCH 01/35] feat: admin bulk actions page with TanStack Table Full-featured admin page for mass operations on users: - TanStack Table with checkbox selection (select all / individual) - Server-side pagination, search, and filters (status, tariff, promo group) - Columns: user, subscription status, tariff, balance, days left, promo group - Floating glass-effect action bar with 7 bulk operations: extend subscription, deactivate, activate, change tariff, add traffic, add balance, assign promo group - Action modal dialogs with input fields, loading state, result summary - Responsive design for desktop, mobile, and Telegram Mini App - Full i18n support (ru, en) - Dark theme with accent highlights and status color badges --- src/App.tsx | 11 + src/api/adminBulkActions.ts | 42 ++ src/locales/en.json | 59 +- src/locales/ru.json | 59 +- src/pages/AdminBulkActions.tsx | 1201 ++++++++++++++++++++++++++++++++ src/pages/AdminPanel.tsx | 12 + 6 files changed, 1382 insertions(+), 2 deletions(-) create mode 100644 src/api/adminBulkActions.ts create mode 100644 src/pages/AdminBulkActions.tsx diff --git a/src/App.tsx b/src/App.tsx index b030a73..0c8423c 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')); @@ -966,6 +967,16 @@ function App() { } /> + + + + + + } + /> ; +} + +export const adminBulkActionsApi = { + execute: async (data: BulkActionRequest): Promise => { + const response = await apiClient.post('/cabinet/admin/bulk/execute', data); + return response.data; + }, +}; diff --git a/src/locales/en.json b/src/locales/en.json index 9fb58e4..190eb65 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1125,7 +1125,8 @@ "salesStats": "Sales Statistics", "landings": "Landings", "referralNetwork": "Referral Network", - "news": "News" + "news": "News", + "bulkActions": "Bulk Actions" }, "panel": { "title": "Admin Panel", @@ -2021,6 +2022,62 @@ "telegramOidcClientSecret": "Client Secret" } }, + "bulkActions": { + "title": "Bulk Actions", + "subtitle": "User management", + "selectedCount": "Selected: {{count}}", + "selectAll": "Select all", + "deselectAll": "Deselect all", + "noResults": "No users found", + "actions": { + "extend": "Extend subscription", + "cancel": "Deactivate subscriptions", + "activate": "Activate subscriptions", + "changeTariff": "Change tariff", + "addTraffic": "Add traffic", + "addBalance": "Add balance", + "assignPromoGroup": "Assign promo group" + }, + "params": { + "days": "Number of days", + "tariff": "Select tariff", + "trafficGb": "Traffic amount (GB)", + "balanceRub": "Amount (RUB)", + "promoGroup": "Promo group", + "removePromoGroup": "Remove promo group" + }, + "confirm": "Apply", + "cancel": "Cancel", + "executing": "Executing...", + "complete": "Complete", + "successCount": "Succeeded: {{count}}", + "errorCount": "Errors: {{count}}", + "filters": { + "search": "Search by ID, username, email", + "status": "Subscription status", + "tariff": "Tariff", + "promoGroup": "Promo group", + "allStatuses": "All statuses", + "allTariffs": "All tariffs", + "allGroups": "All groups" + }, + "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" + } + }, "theme": { "accentColor": "Accent color", "customizeColors": "Customize colors", diff --git a/src/locales/ru.json b/src/locales/ru.json index 2c69a25..b4499fc 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1146,7 +1146,8 @@ "salesStats": "Статистика продаж", "landings": "Лендинги", "referralNetwork": "Реферальная сеть", - "news": "Новости" + "news": "Новости", + "bulkActions": "Массовые действия" }, "panel": { "title": "Панель администратора", @@ -3769,6 +3770,62 @@ "users": "Пользователи", "security": "Безопасность" }, + "bulkActions": { + "title": "Массовые действия", + "subtitle": "Управление пользователями", + "selectedCount": "Выбрано: {{count}}", + "selectAll": "Выбрать всех", + "deselectAll": "Снять выделение", + "noResults": "Пользователи не найдены", + "actions": { + "extend": "Продлить подписку", + "cancel": "Деактивировать подписки", + "activate": "Активировать подписки", + "changeTariff": "Сменить тариф", + "addTraffic": "Начислить трафик", + "addBalance": "Начислить баланс", + "assignPromoGroup": "Назначить промогруппу" + }, + "params": { + "days": "Количество дней", + "tariff": "Выберите тариф", + "trafficGb": "Объём трафика (ГБ)", + "balanceRub": "Сумма (руб.)", + "promoGroup": "Промогруппа", + "removePromoGroup": "Убрать промогруппу" + }, + "confirm": "Применить", + "cancel": "Отмена", + "executing": "Выполнение...", + "complete": "Выполнено", + "successCount": "Успешно: {{count}}", + "errorCount": "Ошибок: {{count}}", + "filters": { + "search": "Поиск по ID, нику, email", + "status": "Статус подписки", + "tariff": "Тариф", + "promoGroup": "Промогруппа", + "allStatuses": "Все статусы", + "allTariffs": "Все тарифы", + "allGroups": "Все группы" + }, + "statuses": { + "active": "Активна", + "expired": "Истекла", + "trial": "Триал", + "limited": "Ограничена", + "disabled": "Отключена" + }, + "columns": { + "user": "Пользователь", + "status": "Статус", + "tariff": "Тариф", + "balance": "Баланс", + "daysRemaining": "Дни", + "promoGroup": "Группа", + "spent": "Потрачено" + } + }, "theme": { "accentColor": "Акцентный цвет", "customizeColors": "Настроить цвета", diff --git a/src/pages/AdminBulkActions.tsx b/src/pages/AdminBulkActions.tsx new file mode 100644 index 0000000..8a7bfa0 --- /dev/null +++ b/src/pages/AdminBulkActions.tsx @@ -0,0 +1,1201 @@ +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +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 } from '../api/adminUsers'; +import { tariffsApi, type TariffListItem } from '../api/tariffs'; +import { promocodesApi, type PromoGroup } from '../api/promocodes'; +import { + adminBulkActionsApi, + type BulkActionType, + type BulkActionParams, + type BulkActionResult, +} 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 ModalState { + open: boolean; + action: BulkActionType | null; + loading: boolean; + result: BulkActionResult | 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 = () => ( + + + +); + +// ============ 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 ( +
+ +
+ +
+
+ ); +} + +// ============ 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); + + useEffect(() => { + if (tariffs.length > 0 && tariffId === 0) { + setTariffId(tariffs[0].id); + } + }, [tariffs, tariffId]); + + useEffect(() => { + if (promoGroups.length > 0 && promoGroupId === null) { + setPromoGroupId(promoGroups[0].id); + } + }, [promoGroups, promoGroupId]); + + if (!modal.open || !modal.action) return null; + + const actionLabelKeys: Record = { + extend: 'admin.bulkActions.actions.extend', + cancel: 'admin.bulkActions.actions.cancel', + activate: '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', + }; + + const handleSubmit = () => { + const params: BulkActionParams = {}; + switch (modal.action) { + case 'extend': + params.days = days; + break; + case 'change_tariff': + params.tariff_id = tariffId; + break; + case 'add_traffic': + params.traffic_gb = trafficGb; + break; + case 'add_balance': + params.balance_kopeks = Math.round(balanceRub * 100); + break; + case 'assign_promo_group': + params.promo_group_id = promoGroupId; + break; + } + onExecute(params); + }; + + const renderInputs = () => { + switch (modal.action) { + case 'extend': + 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))} + /> +
+ ); + default: + return null; + } + }; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+

{t(actionLabelKeys[modal.action])}

+ +
+ + {/* Result view */} + {modal.result ? ( +
+
+
+ {t('admin.bulkActions.complete')} +
+
+
+
+ {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 })} +
+
+ )} +
+
+ +
+ ) : ( + <> + {/* Affected count */} +
+

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

+
+ + {/* Action-specific inputs */} +
{renderInputs()}
+ + {/* Buttons */} +
+ + +
+ + )} +
+
+ ); +} + +// ============ Floating Action Bar ============ + +function FloatingActionBar({ + selectedCount, + onAction, +}: { + selectedCount: number; + onAction: (type: BulkActionType) => void; +}) { + 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); + }, []); + + if (selectedCount === 0) return null; + + const actions: ActionConfig[] = [ + { + type: 'extend', + labelKey: 'admin.bulkActions.actions.extend', + icon: , + colorClass: 'text-success-400 hover:bg-success-500/10', + }, + { + type: 'activate', + labelKey: 'admin.bulkActions.actions.activate', + icon: , + colorClass: 'text-success-400 hover:bg-success-500/10', + }, + { + type: 'cancel', + 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: 'add_balance', + labelKey: 'admin.bulkActions.actions.addBalance', + icon: , + colorClass: 'text-warning-400 hover:bg-warning-500/10', + }, + { + type: 'assign_promo_group', + labelKey: 'admin.bulkActions.actions.assignPromoGroup', + icon: , + colorClass: 'text-accent-300 hover:bg-accent-300/10', + }, + ]; + + return ( +
+
+ {/* Selection count */} +
+
+ {selectedCount} +
+ + {t('admin.bulkActions.selectedCount', { count: selectedCount })} + +
+ +
+ + {/* Actions dropdown */} +
+ + + {open && ( +
+ {actions.map((a) => ( + + ))} +
+ )} +
+
+
+ ); +} + +// ============ 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 [loading, setLoading] = useState(true); + + // Filters + const [searchInput, setSearchInput] = useState(''); + const [committedSearch, setCommittedSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [tariffFilter, setTariffFilter] = useState(''); + const [promoGroupFilter, setPromoGroupFilter] = useState(''); + + // Pagination + const [offset, setOffset] = useState(0); + const limit = 50; + + // Selection + const [rowSelection, setRowSelection] = useState({}); + + // Modal + const [modal, setModal] = useState({ + open: false, + action: null, + loading: false, + result: null, + }); + + // Debounce timer ref + const searchTimerRef = useRef>(undefined); + + // ---- 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) params.tariff_id = Number(tariffFilter); + if (promoGroupFilter) params.promo_group_id = Number(promoGroupFilter); + + const data = await adminUsersApi.getUsers( + params as Parameters[0], + ); + setUsers(data.users); + setTotal(data.total); + } catch { + // keep stale data + } finally { + setLoading(false); + } + }, [offset, committedSearch, statusFilter, tariffFilter, promoGroupFilter]); + + useEffect(() => { + loadUsers(); + }, [loadUsers]); + + // Load tariffs and promo groups once + useEffect(() => { + const load = async () => { + try { + const [tariffData, pgData] = await Promise.all([ + tariffsApi.getTariffs(true), + promocodesApi.getPromoGroups({ limit: 200 }), + ]); + setTariffs(tariffData.tariffs); + setPromoGroups(pgData.items); + } catch { + // silently fail + } + }; + load(); + }, []); + + // ---- Handlers ---- + + const handleSearchChange = (value: string) => { + setSearchInput(value); + clearTimeout(searchTimerRef.current); + searchTimerRef.current = setTimeout(() => { + setOffset(0); + setCommittedSearch(value); + setRowSelection({}); + }, 400); + }; + + const handleSearchSubmit = (e: React.FormEvent) => { + e.preventDefault(); + clearTimeout(searchTimerRef.current); + setOffset(0); + setCommittedSearch(searchInput); + setRowSelection({}); + }; + + const handleStatusFilterChange = (v: string) => { + setStatusFilter(v as SubscriptionStatusFilter); + setOffset(0); + setRowSelection({}); + }; + + const handleTariffFilterChange = (v: string) => { + setTariffFilter(v); + setOffset(0); + setRowSelection({}); + }; + + const handlePromoGroupFilterChange = (v: string) => { + setPromoGroupFilter(v); + setOffset(0); + setRowSelection({}); + }; + + const handleRefresh = () => { + setRowSelection({}); + loadUsers(); + }; + + const selectedUserIds = useMemo(() => { + return Object.keys(rowSelection) + .filter((k) => rowSelection[k]) + .map((idx) => users[Number(idx)]?.id) + .filter(Boolean); + }, [rowSelection, users]); + + const handleOpenAction = (type: BulkActionType) => { + setModal({ open: true, action: type, loading: false, result: null }); + }; + + const handleExecuteAction = async (params: BulkActionParams) => { + if (!modal.action || selectedUserIds.length === 0) return; + + setModal((prev) => ({ ...prev, loading: true })); + + try { + const result = await adminBulkActionsApi.execute({ + action: modal.action, + user_ids: selectedUserIds, + params, + }); + setModal((prev) => ({ ...prev, loading: false, result })); + // Refresh the user list after action + loadUsers(); + } catch { + setModal((prev) => ({ + ...prev, + loading: false, + result: { + success: false, + total: selectedUserIds.length, + success_count: 0, + error_count: selectedUserIds.length, + errors: [], + }, + })); + } + }; + + const handleCloseModal = () => { + if (modal.result) { + setRowSelection({}); + } + setModal({ open: false, action: null, loading: false, result: null }); + }; + + // ---- TanStack Table ---- + + const columns = useMemo[]>( + () => [ + { + id: 'select', + size: 40, + header: ({ table }) => ( +
+ +
+ ), + cell: ({ row }) => ( +
+ +
+ ), + enableSorting: false, + }, + { + id: 'user', + accessorFn: (row) => row.full_name, + header: t('admin.bulkActions.columns.user'), + size: 200, + cell: ({ row }) => { + const user = row.original; + return ( +
+
+ {user.first_name?.[0] || user.username?.[0] || '?'} +
+
+
{user.full_name}
+
+ {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.promo_group_name, + header: t('admin.bulkActions.columns.tariff'), + size: 120, + cell: ({ row }) => { + // UserListItem doesn't have tariff_name directly; we'll show subscription info + const user = row.original; + if (!user.has_subscription) { + return -; + } + return ( + + {user.subscription_status === 'trial' + ? t('admin.bulkActions.statuses.trial') + : user.subscription_status || '-'} + + ); + }, + }, + { + id: 'balance', + accessorKey: 'balance_rubles', + header: t('admin.bulkActions.columns.balance'), + size: 100, + cell: ({ getValue }) => ( + + {formatWithCurrency(getValue() as number)} + + ), + }, + { + id: 'days_remaining', + header: t('admin.bulkActions.columns.daysRemaining'), + size: 80, + cell: ({ row }) => { + const user = row.original; + if (!user.subscription_end_date) { + return -; + } + const end = new Date(user.subscription_end_date); + const now = new Date(); + const days = Math.max( + 0, + Math.ceil((end.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)), + ); + return ( + + {days} + + ); + }, + }, + { + 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], + ); + + const table = useReactTable({ + data: users, + 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: 'trial', label: t('admin.bulkActions.statuses.trial') }, + { value: 'limited', label: t('admin.bulkActions.statuses.limited') }, + { value: 'disabled', label: t('admin.bulkActions.statuses.disabled') }, + ]; + + const tariffOptions: DropdownOption[] = [ + { value: '', label: t('admin.bulkActions.filters.allTariffs') }, + ...tariffs.map((tt) => ({ value: String(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 })), + ]; + + 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 */} +
+ + + +
+
+ + {/* 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) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+
+ {offset + 1}–{Math.min(offset + limit, total)} / {total} +
+
+ + + {currentPage} / {totalPages} + + +
+
+ )} + + {/* Floating action bar */} + + + {/* Action modal */} + + + {/* Bottom spacer when action bar is visible */} + {selectedUserIds.length > 0 &&
} +
+ ); +} diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index f8b8f81..94470e4 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -333,6 +333,12 @@ const icons = { ), + 'list-checks': ( + + + + + ), search: ( @@ -412,6 +418,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', From ebe9d9be483f6fc58fd21009f8d14d989e7259b9 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 24 Apr 2026 04:24:33 +0300 Subject: [PATCH 02/35] =?UTF-8?q?fix:=20bulk=20actions=20frontend=20?= =?UTF-8?q?=E2=80=94=20selectedUserIds=20mapping,=20debounce=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix selectedUserIds: was mapping row keys as array indices instead of using them directly as user IDs (getRowId returns String(user.id)) - Add useEffect cleanup for search debounce timer on unmount --- src/pages/AdminBulkActions.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/pages/AdminBulkActions.tsx b/src/pages/AdminBulkActions.tsx index 8a7bfa0..b561261 100644 --- a/src/pages/AdminBulkActions.tsx +++ b/src/pages/AdminBulkActions.tsx @@ -719,6 +719,11 @@ export default function AdminBulkActions() { }, 400); }; + // Cleanup debounce on unmount + useEffect(() => { + return () => clearTimeout(searchTimerRef.current); + }, []); + const handleSearchSubmit = (e: React.FormEvent) => { e.preventDefault(); clearTimeout(searchTimerRef.current); @@ -753,9 +758,9 @@ export default function AdminBulkActions() { const selectedUserIds = useMemo(() => { return Object.keys(rowSelection) .filter((k) => rowSelection[k]) - .map((idx) => users[Number(idx)]?.id) - .filter(Boolean); - }, [rowSelection, users]); + .map(Number) + .filter((id) => id > 0); + }, [rowSelection]); const handleOpenAction = (type: BulkActionType) => { setModal({ open: true, action: type, loading: false, result: null }); From 312e0b492707a646fcb6f0ee71b1568389203043 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 24 Apr 2026 04:40:53 +0300 Subject: [PATCH 03/35] =?UTF-8?q?feat:=20bulk=20actions=20=E2=80=94=20live?= =?UTF-8?q?=20progress,=20grant=20subscription,=20error=20details?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Real-time progress: SSE streaming with animated progress bar, live success/error counters, scrollable log of last 10 results with auto-scroll, percentage display - New action "Выдать подписку": tariff selector + days input, warning about users with existing subscriptions being skipped - Error details: collapsible section with per-user error list, colored summary line (green successes / red errors) - Fix: modal cannot be closed during loading (backdrop + Escape) - Fix: row checkbox aria-labels now user-specific - i18n: full progress/grant/error translations (ru + en) --- src/api/adminBulkActions.ts | 106 +++++++- src/locales/en.json | 21 +- src/locales/ru.json | 21 +- src/pages/AdminBulkActions.tsx | 446 +++++++++++++++++++++++++++++---- 4 files changed, 535 insertions(+), 59 deletions(-) diff --git a/src/api/adminBulkActions.ts b/src/api/adminBulkActions.ts index 5ec403f..4a35afe 100644 --- a/src/api/adminBulkActions.ts +++ b/src/api/adminBulkActions.ts @@ -1,4 +1,5 @@ import apiClient from './client'; +import { tokenStorage } from '../utils/token'; export type BulkActionType = | 'extend' @@ -7,7 +8,8 @@ export type BulkActionType = | 'change_tariff' | 'add_traffic' | 'add_balance' - | 'assign_promo_group'; + | 'assign_promo_group' + | 'grant_subscription'; export interface BulkActionRequest { action: BulkActionType; @@ -23,20 +25,114 @@ export interface BulkActionParams { promo_group_id?: number | null; } +export interface BulkActionErrorItem { + user_id: number; + username?: string; + error: string; +} + export interface BulkActionResult { success: boolean; total: number; success_count: number; error_count: number; - errors: Array<{ - user_id: number; - error: string; - }>; + errors: BulkActionErrorItem[]; } +export interface BulkProgressEvent { + type: 'progress'; + current: number; + total: number; + user_id: number; + username?: string; + success: boolean; + message?: string; + error?: string; +} + +export interface BulkCompleteEvent { + type: 'complete'; + success: boolean; + total: number; + success_count: number; + error_count: number; + errors: BulkActionErrorItem[]; +} + +export type BulkSSEEvent = BulkProgressEvent | BulkCompleteEvent; + +const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'; + export const adminBulkActionsApi = { execute: async (data: BulkActionRequest): Promise => { const response = await apiClient.post('/cabinet/admin/bulk/execute', data); return response.data; }, + + 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 result = (await response.json()) as BulkActionResult; + onEvent({ + type: 'complete', + ...result, + }); + } + }, }; diff --git a/src/locales/en.json b/src/locales/en.json index 190eb65..b7db59f 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2028,6 +2028,8 @@ "selectedCount": "Selected: {{count}}", "selectAll": "Select all", "deselectAll": "Deselect all", + "selectUser": "Select user {{name}}", + "deselectUser": "Deselect user {{name}}", "noResults": "No users found", "actions": { "extend": "Extend subscription", @@ -2036,7 +2038,8 @@ "changeTariff": "Change tariff", "addTraffic": "Add traffic", "addBalance": "Add balance", - "assignPromoGroup": "Assign promo group" + "assignPromoGroup": "Assign promo group", + "grantSubscription": "Grant subscription" }, "params": { "days": "Number of days", @@ -2046,12 +2049,28 @@ "promoGroup": "Promo group", "removePromoGroup": "Remove promo group" }, + "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", diff --git a/src/locales/ru.json b/src/locales/ru.json index b4499fc..74d0517 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3776,6 +3776,8 @@ "selectedCount": "Выбрано: {{count}}", "selectAll": "Выбрать всех", "deselectAll": "Снять выделение", + "selectUser": "Выбрать пользователя {{name}}", + "deselectUser": "Снять выбор пользователя {{name}}", "noResults": "Пользователи не найдены", "actions": { "extend": "Продлить подписку", @@ -3784,7 +3786,8 @@ "changeTariff": "Сменить тариф", "addTraffic": "Начислить трафик", "addBalance": "Начислить баланс", - "assignPromoGroup": "Назначить промогруппу" + "assignPromoGroup": "Назначить промогруппу", + "grantSubscription": "Выдать подписку" }, "params": { "days": "Количество дней", @@ -3794,12 +3797,28 @@ "promoGroup": "Промогруппа", "removePromoGroup": "Убрать промогруппу" }, + "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": "Статус подписки", diff --git a/src/pages/AdminBulkActions.tsx b/src/pages/AdminBulkActions.tsx index b561261..b039a82 100644 --- a/src/pages/AdminBulkActions.tsx +++ b/src/pages/AdminBulkActions.tsx @@ -16,6 +16,7 @@ import { type BulkActionType, type BulkActionParams, type BulkActionResult, + type BulkProgressEvent, } from '../api/adminBulkActions'; import { usePlatform } from '../platform/hooks/usePlatform'; import { useCurrency } from '../hooks/useCurrency'; @@ -32,11 +33,20 @@ interface ActionConfig { 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 ============ @@ -235,6 +245,165 @@ function DropdownSelect({ ); } +// ============ 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.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 { @@ -260,12 +429,17 @@ function ActionModal({ 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); useEffect(() => { if (tariffs.length > 0 && tariffId === 0) { setTariffId(tariffs[0].id); } - }, [tariffs, tariffId]); + if (tariffs.length > 0 && grantTariffId === 0) { + setGrantTariffId(tariffs[0].id); + } + }, [tariffs, tariffId, grantTariffId]); useEffect(() => { if (promoGroups.length > 0 && promoGroupId === null) { @@ -273,6 +447,18 @@ function ActionModal({ } }, [promoGroups, promoGroupId]); + // 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 = { @@ -283,6 +469,7 @@ function ActionModal({ 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', }; const handleSubmit = () => { @@ -303,10 +490,20 @@ function ActionModal({ case 'assign_promo_group': params.promo_group_id = promoGroupId; break; + case 'grant_subscription': + params.tariff_id = grantTariffId; + params.days = grantDays; + break; } onExecute(params); }; + const handleBackdropClick = () => { + if (!modal.loading) { + onClose(); + } + }; + const renderInputs = () => { switch (modal.action) { case 'extend': @@ -386,6 +583,40 @@ function ActionModal({ />
); + 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')} +

+
+
+ ); default: return null; } @@ -397,30 +628,64 @@ function ActionModal({ role="dialog" aria-modal="true" > - {/* Backdrop */} -
+ {/* Backdrop — no close on click during loading */} +
{/* Modal */}
{/* Header */}

{t(actionLabelKeys[modal.action])}

- + {!modal.loading && ( + + )}
- {/* Result view */} - {modal.result ? ( + {/* 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, + })} + + + )} +
@@ -442,6 +707,10 @@ function ActionModal({ )}
+ + {/* Collapsible error details */} + +
@@ -554,6 +816,12 @@ function FloatingActionBar({ 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', @@ -655,6 +923,7 @@ export default function AdminBulkActions() { action: null, loading: false, result: null, + progress: null, }); // Debounce timer ref @@ -763,32 +1032,100 @@ export default function AdminBulkActions() { }, [rowSelection]); const handleOpenAction = (type: BulkActionType) => { - setModal({ open: true, action: type, loading: false, result: null }); + setModal({ open: true, action: type, loading: false, result: null, progress: null }); }; const handleExecuteAction = async (params: BulkActionParams) => { if (!modal.action || selectedUserIds.length === 0) return; - setModal((prev) => ({ ...prev, loading: true })); + const totalCount = selectedUserIds.length; + setModal((prev) => ({ + ...prev, + loading: true, + progress: { + current: 0, + total: totalCount, + successCount: 0, + errorCount: 0, + log: [], + }, + })); try { - const result = await adminBulkActionsApi.execute({ - action: modal.action, - user_ids: selectedUserIds, - params, + await adminBulkActionsApi.executeWithStream( + { + action: modal.action, + user_ids: selectedUserIds, + params, + }, + (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) => ({ + ...prev, + loading: false, + progress: null, + result: { + success: event.success, + total: event.total, + success_count: event.success_count, + error_count: event.error_count, + errors: event.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, + 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; }); - setModal((prev) => ({ ...prev, loading: false, result })); - // Refresh the user list after action + loadUsers(); } catch { setModal((prev) => ({ ...prev, loading: false, + progress: null, result: { success: false, - total: selectedUserIds.length, - success_count: 0, - error_count: selectedUserIds.length, + total: totalCount, + success_count: prev.progress?.successCount ?? 0, + error_count: totalCount - (prev.progress?.successCount ?? 0), errors: [], }, })); @@ -796,10 +1133,11 @@ export default function AdminBulkActions() { }; const handleCloseModal = () => { + if (modal.loading) return; if (modal.result) { setRowSelection({}); } - setModal({ open: false, action: null, loading: false, result: null }); + setModal({ open: false, action: null, loading: false, result: null, progress: null }); }; // ---- TanStack Table ---- @@ -830,26 +1168,30 @@ export default function AdminBulkActions() {
), - cell: ({ row }) => ( -
- -
- ), + cell: ({ row }) => { + const userName = + row.original.full_name || row.original.username || String(row.original.telegram_id); + return ( +
+ +
+ ); + }, enableSorting: false, }, { From fc8170fa1fab7cde102b26eea4c340276993eec1 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 24 Apr 2026 04:52:30 +0300 Subject: [PATCH 04/35] fix: align frontend bulk action types with backend API contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix BulkActionType enum: 'extend' → 'extend_subscription', 'cancel' → 'cancel_subscription', 'activate' → 'activate_subscription' - Fix BulkActionParams: 'balance_kopeks' → 'amount_kopeks' to match backend - Fix SSE complete handler: build errors from progress log (backend complete event has no errors array) - Add skipped_count to all BulkActionResult construction paths - Add dry_run support to BulkActionRequest type - Transform backend results[] to frontend errors[] in non-streaming path - Remove unused errors variable in streaming fallback path --- src/api/adminBulkActions.ts | 40 ++++++++++++++++++------ src/pages/AdminBulkActions.tsx | 56 +++++++++++++++++++++------------- 2 files changed, 66 insertions(+), 30 deletions(-) diff --git a/src/api/adminBulkActions.ts b/src/api/adminBulkActions.ts index 4a35afe..d0a0494 100644 --- a/src/api/adminBulkActions.ts +++ b/src/api/adminBulkActions.ts @@ -2,9 +2,10 @@ import apiClient from './client'; import { tokenStorage } from '../utils/token'; export type BulkActionType = - | 'extend' - | 'cancel' - | 'activate' + | 'extend_subscription' + | 'add_days' + | 'cancel_subscription' + | 'activate_subscription' | 'change_tariff' | 'add_traffic' | 'add_balance' @@ -15,13 +16,15 @@ export interface BulkActionRequest { action: BulkActionType; user_ids: number[]; params: BulkActionParams; + dry_run?: boolean; } export interface BulkActionParams { days?: number; tariff_id?: number; traffic_gb?: number; - balance_kopeks?: number; + amount_kopeks?: number; + balance_description?: string; promo_group_id?: number | null; } @@ -36,6 +39,7 @@ export interface BulkActionResult { total: number; success_count: number; error_count: number; + skipped_count: number; errors: BulkActionErrorItem[]; } @@ -52,11 +56,10 @@ export interface BulkProgressEvent { export interface BulkCompleteEvent { type: 'complete'; - success: boolean; total: number; success_count: number; error_count: number; - errors: BulkActionErrorItem[]; + skipped_count: number; } export type BulkSSEEvent = BulkProgressEvent | BulkCompleteEvent; @@ -66,7 +69,23 @@ const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'; export const adminBulkActionsApi = { execute: async (data: BulkActionRequest): Promise => { const response = await apiClient.post('/cabinet/admin/bulk/execute', data); - return response.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 ( @@ -128,10 +147,13 @@ export const adminBulkActionsApi = { } } else { // Fallback: non-streaming JSON response - const result = (await response.json()) as BulkActionResult; + const raw = await response.json(); onEvent({ type: 'complete', - ...result, + total: raw.total, + success_count: raw.success_count, + error_count: raw.error_count, + skipped_count: raw.skipped_count || 0, }); } }, diff --git a/src/pages/AdminBulkActions.tsx b/src/pages/AdminBulkActions.tsx index b039a82..cc36c4a 100644 --- a/src/pages/AdminBulkActions.tsx +++ b/src/pages/AdminBulkActions.tsx @@ -462,9 +462,10 @@ function ActionModal({ if (!modal.open || !modal.action) return null; const actionLabelKeys: Record = { - extend: 'admin.bulkActions.actions.extend', - cancel: 'admin.bulkActions.actions.cancel', - activate: 'admin.bulkActions.actions.activate', + 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', @@ -475,7 +476,7 @@ function ActionModal({ const handleSubmit = () => { const params: BulkActionParams = {}; switch (modal.action) { - case 'extend': + case 'extend_subscription': params.days = days; break; case 'change_tariff': @@ -485,7 +486,7 @@ function ActionModal({ params.traffic_gb = trafficGb; break; case 'add_balance': - params.balance_kopeks = Math.round(balanceRub * 100); + params.amount_kopeks = Math.round(balanceRub * 100); break; case 'assign_promo_group': params.promo_group_id = promoGroupId; @@ -506,7 +507,7 @@ function ActionModal({ const renderInputs = () => { switch (modal.action) { - case 'extend': + case 'extend_subscription': return (
))} From 161fde430160a145cbc2c79a616676905d0242ad Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 24 Apr 2026 05:28:08 +0300 Subject: [PATCH 06/35] =?UTF-8?q?fix:=20bulk=20actions=20=E2=80=94=20add?= =?UTF-8?q?=20tariff=20info=20to=20user=20list,=20fix=20tariff=20column=20?= =?UTF-8?q?display?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update UserListItem type to include tariff_id, tariff_name, traffic_used_gb, traffic_limit_gb, device_limit, days_remaining from the backend (now returned in user list API) - Fix tariff column: was showing subscription_status, now shows actual tariff_name from the API response --- src/api/adminUsers.ts | 6 ++++++ src/pages/AdminBulkActions.tsx | 15 ++++----------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index 3a0a23a..5e33075 100644 --- a/src/api/adminUsers.ts +++ b/src/api/adminUsers.ts @@ -49,6 +49,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; diff --git a/src/pages/AdminBulkActions.tsx b/src/pages/AdminBulkActions.tsx index 5cdc93a..d3ed81d 100644 --- a/src/pages/AdminBulkActions.tsx +++ b/src/pages/AdminBulkActions.tsx @@ -1245,22 +1245,15 @@ export default function AdminBulkActions() { }, { id: 'tariff', - accessorFn: (row) => row.promo_group_name, + accessorFn: (row) => row.tariff_name, header: t('admin.bulkActions.columns.tariff'), size: 120, cell: ({ row }) => { - // UserListItem doesn't have tariff_name directly; we'll show subscription info const user = row.original; - if (!user.has_subscription) { - return -; + if (!user.tariff_name) { + return ; } - return ( - - {user.subscription_status === 'trial' - ? t('admin.bulkActions.statuses.trial') - : user.subscription_status || '-'} - - ); + return {user.tariff_name}; }, }, { From 78b41dc33802138941234d7cf0ef78616a6d92d3 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 24 Apr 2026 05:49:38 +0300 Subject: [PATCH 07/35] =?UTF-8?q?feat:=20multi-tariff=20bulk=20actions=20U?= =?UTF-8?q?I=20=E2=80=94=20subscription-level=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Detect multi-tariff mode when users have multiple subscriptions - Expandable user rows with chevron: click to show subscription sub-rows - Each subscription sub-row shows: tariff name, status badge, days remaining (color-coded green/amber/red), traffic progress bar - Independent subscription checkboxes for subscription-level actions - FloatingActionBar shows dual counters: users (accent) + subscriptions (green), actions grouped by target type with disabled state - Subscription-level actions send subscription_ids, user-level send user_ids to the backend - Backward compatible: single-tariff mode unchanged (no chevrons, no sub-rows, user-only selection) - i18n: subscriptionsSelected, usersSelected, expand/collapse, daysUnit, trafficOf, trafficGbUnit (ru + en) --- src/api/adminBulkActions.ts | 4 +- src/api/adminUsers.ts | 12 + src/locales/en.json | 12 +- src/locales/ru.json | 12 +- src/pages/AdminBulkActions.tsx | 565 ++++++++++++++++++++++++++------- 5 files changed, 484 insertions(+), 121 deletions(-) diff --git a/src/api/adminBulkActions.ts b/src/api/adminBulkActions.ts index d0a0494..f74e8af 100644 --- a/src/api/adminBulkActions.ts +++ b/src/api/adminBulkActions.ts @@ -14,7 +14,8 @@ export type BulkActionType = export interface BulkActionRequest { action: BulkActionType; - user_ids: number[]; + user_ids?: number[]; + subscription_ids?: number[]; params: BulkActionParams; dry_run?: boolean; } @@ -48,6 +49,7 @@ export interface BulkProgressEvent { current: number; total: number; user_id: number; + subscription_id?: number; username?: string; success: boolean; message?: string; diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index 5e33075..fc223ad 100644 --- a/src/api/adminUsers.ts +++ b/src/api/adminUsers.ts @@ -33,6 +33,17 @@ 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; +} + export interface UserListItem { id: number; telegram_id: number; @@ -62,6 +73,7 @@ export interface UserListItem { has_restrictions: boolean; restriction_topup: boolean; restriction_subscription: boolean; + subscriptions?: UserListItemSubscription[]; } export interface UsersListResponse { diff --git a/src/locales/en.json b/src/locales/en.json index b7db59f..4a4a6cc 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2095,7 +2095,17 @@ "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" }, "theme": { "accentColor": "Accent color", diff --git a/src/locales/ru.json b/src/locales/ru.json index 74d0517..c971e1f 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3843,7 +3843,17 @@ "daysRemaining": "Дни", "promoGroup": "Группа", "spent": "Потрачено" - } + }, + "subscriptionsSelected": "{{count}} подписок выбрано", + "usersSelected": "{{count}} пользователей выбрано", + "expandSubscriptions": "Показать подписки", + "collapseSubscriptions": "Скрыть подписки", + "noSubscriptions": "Нет подписок", + "subscriptionTarget": "Цель: подписки", + "userTarget": "Цель: пользователи", + "daysUnit": "дн.", + "trafficOf": "из", + "trafficGbUnit": "ГБ" }, "theme": { "accentColor": "Акцентный цвет", diff --git a/src/pages/AdminBulkActions.tsx b/src/pages/AdminBulkActions.tsx index d3ed81d..0f2ceb1 100644 --- a/src/pages/AdminBulkActions.tsx +++ b/src/pages/AdminBulkActions.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; import { @@ -8,7 +8,7 @@ import { type ColumnDef, type RowSelectionState, } from '@tanstack/react-table'; -import { adminUsersApi, type UserListItem } from '../api/adminUsers'; +import { adminUsersApi, type UserListItem, type UserListItemSubscription } from '../api/adminUsers'; import { tariffsApi, type TariffListItem } from '../api/tariffs'; import { promocodesApi, type PromoGroup } from '../api/promocodes'; import { @@ -119,6 +119,160 @@ 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', +]); + +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')} + +
+
+
+
+
+ + + ); +} + // ============ Status badge ============ function StatusBadge({ status }: { status: string | null }) { @@ -757,13 +911,19 @@ function ActionModal({ // ============ Floating Action Bar ============ -function FloatingActionBar({ - selectedCount, - onAction, -}: { - selectedCount: number; +interface FloatingActionBarProps { + selectedUserCount: number; + selectedSubscriptionCount: number; + isMultiTariff: boolean; onAction: (type: BulkActionType) => void; -}) { +} + +function FloatingActionBar({ + selectedUserCount, + selectedSubscriptionCount, + isMultiTariff, + onAction, +}: FloatingActionBarProps) { const { t } = useTranslation(); const [open, setOpen] = useState(false); const menuRef = useRef(null); @@ -778,7 +938,8 @@ function FloatingActionBar({ return () => document.removeEventListener('mousedown', handler); }, []); - if (selectedCount === 0) return null; + const hasAnySelection = selectedUserCount > 0 || selectedSubscriptionCount > 0; + if (!hasAnySelection) return null; const actions: ActionConfig[] = [ { @@ -837,14 +998,31 @@ function FloatingActionBar({ ref={menuRef} className="relative flex w-full max-w-2xl items-center gap-3 rounded-2xl border border-dark-700/60 bg-dark-800/80 px-5 py-3 shadow-2xl backdrop-blur-xl" > - {/* Selection count */} + {/* Selection counts */}
-
- {selectedCount} -
- - {t('admin.bulkActions.selectedCount', { count: selectedCount })} - + {selectedUserCount > 0 && ( +
+
+ {selectedUserCount} +
+ + {t('admin.bulkActions.usersSelected', { count: selectedUserCount })} + +
+ )} + {isMultiTariff && selectedSubscriptionCount > 0 && ( +
+ {selectedUserCount > 0 &&
} +
+ {selectedSubscriptionCount} +
+ + {t('admin.bulkActions.subscriptionsSelected', { + count: selectedSubscriptionCount, + })} + +
+ )}
@@ -860,25 +1038,75 @@ function FloatingActionBar({ {open && ( -
- {actions.map((a) => ( - - ))} +
+ )} + {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 ( + + ); + })}
)}
@@ -917,6 +1145,8 @@ export default function AdminBulkActions() { // Selection const [rowSelection, setRowSelection] = useState({}); + const [subscriptionSelection, setSubscriptionSelection] = useState>({}); + const [expandedRows, setExpandedRows] = useState>({}); // Modal const [modal, setModal] = useState({ @@ -930,6 +1160,12 @@ export default function AdminBulkActions() { // 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 () => { @@ -979,13 +1215,19 @@ export default function AdminBulkActions() { // ---- Handlers ---- + const clearAllSelections = useCallback(() => { + setRowSelection({}); + setSubscriptionSelection({}); + setExpandedRows({}); + }, []); + const handleSearchChange = (value: string) => { setSearchInput(value); clearTimeout(searchTimerRef.current); searchTimerRef.current = setTimeout(() => { setOffset(0); setCommittedSearch(value); - setRowSelection({}); + clearAllSelections(); }, 400); }; @@ -999,29 +1241,29 @@ export default function AdminBulkActions() { clearTimeout(searchTimerRef.current); setOffset(0); setCommittedSearch(searchInput); - setRowSelection({}); + clearAllSelections(); }; const handleStatusFilterChange = (v: string) => { setStatusFilter(v as SubscriptionStatusFilter); setOffset(0); - setRowSelection({}); + clearAllSelections(); }; const handleTariffFilterChange = (v: string) => { setTariffFilter(v); setOffset(0); - setRowSelection({}); + clearAllSelections(); }; const handlePromoGroupFilterChange = (v: string) => { setPromoGroupFilter(v); setOffset(0); - setRowSelection({}); + clearAllSelections(); }; const handleRefresh = () => { - setRowSelection({}); + clearAllSelections(); loadUsers(); }; @@ -1032,14 +1274,38 @@ export default function AdminBulkActions() { .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 handleOpenAction = (type: BulkActionType) => { setModal({ open: true, action: type, loading: false, result: null, progress: null }); }; const handleExecuteAction = async (params: BulkActionParams) => { - if (!modal.action || selectedUserIds.length === 0) return; + if (!modal.action) return; - const totalCount = selectedUserIds.length; + 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, @@ -1052,55 +1318,52 @@ export default function AdminBulkActions() { }, })); + const requestPayload = isSubAction + ? { action: modal.action, subscription_ids: targetIds, params } + : { action: modal.action, user_ids: targetIds, params }; + try { - await adminBulkActionsApi.executeWithStream( - { - action: modal.action, - user_ids: selectedUserIds, - params, - }, - (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, + await adminBulkActionsApi.executeWithStream(requestPayload, (event) => { + if (event.type === 'progress') { + setModal((prev) => ({ + ...prev, + progress: prev.progress + ? { + current: event.current, total: event.total, - success_count: event.success_count, - error_count: event.error_count, - skipped_count: event.skipped_count || 0, - errors, - }, - }; - }); - loadUsers(); - } - }, - ); + 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) => { @@ -1149,7 +1412,7 @@ export default function AdminBulkActions() { const handleCloseModal = () => { if (modal.loading) return; if (modal.result) { - setRowSelection({}); + clearAllSelections(); } setModal({ open: false, action: null, loading: false, result: null, progress: null }); }; @@ -1215,13 +1478,43 @@ export default function AdminBulkActions() { size: 200, cell: ({ row }) => { const user = row.original; + const subCount = user.subscriptions?.length ?? 0; + const canExpand = subCount > 1; + const isExpanded = expandedRows[user.id] ?? false; return (
-
- {user.first_name?.[0] || user.username?.[0] || '?'} -
+ {canExpand ? ( + + ) : ( +
+ {user.first_name?.[0] || user.username?.[0] || '?'} +
+ )}
-
{user.full_name}
+
+ + {user.full_name} + + {canExpand && ( + + {subCount} + + )} +
{user.username ? `@${user.username}` : `ID: ${user.telegram_id}`}
@@ -1325,7 +1618,7 @@ export default function AdminBulkActions() { }, }, ], - [t, formatWithCurrency], + [t, formatWithCurrency, expandedRows, toggleExpandRow], ); const table = useReactTable({ @@ -1474,25 +1767,50 @@ export default function AdminBulkActions() { ))} - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - { + const user = row.original; + const subs = user.subscriptions ?? []; + const canExpand = subs.length > 1; + const isExpanded = expandedRows[user.id] ?? false; + + return ( + + {/* User row */} + - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - ))} + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + + {/* Subscription sub-rows (only when expanded and multi-sub) */} + {canExpand && + isExpanded && + subs.map((sub) => ( + toggleSubscriptionSelection(sub.id)} + isMultiTariff={isMultiTariff} + /> + ))} + + {/* Single subscription — auto-select with user (no separate sub-row) */} + + ); + })}
@@ -1509,7 +1827,7 @@ export default function AdminBulkActions() { + + {open && ( +
+ {/* Select all / Deselect all */} +
+ + / + +
+ + {/* Options */} + {options.map((option) => { + const isChecked = selected.includes(option.value); + return ( + + ); + })} +
+ )} +
+ ); +} + // ============ Progress View ============ function ProgressView({ progress }: { progress: ProgressState }) { @@ -912,14 +1068,18 @@ 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); @@ -1022,6 +1182,22 @@ function FloatingActionBar({ )}
+ {/* Toggle all subscriptions button */} + {isMultiTariff && totalVisibleSubscriptionCount > 0 && ( + <> +
+ + + )} +
{/* Actions dropdown */} @@ -1133,7 +1309,7 @@ export default function AdminBulkActions() { const [searchInput, setSearchInput] = useState(''); const [committedSearch, setCommittedSearch] = useState(''); const [statusFilter, setStatusFilter] = useState(''); - const [tariffFilter, setTariffFilter] = useState(''); + const [tariffFilter, setTariffFilter] = useState([]); const [promoGroupFilter, setPromoGroupFilter] = useState(''); // Pagination @@ -1174,7 +1350,7 @@ export default function AdminBulkActions() { }; if (committedSearch) params.search = committedSearch; if (statusFilter) params.subscription_status = statusFilter; - if (tariffFilter) params.tariff_id = Number(tariffFilter); + if (tariffFilter.length === 1) params.tariff_id = tariffFilter[0]; if (promoGroupFilter) params.promo_group_id = Number(promoGroupFilter); const data = await adminUsersApi.getUsers( @@ -1182,6 +1358,14 @@ export default function AdminBulkActions() { ); setUsers(data.users); setTotal(data.total); + // Auto-expand all users with multiple subscriptions + const autoExpand: Record = {}; + for (const u of data.users) { + if ((u.subscriptions?.length ?? 0) > 1) { + autoExpand[u.id] = true; + } + } + setExpandedRows(autoExpand); } catch { // keep stale data } finally { @@ -1247,8 +1431,8 @@ export default function AdminBulkActions() { clearAllSelections(); }; - const handleTariffFilterChange = (v: string) => { - setTariffFilter(v); + const handleTariffFilterChange = (ids: number[]) => { + setTariffFilter(ids); setOffset(0); clearAllSelections(); }; @@ -1291,6 +1475,64 @@ export default function AdminBulkActions() { })); }, []); + 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); + if (filtered.length > 1) { + 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 }); }; @@ -1475,7 +1717,8 @@ export default function AdminBulkActions() { size: 200, cell: ({ row }) => { const user = row.original; - const subCount = user.subscriptions?.length ?? 0; + const filteredSubs = getFilteredSubs(user.subscriptions ?? []); + const subCount = filteredSubs.length; const canExpand = subCount > 1; const isExpanded = expandedRows[user.id] ?? false; return ( @@ -1615,7 +1858,7 @@ export default function AdminBulkActions() { }, }, ], - [t, formatWithCurrency, expandedRows, toggleExpandRow], + [t, formatWithCurrency, expandedRows, toggleExpandRow, getFilteredSubs], ); const table = useReactTable({ @@ -1641,10 +1884,10 @@ export default function AdminBulkActions() { { value: 'disabled', label: t('admin.bulkActions.statuses.disabled') }, ]; - const tariffOptions: DropdownOption[] = [ - { value: '', label: t('admin.bulkActions.filters.allTariffs') }, - ...tariffs.map((tt) => ({ value: String(tt.id), label: tt.name })), - ]; + const tariffMultiOptions: MultiSelectOption[] = tariffs.map((tt) => ({ + value: tt.id, + label: tt.name, + })); const promoGroupOptions: DropdownOption[] = [ { value: '', label: t('admin.bulkActions.filters.allGroups') }, @@ -1712,10 +1955,11 @@ export default function AdminBulkActions() { options={statusOptions} onChange={handleStatusFilterChange} /> - {table.getRowModel().rows.map((row) => { const user = row.original; - const subs = user.subscriptions ?? []; - const canExpand = subs.length > 1; + const filteredSubs = getFilteredSubs(user.subscriptions ?? []); + const canExpand = filteredSubs.length > 1; const isExpanded = expandedRows[user.id] ?? false; return ( @@ -1794,7 +2038,7 @@ export default function AdminBulkActions() { {/* Subscription sub-rows (only when expanded and multi-sub) */} {canExpand && isExpanded && - subs.map((sub) => ( + filteredSubs.map((sub) => ( {/* Action modal */} From 5969c74f92a34d6f24ee005a4c1b9546bb4d660d Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 24 Apr 2026 06:36:04 +0300 Subject: [PATCH 12/35] feat: devices in subscription rows, set_devices action, table columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove "Days" column from main table (each subscription shows own) - Tariff column shows ALL subscription tariffs as comma-separated list - Subscription sub-rows show device count with phone icon - New bulk action "Изменить устройства" (set_devices) with input 1-50 - Update UserListItemSubscription type with device_limit field - i18n: setDevices + deviceLimit keys (ru + en) --- src/api/adminBulkActions.ts | 4 +- src/api/adminUsers.ts | 1 + src/locales/en.json | 6 +- src/locales/ru.json | 6 +- src/pages/AdminBulkActions.tsx | 116 ++++++++++++++++++++++++--------- 5 files changed, 97 insertions(+), 36 deletions(-) diff --git a/src/api/adminBulkActions.ts b/src/api/adminBulkActions.ts index f74e8af..69315ef 100644 --- a/src/api/adminBulkActions.ts +++ b/src/api/adminBulkActions.ts @@ -10,7 +10,8 @@ export type BulkActionType = | 'add_traffic' | 'add_balance' | 'assign_promo_group' - | 'grant_subscription'; + | 'grant_subscription' + | 'set_devices'; export interface BulkActionRequest { action: BulkActionType; @@ -27,6 +28,7 @@ export interface BulkActionParams { amount_kopeks?: number; balance_description?: string; promo_group_id?: number | null; + device_limit?: number; } export interface BulkActionErrorItem { diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index fc223ad..96737d8 100644 --- a/src/api/adminUsers.ts +++ b/src/api/adminUsers.ts @@ -42,6 +42,7 @@ export interface UserListItemSubscription { days_remaining: number; traffic_used_gb: number; traffic_limit_gb: number; + device_limit: number; } export interface UserListItem { diff --git a/src/locales/en.json b/src/locales/en.json index 94f67b4..aaef068 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2039,7 +2039,8 @@ "addTraffic": "Add traffic", "addBalance": "Add balance", "assignPromoGroup": "Assign promo group", - "grantSubscription": "Grant subscription" + "grantSubscription": "Grant subscription", + "setDevices": "Set devices" }, "params": { "days": "Number of days", @@ -2047,7 +2048,8 @@ "trafficGb": "Traffic amount (GB)", "balanceRub": "Amount (RUB)", "promoGroup": "Promo group", - "removePromoGroup": "Remove promo group" + "removePromoGroup": "Remove promo group", + "deviceLimit": "Device count" }, "grantSubscription": { "warning": "Users who already have a subscription on the selected tariff will be skipped" diff --git a/src/locales/ru.json b/src/locales/ru.json index 791275f..100f22b 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3787,7 +3787,8 @@ "addTraffic": "Начислить трафик", "addBalance": "Начислить баланс", "assignPromoGroup": "Назначить промогруппу", - "grantSubscription": "Выдать подписку" + "grantSubscription": "Выдать подписку", + "setDevices": "Изменить устройства" }, "params": { "days": "Количество дней", @@ -3795,7 +3796,8 @@ "trafficGb": "Объём трафика (ГБ)", "balanceRub": "Сумма (руб.)", "promoGroup": "Промогруппа", - "removePromoGroup": "Убрать промогруппу" + "removePromoGroup": "Убрать промогруппу", + "deviceLimit": "Кол-во устройств" }, "grantSubscription": { "warning": "Пользователи, у которых уже есть подписка на выбранный тариф, будут пропущены" diff --git a/src/pages/AdminBulkActions.tsx b/src/pages/AdminBulkActions.tsx index 9efbd3f..add861b 100644 --- a/src/pages/AdminBulkActions.tsx +++ b/src/pages/AdminBulkActions.tsx @@ -140,6 +140,7 @@ const SUBSCRIPTION_LEVEL_ACTIONS: Set = new Set([ 'activate_subscription', 'change_tariff', 'add_traffic', + 'set_devices', ]); function isSubscriptionLevelAction(action: BulkActionType): boolean { @@ -264,6 +265,24 @@ function SubscriptionSubRow({ />
+ + {/* Devices */} + + + + + {subscription.device_limit} +
@@ -738,6 +757,7 @@ function ActionModal({ 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); useEffect(() => { if (tariffs.length > 0 && tariffId === 0) { @@ -778,6 +798,7 @@ function ActionModal({ 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', }; const handleSubmit = () => { @@ -802,6 +823,9 @@ function ActionModal({ params.tariff_id = grantTariffId; params.days = grantDays; break; + case 'set_devices': + params.device_limit = deviceLimit; + break; } onExecute(params); }; @@ -891,6 +915,22 @@ function ActionModal({ />
); + 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 (
@@ -1129,6 +1169,26 @@ function FloatingActionBar({ 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: 'add_balance', labelKey: 'admin.bulkActions.actions.addBalance', @@ -1780,13 +1840,34 @@ export default function AdminBulkActions() { id: 'tariff', accessorFn: (row) => row.tariff_name, header: t('admin.bulkActions.columns.tariff'), - size: 120, + size: 160, cell: ({ row }) => { const user = row.original; - if (!user.tariff_name) { - return ; + 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 {user.tariff_name}; + + return ( +
+ {uniqueNames.map((name) => ( + + {name} + + ))} +
+ ); }, }, { @@ -1800,33 +1881,6 @@ export default function AdminBulkActions() { ), }, - { - id: 'days_remaining', - header: t('admin.bulkActions.columns.daysRemaining'), - size: 80, - cell: ({ row }) => { - const user = row.original; - if (!user.subscription_end_date) { - return -; - } - const end = new Date(user.subscription_end_date); - const now = new Date(); - const days = Math.max( - 0, - Math.ceil((end.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)), - ); - return ( - - {days} - - ); - }, - }, { id: 'promo_group', accessorFn: (row) => row.promo_group_name, From c726d3d74e5990b7ad05d2453795000aedde7c78 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 24 Apr 2026 06:51:21 +0300 Subject: [PATCH 13/35] feat: bulk delete subscription + fix sub-rows showing for filtered single subs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add "Удалить подписки" action with red trash icon, destructive warning dialog (red border, irreversible action notice) - Fix canExpand: show sub-rows when isMultiTariff && subs > 0 (was > 1, hiding single filtered subscription results) - i18n: deleteSubscription action + warning/hint (ru + en) --- src/api/adminBulkActions.ts | 3 ++- src/locales/en.json | 7 ++++++- src/locales/ru.json | 7 ++++++- src/pages/AdminBulkActions.tsx | 37 ++++++++++++++++++++++++++++++++-- 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/src/api/adminBulkActions.ts b/src/api/adminBulkActions.ts index 69315ef..1088dee 100644 --- a/src/api/adminBulkActions.ts +++ b/src/api/adminBulkActions.ts @@ -11,7 +11,8 @@ export type BulkActionType = | 'add_balance' | 'assign_promo_group' | 'grant_subscription' - | 'set_devices'; + | 'set_devices' + | 'delete_subscription'; export interface BulkActionRequest { action: BulkActionType; diff --git a/src/locales/en.json b/src/locales/en.json index aaef068..fa92050 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2040,7 +2040,12 @@ "addBalance": "Add balance", "assignPromoGroup": "Assign promo group", "grantSubscription": "Grant subscription", - "setDevices": "Set devices" + "setDevices": "Set devices", + "deleteSubscription": "Delete subscriptions" + }, + "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." }, "params": { "days": "Number of days", diff --git a/src/locales/ru.json b/src/locales/ru.json index 100f22b..dedf504 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3788,7 +3788,12 @@ "addBalance": "Начислить баланс", "assignPromoGroup": "Назначить промогруппу", "grantSubscription": "Выдать подписку", - "setDevices": "Изменить устройства" + "setDevices": "Изменить устройства", + "deleteSubscription": "Удалить подписки" + }, + "deleteSubscription": { + "warning": "Выбранные подписки будут безвозвратно удалены из бота и панели RemnaWave!", + "hint": "Пользователи потеряют доступ к VPN по удалённым подпискам. Это действие нельзя отменить." }, "params": { "days": "Количество дней", diff --git a/src/pages/AdminBulkActions.tsx b/src/pages/AdminBulkActions.tsx index add861b..3f2662b 100644 --- a/src/pages/AdminBulkActions.tsx +++ b/src/pages/AdminBulkActions.tsx @@ -141,6 +141,7 @@ const SUBSCRIPTION_LEVEL_ACTIONS: Set = new Set([ 'change_tariff', 'add_traffic', 'set_devices', + 'delete_subscription', ]); function isSubscriptionLevelAction(action: BulkActionType): boolean { @@ -799,6 +800,7 @@ function ActionModal({ 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', }; const handleSubmit = () => { @@ -965,6 +967,17 @@ function ActionModal({
); + case 'delete_subscription': + return ( +
+

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

+

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

+
+ ); default: return null; } @@ -1189,6 +1202,26 @@ function FloatingActionBar({ ), 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', @@ -1779,7 +1812,7 @@ export default function AdminBulkActions() { const user = row.original; const filteredSubs = getFilteredSubs(user.subscriptions ?? []); const subCount = filteredSubs.length; - const canExpand = subCount > 1; + const canExpand = isMultiTariff && subCount > 0; const isExpanded = expandedRows[user.id] ?? false; return (
@@ -2065,7 +2098,7 @@ export default function AdminBulkActions() { {table.getRowModel().rows.map((row) => { const user = row.original; const filteredSubs = getFilteredSubs(user.subscriptions ?? []); - const canExpand = filteredSubs.length > 1; + const canExpand = isMultiTariff && filteredSubs.length > 0; const isExpanded = expandedRows[user.id] ?? false; return ( From 3cb15177ccaab7c6451b42f80363694691464fe6 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 24 Apr 2026 06:59:55 +0300 Subject: [PATCH 14/35] fix: show sub-rows for single-subscription users + fix floating bar position - Auto-expand ALL users with subscriptions (was only > 1), so users with single subscription also show their sub-row for selection - FloatingActionBar: render via createPortal to document.body to ensure fixed positioning works correctly regardless of parent overflow/transform contexts (was trapped inside scrollable area) --- src/pages/AdminBulkActions.tsx | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/pages/AdminBulkActions.tsx b/src/pages/AdminBulkActions.tsx index 3f2662b..1a5bdba 100644 --- a/src/pages/AdminBulkActions.tsx +++ b/src/pages/AdminBulkActions.tsx @@ -1,4 +1,5 @@ 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 { @@ -1451,10 +1452,10 @@ export default function AdminBulkActions() { ); setUsers(data.users); setTotal(data.total); - // Auto-expand all users with multiple subscriptions + // Auto-expand all users who have subscriptions const autoExpand: Record = {}; for (const u of data.users) { - if ((u.subscriptions?.length ?? 0) > 1) { + if ((u.subscriptions?.length ?? 0) > 0) { autoExpand[u.id] = true; } } @@ -2181,15 +2182,18 @@ export default function AdminBulkActions() {
)} - {/* Floating action bar */} - + {/* Floating action bar — portal to body for correct fixed positioning */} + {createPortal( + , + document.body, + )} {/* Action modal */} Date: Fri, 24 Apr 2026 07:03:53 +0300 Subject: [PATCH 15/35] feat: second header checkbox for bulk subscription selection Add green checkbox in table header next to the user checkbox for mass-selecting all visible subscriptions. Shows filled/partial/empty states matching user checkbox pattern. Only visible in multi-tariff mode. Wider select column (56px) to fit both checkboxes. --- src/pages/AdminBulkActions.tsx | 70 ++++++++++++++++++++++++---------- 1 file changed, 49 insertions(+), 21 deletions(-) diff --git a/src/pages/AdminBulkActions.tsx b/src/pages/AdminBulkActions.tsx index 1a5bdba..3417d39 100644 --- a/src/pages/AdminBulkActions.tsx +++ b/src/pages/AdminBulkActions.tsx @@ -1756,28 +1756,56 @@ export default function AdminBulkActions() { () => [ { id: 'select', - size: 40, - header: ({ table }) => ( -
- + {/* Select all subscriptions */} + {isMultiTariff && allVisibleSubscriptionIds.length > 0 && ( + )} - aria-label={t('admin.bulkActions.selectAll')} - > - {table.getIsAllRowsSelected() && } - {table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected() && ( -
- )} - -
- ), +
+ ); + }, cell: ({ row }) => { const userName = row.original.full_name || row.original.username || String(row.original.telegram_id); From c32bcfeb9e998408f20b72ba024195826af5f1da Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 24 Apr 2026 07:04:44 +0300 Subject: [PATCH 16/35] =?UTF-8?q?fix:=20floating=20action=20bar=20hidden?= =?UTF-8?q?=20behind=20mobile=20bottom=20nav=20=E2=80=94=20raise=20z-index?= =?UTF-8?q?=20+=20bottom=20padding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/AdminBulkActions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/AdminBulkActions.tsx b/src/pages/AdminBulkActions.tsx index 3417d39..c1b1530 100644 --- a/src/pages/AdminBulkActions.tsx +++ b/src/pages/AdminBulkActions.tsx @@ -1244,7 +1244,7 @@ function FloatingActionBar({ ]; return ( -
+
Date: Fri, 24 Apr 2026 07:08:40 +0300 Subject: [PATCH 17/35] fix: multi-tariff filter shows all users when >1 tariffs selected Server only supports single tariff_id filter. When multiple tariffs are selected, apply client-side filtering to hide users who have no subscriptions matching any of the selected tariffs. --- src/pages/AdminBulkActions.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/pages/AdminBulkActions.tsx b/src/pages/AdminBulkActions.tsx index c1b1530..5daaaff 100644 --- a/src/pages/AdminBulkActions.tsx +++ b/src/pages/AdminBulkActions.tsx @@ -1977,8 +1977,18 @@ export default function AdminBulkActions() { [t, formatWithCurrency, expandedRows, toggleExpandRow, getFilteredSubs], ); + // When multiple tariffs are selected, filter users client-side + // (server only supports single tariff_id filter) + const filteredUsers = useMemo(() => { + if (tariffFilter.length <= 1) return users; + return users.filter((u) => { + const subs = u.subscriptions ?? []; + return subs.some((s) => s.tariff_id !== null && tariffFilter.includes(s.tariff_id)); + }); + }, [users, tariffFilter]); + const table = useReactTable({ - data: users, + data: filteredUsers, columns, state: { rowSelection }, onRowSelectionChange: setRowSelection, From 98871af6565adc51fa6892da3755704f9dd2c934 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 24 Apr 2026 07:16:53 +0300 Subject: [PATCH 18/35] fix: multi-tariff filter now works server-side + trial-only checkbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Send comma-separated tariff_ids to backend (was only single ID) — server now filters with IN() operator, pagination correct - Remove client-side tariff filtering (server handles it) - Add "Только триал" checkbox filter: amber checkbox, filters users with trial subscriptions client-side - Remove "trial" from status dropdown (was not working via subscription_status server filter) - i18n: trialOnly key (ru + en) --- src/locales/en.json | 1 + src/locales/ru.json | 1 + src/pages/AdminBulkActions.tsx | 48 +++++++++++++++++++++++++++------- 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index fa92050..0df84ac 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2086,6 +2086,7 @@ "allStatuses": "All statuses", "allTariffs": "All tariffs", "allGroups": "All groups", + "trialOnly": "Trial only", "tariffsSelected": "{{count}} tariffs", "selectAll": "Select all", "deselectAll": "Deselect all" diff --git a/src/locales/ru.json b/src/locales/ru.json index dedf504..0e92425 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3834,6 +3834,7 @@ "allStatuses": "Все статусы", "allTariffs": "Все тарифы", "allGroups": "Все группы", + "trialOnly": "Только триал", "tariffsSelected": "{{count}} тарифов", "selectAll": "Выбрать все", "deselectAll": "Снять все" diff --git a/src/pages/AdminBulkActions.tsx b/src/pages/AdminBulkActions.tsx index 5daaaff..402cf83 100644 --- a/src/pages/AdminBulkActions.tsx +++ b/src/pages/AdminBulkActions.tsx @@ -1403,6 +1403,7 @@ export default function AdminBulkActions() { 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(''); @@ -1444,7 +1445,7 @@ export default function AdminBulkActions() { }; if (committedSearch) params.search = committedSearch; if (statusFilter) params.subscription_status = statusFilter; - if (tariffFilter.length === 1) params.tariff_id = tariffFilter[0]; + if (tariffFilter.length > 0) params.tariff_id = tariffFilter.join(','); if (promoGroupFilter) params.promo_group_id = Number(promoGroupFilter); const data = await adminUsersApi.getUsers( @@ -1980,12 +1981,17 @@ export default function AdminBulkActions() { // When multiple tariffs are selected, filter users client-side // (server only supports single tariff_id filter) const filteredUsers = useMemo(() => { - if (tariffFilter.length <= 1) return users; - return users.filter((u) => { - const subs = u.subscriptions ?? []; - return subs.some((s) => s.tariff_id !== null && tariffFilter.includes(s.tariff_id)); - }); - }, [users, tariffFilter]); + 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, @@ -2005,7 +2011,6 @@ export default function AdminBulkActions() { { value: '', label: t('admin.bulkActions.filters.allStatuses') }, { value: 'active', label: t('admin.bulkActions.statuses.active') }, { value: 'expired', label: t('admin.bulkActions.statuses.expired') }, - { value: 'trial', label: t('admin.bulkActions.statuses.trial') }, { value: 'limited', label: t('admin.bulkActions.statuses.limited') }, { value: 'disabled', label: t('admin.bulkActions.statuses.disabled') }, ]; @@ -2074,7 +2079,7 @@ export default function AdminBulkActions() {
- {/* Filter dropdowns */} + {/* Filter dropdowns + trial toggle */}
+ + {/* Trial filter checkbox */} +
{/* Table */} From e2706c7c265b16378fa0919cebc9f75648858eed Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 24 Apr 2026 07:23:58 +0300 Subject: [PATCH 19/35] fix: select-all subscriptions + per-page selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix allVisibleSubscriptionIds: include ALL users' filtered subs (was only users with >1 subs — single-sub users were excluded) - Add per-page selector (25/50/100/200) in pagination bar - Make limit a state variable instead of constant - perPage i18n keys (ru + en) --- src/locales/en.json | 1 + src/locales/ru.json | 1 + src/pages/AdminBulkActions.tsx | 42 ++++++++++++++++++++++++---------- 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index 0df84ac..cd5f6de 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2031,6 +2031,7 @@ "selectUser": "Select user {{name}}", "deselectUser": "Deselect user {{name}}", "noResults": "No users found", + "perPage": "Per page", "actions": { "extend": "Extend subscription", "cancel": "Deactivate subscriptions", diff --git a/src/locales/ru.json b/src/locales/ru.json index 0e92425..5c761d8 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3779,6 +3779,7 @@ "selectUser": "Выбрать пользователя {{name}}", "deselectUser": "Снять выбор пользователя {{name}}", "noResults": "Пользователи не найдены", + "perPage": "На странице", "actions": { "extend": "Продлить подписку", "cancel": "Деактивировать подписки", diff --git a/src/pages/AdminBulkActions.tsx b/src/pages/AdminBulkActions.tsx index 402cf83..ec37f92 100644 --- a/src/pages/AdminBulkActions.tsx +++ b/src/pages/AdminBulkActions.tsx @@ -1409,7 +1409,7 @@ export default function AdminBulkActions() { // Pagination const [offset, setOffset] = useState(0); - const limit = 50; + const [limit, setLimit] = useState(50); // Selection const [rowSelection, setRowSelection] = useState({}); @@ -1466,7 +1466,7 @@ export default function AdminBulkActions() { } finally { setLoading(false); } - }, [offset, committedSearch, statusFilter, tariffFilter, promoGroupFilter]); + }, [offset, limit, committedSearch, statusFilter, tariffFilter, promoGroupFilter]); useEffect(() => { loadUsers(); @@ -1583,10 +1583,8 @@ export default function AdminBulkActions() { for (const user of users) { const subs = user.subscriptions ?? []; const filtered = getFilteredSubs(subs); - if (filtered.length > 1) { - for (const sub of filtered) { - ids.push(sub.id); - } + for (const sub of filtered) { + ids.push(sub.id); } } return ids; @@ -2214,12 +2212,32 @@ export default function AdminBulkActions() {
)} - {/* Pagination */} - {totalPages > 1 && ( -
-
+ {/* Pagination + per-page selector */} +
+
+ {offset + 1}–{Math.min(offset + limit, total)} / {total} + +
+ {t('admin.bulkActions.perPage')} +
+
+ {totalPages > 1 && (
-
- )} + )} +
{/* Floating action bar — portal to body for correct fixed positioning */} {createPortal( From cb148d181a55b625c3fa09470b475b0db8f98615 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 24 Apr 2026 07:29:02 +0300 Subject: [PATCH 20/35] fix: persist checkbox selection across page changes and filter switches Selection (both user and subscription checkboxes) now persists when: - Switching pages (prev/next) - Changing filters (status, tariff, promo group, trial) - Changing per-page limit - Searching - Refreshing data Selection is only cleared after a successful bulk action execution (modal close with result). --- src/pages/AdminBulkActions.tsx | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/pages/AdminBulkActions.tsx b/src/pages/AdminBulkActions.tsx index ec37f92..a7f5887 100644 --- a/src/pages/AdminBulkActions.tsx +++ b/src/pages/AdminBulkActions.tsx @@ -1503,7 +1503,6 @@ export default function AdminBulkActions() { searchTimerRef.current = setTimeout(() => { setOffset(0); setCommittedSearch(value); - clearAllSelections(); }, 400); }; @@ -1517,29 +1516,24 @@ export default function AdminBulkActions() { clearTimeout(searchTimerRef.current); setOffset(0); setCommittedSearch(searchInput); - clearAllSelections(); }; const handleStatusFilterChange = (v: string) => { setStatusFilter(v as SubscriptionStatusFilter); setOffset(0); - clearAllSelections(); }; const handleTariffFilterChange = (ids: number[]) => { setTariffFilter(ids); setOffset(0); - clearAllSelections(); }; const handlePromoGroupFilterChange = (v: string) => { setPromoGroupFilter(v); setOffset(0); - clearAllSelections(); }; const handleRefresh = () => { - clearAllSelections(); loadUsers(); }; @@ -2103,7 +2097,6 @@ export default function AdminBulkActions() { onClick={() => { setTrialOnly((prev) => !prev); setOffset(0); - clearAllSelections(); }} className={cn( 'flex h-5 w-5 items-center justify-center rounded-md border-2 transition-all duration-150', @@ -2225,7 +2218,6 @@ export default function AdminBulkActions() { onChange={(e) => { setLimit(Number(e.target.value)); setOffset(0); - clearAllSelections(); }} className="rounded-lg border border-dark-700 bg-dark-800 px-2 py-1.5 text-xs text-dark-200 outline-none transition-colors focus:border-accent-500/40" > @@ -2242,7 +2234,6 @@ export default function AdminBulkActions() {
+ {/* 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( Date: Fri, 24 Apr 2026 08:11:20 +0300 Subject: [PATCH 22/35] =?UTF-8?q?feat:=20information=20pages=20=E2=80=94?= =?UTF-8?q?=20admin=20editor=20with=20TipTap,=20public=20viewer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin panel: - New section "Информационные страницы" in admin nav - Admin list: slug, title, active toggle, edit/delete, sort order - Admin editor: full TipTap (bold, italic, headings, lists, links, image/video upload, alignment, highlight, code blocks) with locale tabs (RU/EN/ZH/FA) for title and content - Create/edit via URL params, slug auto-generation Public: - /info/:slug route with DOMPurify sanitized content - Locale fallback, Telegram safe area, loading skeleton, 404 i18n: all 4 locales --- src/App.tsx | 47 ++ src/api/infoPages.ts | 96 ++++ src/locales/en.json | 34 +- src/locales/fa.json | 34 +- src/locales/ru.json | 34 +- src/locales/zh.json | 34 +- src/pages/AdminInfoPageEditor.tsx | 833 ++++++++++++++++++++++++++++++ src/pages/AdminInfoPages.tsx | 346 +++++++++++++ src/pages/AdminPanel.tsx | 15 + src/pages/InfoPageView.tsx | 279 ++++++++++ 10 files changed, 1748 insertions(+), 4 deletions(-) create mode 100644 src/api/infoPages.ts create mode 100644 src/pages/AdminInfoPageEditor.tsx create mode 100644 src/pages/AdminInfoPages.tsx create mode 100644 src/pages/InfoPageView.tsx diff --git a/src/App.tsx b/src/App.tsx index 0c8423c..096c55c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -148,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, @@ -555,6 +560,16 @@ function App() { } /> + + + + + + } + /> {/* Admin routes */} + {/* Info pages admin routes */} + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + ; + content: Record; + is_active: boolean; + sort_order: number; + icon: string | null; + created_at: string; + updated_at: string | null; +} + +export interface InfoPageListItem { + id: number; + slug: string; + title: Record; + is_active: boolean; + sort_order: number; + icon: string | null; + updated_at: string | null; +} + +export interface InfoPageCreateRequest { + slug: string; + title: Record; + content: Record; + is_active: boolean; + sort_order: number; + icon: string | null; +} + +export interface InfoPageUpdateRequest { + slug?: string; + title?: Record; + content?: Record; + is_active?: boolean; + sort_order?: number; + icon?: string | null; +} + +export interface InfoPageReorderRequest { + items: Array<{ id: number; sort_order: number }>; +} + +export const infoPagesApi = { + // Public endpoints + getPages: async (): Promise => { + const response = await apiClient.get('/cabinet/info-pages'); + 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 (): Promise => { + const response = await apiClient.get('/cabinet/admin/info-pages'); + 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 cd5f6de..a5eda77 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1126,7 +1126,8 @@ "landings": "Landings", "referralNetwork": "Referral Network", "news": "News", - "bulkActions": "Bulk Actions" + "bulkActions": "Bulk Actions", + "infoPages": "Info Pages" }, "panel": { "title": "Admin Panel", @@ -3798,6 +3799,37 @@ "prev": "Previous", "next": "Next" } + }, + "infoPages": { + "title": "Information Pages", + "subtitle": "Manage static information pages", + "create": "Create Page", + "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", + "localeLabel": "Content Language", + "fields": { + "slug": "URL Slug", + "title": "Title", + "content": "Content", + "isActive": "Active", + "sortOrder": "Sort Order", + "icon": "Icon (emoji)" + }, + "locales": { + "ru": "Russian", + "en": "English", + "zh": "Chinese", + "fa": "Persian" + } } }, "adminUpdates": { diff --git a/src/locales/fa.json b/src/locales/fa.json index fbe1cde..a570208 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,37 @@ "periodDaySuffix": "روز", "localeTab": "زبان", "localeHint": "زبان را تغییر دهید تا متن را به آن زبان ویرایش کنید" + }, + "infoPages": { + "title": "صفحات اطلاعات", + "subtitle": "مدیریت صفحات اطلاعات ثابت", + "create": "ایجاد صفحه", + "edit": "ویرایش", + "save": "ذخیره", + "saving": "در حال ذخیره...", + "saved": "صفحه ذخیره شد", + "delete": "حذف", + "confirmDelete": "این صفحه حذف شود؟", + "saveError": "ذخیره صفحه ناموفق بود. لطفاً دوباره تلاش کنید.", + "noPages": "هنوز صفحه اطلاعاتی وجود ندارد", + "notFound": "صفحه پیدا نشد", + "active": "فعال", + "inactive": "غیرفعال", + "localeLabel": "زبان محتوا", + "fields": { + "slug": "شناسه URL", + "title": "عنوان", + "content": "محتوا", + "isActive": "فعال", + "sortOrder": "ترتیب", + "icon": "آیکون (ایموجی)" + }, + "locales": { + "ru": "روسی", + "en": "انگلیسی", + "zh": "چینی", + "fa": "فارسی" + } } }, "adminUpdates": { diff --git a/src/locales/ru.json b/src/locales/ru.json index 5c761d8..9f4def2 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1147,7 +1147,8 @@ "landings": "Лендинги", "referralNetwork": "Реферальная сеть", "news": "Новости", - "bulkActions": "Массовые действия" + "bulkActions": "Массовые действия", + "infoPages": "Инфо-страницы" }, "panel": { "title": "Панель администратора", @@ -4342,6 +4343,37 @@ "prev": "Назад", "next": "Далее" } + }, + "infoPages": { + "title": "Информационные страницы", + "subtitle": "Управление статическими информационными страницами", + "create": "Создать страницу", + "edit": "Редактировать", + "save": "Сохранить", + "saving": "Сохранение...", + "saved": "Страница сохранена", + "delete": "Удалить", + "confirmDelete": "Удалить эту страницу?", + "saveError": "Не удалось сохранить страницу. Попробуйте ещё раз.", + "noPages": "Информационных страниц пока нет", + "notFound": "Страница не найдена", + "active": "Активна", + "inactive": "Неактивна", + "localeLabel": "Язык контента", + "fields": { + "slug": "URL-адрес", + "title": "Заголовок", + "content": "Содержание", + "isActive": "Активна", + "sortOrder": "Порядок сортировки", + "icon": "Иконка (эмодзи)" + }, + "locales": { + "ru": "Русский", + "en": "Английский", + "zh": "Китайский", + "fa": "Персидский" + } } }, "adminUpdates": { diff --git a/src/locales/zh.json b/src/locales/zh.json index 5d775b2..0eb5726 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,37 @@ "periodDaySuffix": "天", "localeTab": "语言", "localeHint": "切换语言以编辑该语言的文本" + }, + "infoPages": { + "title": "信息页面", + "subtitle": "管理静态信息页面", + "create": "创建页面", + "edit": "编辑", + "save": "保存", + "saving": "保存中...", + "saved": "页面已保存", + "delete": "删除", + "confirmDelete": "确定删除此页面?", + "saveError": "保存页面失败,请重试。", + "noPages": "暂无信息页面", + "notFound": "页面未找到", + "active": "已激活", + "inactive": "未激活", + "localeLabel": "内容语言", + "fields": { + "slug": "URL标识", + "title": "标题", + "content": "内容", + "isActive": "已激活", + "sortOrder": "排序", + "icon": "图标(表情)" + }, + "locales": { + "ru": "俄语", + "en": "英语", + "zh": "中文", + "fa": "波斯语" + } } }, "adminUpdates": { diff --git a/src/pages/AdminInfoPageEditor.tsx b/src/pages/AdminInfoPageEditor.tsx new file mode 100644 index 0000000..c4e7281 --- /dev/null +++ b/src/pages/AdminInfoPageEditor.tsx @@ -0,0 +1,833 @@ +import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; +import { useNavigate, useParams } 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'; + +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); +} + +export default function AdminInfoPageEditor() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { id: rawId } = useParams<{ id: string }>(); + const queryClient = useQueryClient(); + const haptic = useHapticFeedback(); + const pageId = rawId != null ? Number(rawId) : undefined; + const isEdit = pageId != null && !Number.isNaN(pageId); + + // 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 [saveError, setSaveError] = useState(null); + + // 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 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 + const editorPopulated = useRef(false); + useEffect(() => { + if (!pageData) return; + setSlug(pageData.slug); + setSlugManuallyEdited(true); + setIcon(pageData.icon ?? ''); + setIsActive(pageData.is_active); + setSortOrder(pageData.sort_order); + setTitles(pageData.title); + setContents(pageData.content); + if (editor && pageData.content[activeLocale] && !editorPopulated.current) { + editor.commands.setContent(pageData.content[activeLocale] ?? ''); + editorPopulated.current = true; + } + }, [pageData, editor, activeLocale]); + + // 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; + is_active: boolean; + sort_order: number; + icon: string | 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; + + // Capture current editor content for the active locale + const currentHtml = editor?.getHTML() ?? ''; + const isEmpty = currentHtml === '

' || currentHtml === ''; + const finalContents = { + ...contents, + [activeLocale]: isEmpty ? '' : currentHtml, + }; + + const data = { + slug: slug.trim(), + title: titles, + content: finalContents, + is_active: isActive, + sort_order: sortOrder, + icon: icon.trim() || null, + }; + + 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')} +
+ + {/* Locale tabs */} +
+ +
+ {AVAILABLE_LOCALES.map((loc) => ( + + ))} +
+
+ + {/* Title for current locale */} +
+ + setTitles((prev) => ({ ...prev, [activeLocale]: e.target.value }))} + className="input" + /> +
+ + {/* Content editor */} +
+ +
+ {/* 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..bc8b1d9 --- /dev/null +++ b/src/pages/AdminInfoPages.tsx @@ -0,0 +1,346 @@ +import { useCallback, 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 type { InfoPageListItem } from '../api/infoPages'; + +// 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.is_active ? t('admin.infoPages.active') : t('admin.infoPages.inactive')} + + #{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 { + data: pages, + isLoading, + refetch, + } = useQuery({ + queryKey: ['admin', 'info-pages', 'list'], + queryFn: () => infoPagesApi.getAdminPages(), + 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} + + )} +
+
+
+ + +
+
+ + {/* 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 94470e4..24db4c2 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -345,6 +345,15 @@ const icons = { ), + 'file-text': ( + + + + + + + + ), chevron: ( @@ -561,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/InfoPageView.tsx b/src/pages/InfoPageView.tsx new file mode 100644 index 0000000..a0bc401 --- /dev/null +++ b/src/pages/InfoPageView.tsx @@ -0,0 +1,279 @@ +import { useEffect, useMemo, useRef } 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'; + +// 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); +} + +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]); + + // Content is sanitized with DOMPurify before rendering + const sanitizedContent = useMemo(() => { + if (!page) return ''; + const rawContent = page.content[locale] || page.content['ru'] || page.content['en'] || ''; + return sanitizeHtml(rawContent); + }, [page, locale]); + + 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 - sanitized with DOMPurify (strict allowlist) */} +
+
+ ); +} From 3ef54adb3eb8b3e50629805ed7ef9b53b10f8976 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 24 Apr 2026 08:22:16 +0300 Subject: [PATCH 23/35] =?UTF-8?q?fix:=20info=20page=20editor=20=E2=80=94?= =?UTF-8?q?=20locale=20switch=20no=20longer=20resets=20user=20edits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the form population effect into two: 1. Form fields (slug, icon, active, titles, contents) — populated once when pageData loads, never re-run on locale switch 2. Editor content — set once when editor instance is ready Previously activeLocale was in the deps array, causing every tab switch to overwrite all in-memory edits with original server data. --- src/pages/AdminInfoPageEditor.tsx | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/pages/AdminInfoPageEditor.tsx b/src/pages/AdminInfoPageEditor.tsx index c4e7281..7be6149 100644 --- a/src/pages/AdminInfoPageEditor.tsx +++ b/src/pages/AdminInfoPageEditor.tsx @@ -411,10 +411,11 @@ export default function AdminInfoPageEditor() { refetchOnWindowFocus: false, }); - // Populate form when page data loads + // Populate form when page data loads (once only — not on locale switch) const editorPopulated = useRef(false); + const formPopulated = useRef(false); useEffect(() => { - if (!pageData) return; + if (!pageData || formPopulated.current) return; setSlug(pageData.slug); setSlugManuallyEdited(true); setIcon(pageData.icon ?? ''); @@ -422,11 +423,16 @@ export default function AdminInfoPageEditor() { setSortOrder(pageData.sort_order); setTitles(pageData.title); setContents(pageData.content); - if (editor && pageData.content[activeLocale] && !editorPopulated.current) { - editor.commands.setContent(pageData.content[activeLocale] ?? ''); - editorPopulated.current = true; - } - }, [pageData, editor, activeLocale]); + 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(() => { From 596f638cdd2d4fe69105685c6a65db871aef946d Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 24 Apr 2026 13:51:08 +0300 Subject: [PATCH 24/35] =?UTF-8?q?fix:=20prose=20content=20overflow=20on=20?= =?UTF-8?q?mobile=20=E2=80=94=20add=20overflow-x-auto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/InfoPageView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/InfoPageView.tsx b/src/pages/InfoPageView.tsx index a0bc401..2d024e6 100644 --- a/src/pages/InfoPageView.tsx +++ b/src/pages/InfoPageView.tsx @@ -271,7 +271,7 @@ export default function InfoPageView() { {/* Page content - sanitized with DOMPurify (strict allowlist) */}
From 1ee0f1834315195fca050bcc4e00ec988b703ffb Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 24 Apr 2026 14:02:03 +0300 Subject: [PATCH 25/35] =?UTF-8?q?feat:=20FAQ=20pages=20=E2=80=94=20Q&A=20b?= =?UTF-8?q?uilder=20in=20admin,=20accordion=20view=20for=20users?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin editor: - page_type selector: "Страница" / "FAQ" radio buttons - When FAQ: FaqBuilder component replaces TipTap editor - Question input + answer textarea per Q&A item - Add/remove items, up/down reorder buttons - Per-locale Q&A (stored as JSON array in content JSONB) - "Создать FAQ" button in list alongside "Создать страницу" - Filter tabs: Все / Страницы / FAQ - Page type badge on each row (accent for pages, amber for FAQ) Public view (InfoPageView): - Auto-detects page_type='faq' and renders FaqView component - Accordion with smooth height animation (300ms ease-in-out) - Search filter appears when >3 questions - DOMPurify sanitization on FAQ answers - Single-item expand (click to open/close) i18n: full FAQ keys for all 4 locales (questions, answers, search, counts, empty states, builder labels) --- src/api/infoPages.ts | 24 +- src/locales/en.json | 31 +- src/locales/fa.json | 31 +- src/locales/ru.json | 31 +- src/locales/zh.json | 31 +- src/pages/AdminInfoPageEditor.tsx | 601 +++++++++++++++++++++--------- src/pages/AdminInfoPages.tsx | 53 ++- src/pages/InfoPageView.tsx | 171 ++++++++- 8 files changed, 781 insertions(+), 192 deletions(-) diff --git a/src/api/infoPages.ts b/src/api/infoPages.ts index 089e604..994560b 100644 --- a/src/api/infoPages.ts +++ b/src/api/infoPages.ts @@ -1,10 +1,13 @@ import apiClient from './client'; +export type InfoPageType = 'page' | 'faq'; + export interface InfoPage { id: number; slug: string; title: Record; content: Record; + page_type: InfoPageType; is_active: boolean; sort_order: number; icon: string | null; @@ -16,6 +19,7 @@ export interface InfoPageListItem { id: number; slug: string; title: Record; + page_type: InfoPageType; is_active: boolean; sort_order: number; icon: string | null; @@ -26,6 +30,7 @@ export interface InfoPageCreateRequest { slug: string; title: Record; content: Record; + page_type: InfoPageType; is_active: boolean; sort_order: number; icon: string | null; @@ -35,19 +40,27 @@ export interface InfoPageUpdateRequest { slug?: string; title?: Record; content?: Record; + page_type?: InfoPageType; is_active?: boolean; sort_order?: number; icon?: string | null; } +/** Single FAQ Q&A item stored in content JSONB. */ +export interface FaqItem { + q: string; + a: string; +} + export interface InfoPageReorderRequest { items: Array<{ id: number; sort_order: number }>; } export const infoPagesApi = { // Public endpoints - getPages: async (): Promise => { - const response = await apiClient.get('/cabinet/info-pages'); + getPages: async (pageType?: InfoPageType): Promise => { + const params = pageType ? { page_type: pageType } : undefined; + const response = await apiClient.get('/cabinet/info-pages', { params }); return response.data; }, @@ -59,8 +72,11 @@ export const infoPagesApi = { }, // Admin endpoints - getAdminPages: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/info-pages'); + 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; }, diff --git a/src/locales/en.json b/src/locales/en.json index a5eda77..46a1db5 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3804,6 +3804,7 @@ "title": "Information Pages", "subtitle": "Manage static information pages", "create": "Create Page", + "createFaq": "Create FAQ", "edit": "Edit", "save": "Save", "saving": "Saving...", @@ -3815,6 +3816,7 @@ "notFound": "Page not found", "active": "Active", "inactive": "Inactive", + "typePage": "Page", "localeLabel": "Content Language", "fields": { "slug": "URL Slug", @@ -3822,7 +3824,34 @@ "content": "Content", "isActive": "Active", "sortOrder": "Sort Order", - "icon": "Icon (emoji)" + "icon": "Icon (emoji)", + "pageType": "Page Type" + }, + "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", diff --git a/src/locales/fa.json b/src/locales/fa.json index a570208..056c1cf 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -3372,6 +3372,7 @@ "title": "صفحات اطلاعات", "subtitle": "مدیریت صفحات اطلاعات ثابت", "create": "ایجاد صفحه", + "createFaq": "ایجاد FAQ", "edit": "ویرایش", "save": "ذخیره", "saving": "در حال ذخیره...", @@ -3383,6 +3384,7 @@ "notFound": "صفحه پیدا نشد", "active": "فعال", "inactive": "غیرفعال", + "typePage": "صفحه", "localeLabel": "زبان محتوا", "fields": { "slug": "شناسه URL", @@ -3390,7 +3392,34 @@ "content": "محتوا", "isActive": "فعال", "sortOrder": "ترتیب", - "icon": "آیکون (ایموجی)" + "icon": "آیکون (ایموجی)", + "pageType": "نوع صفحه" + }, + "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": "روسی", diff --git a/src/locales/ru.json b/src/locales/ru.json index 9f4def2..873ea88 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -4348,6 +4348,7 @@ "title": "Информационные страницы", "subtitle": "Управление статическими информационными страницами", "create": "Создать страницу", + "createFaq": "Создать FAQ", "edit": "Редактировать", "save": "Сохранить", "saving": "Сохранение...", @@ -4359,6 +4360,7 @@ "notFound": "Страница не найдена", "active": "Активна", "inactive": "Неактивна", + "typePage": "Страница", "localeLabel": "Язык контента", "fields": { "slug": "URL-адрес", @@ -4366,7 +4368,34 @@ "content": "Содержание", "isActive": "Активна", "sortOrder": "Порядок сортировки", - "icon": "Иконка (эмодзи)" + "icon": "Иконка (эмодзи)", + "pageType": "Тип страницы" + }, + "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": "Русский", diff --git a/src/locales/zh.json b/src/locales/zh.json index 0eb5726..9748c77 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -3371,6 +3371,7 @@ "title": "信息页面", "subtitle": "管理静态信息页面", "create": "创建页面", + "createFaq": "创建FAQ", "edit": "编辑", "save": "保存", "saving": "保存中...", @@ -3382,6 +3383,7 @@ "notFound": "页面未找到", "active": "已激活", "inactive": "未激活", + "typePage": "页面", "localeLabel": "内容语言", "fields": { "slug": "URL标识", @@ -3389,7 +3391,34 @@ "content": "内容", "isActive": "已激活", "sortOrder": "排序", - "icon": "图标(表情)" + "icon": "图标(表情)", + "pageType": "页面类型" + }, + "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": "俄语", diff --git a/src/pages/AdminInfoPageEditor.tsx b/src/pages/AdminInfoPageEditor.tsx index 7be6149..2b242eb 100644 --- a/src/pages/AdminInfoPageEditor.tsx +++ b/src/pages/AdminInfoPageEditor.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; -import { useNavigate, useParams } from 'react-router'; +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'; @@ -17,6 +17,7 @@ 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 } from '../api/infoPages'; const AVAILABLE_LOCALES = ['ru', 'en', 'zh', 'fa'] as const; type LocaleCode = (typeof AVAILABLE_LOCALES)[number]; @@ -204,14 +205,203 @@ function generateSlug(title: string): string { .substring(0, 100); } +// --- FAQ Q&A Item Icons --- +const ChevronUpIcon = () => ( + + + +); + +const ChevronDownIcon = () => ( + + + +); + +const TrashSmallIcon = () => ( + + + +); + +const PlusSmallIcon = () => ( + + + +); + +// --- 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(); + + 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) => { + onChange(items.filter((_, i) => i !== index)); + }, + [items, onChange], + ); + + const handleAdd = useCallback(() => { + 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]]; + 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]]; + onChange(updated); + }, + [items, onChange], + ); + + void locale; + + 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')} + /> +
+
+ +