feat: add system pages admin section

This commit is contained in:
Boris Kovalskii
2026-06-11 09:15:59 +10:00
parent 04cbbb5e51
commit 0d6a85a4f9
6 changed files with 673 additions and 2 deletions

View File

@@ -155,6 +155,7 @@ const AdminNewsCreate = lazyWithRetry(() => import('./pages/AdminNewsCreate'));
const InfoPageView = lazyWithRetry(() => import('./pages/InfoPageView')); const InfoPageView = lazyWithRetry(() => import('./pages/InfoPageView'));
const AdminInfoPages = lazyWithRetry(() => import('./pages/AdminInfoPages')); const AdminInfoPages = lazyWithRetry(() => import('./pages/AdminInfoPages'));
const AdminInfoPageEditor = lazyWithRetry(() => import('./pages/AdminInfoPageEditor')); const AdminInfoPageEditor = lazyWithRetry(() => import('./pages/AdminInfoPageEditor'));
const AdminLegalPages = lazyWithRetry(() => import('./pages/AdminLegalPages'));
function ProtectedRoute({ function ProtectedRoute({
children, children,
@@ -1345,6 +1346,16 @@ function App() {
</PermissionRoute> </PermissionRoute>
} }
/> />
<Route
path="/admin/legal-pages"
element={
<PermissionRoute permission="info_pages:read">
<LazyPage>
<AdminLegalPages />
</LazyPage>
</PermissionRoute>
}
/>
<Route <Route
path="/admin/audit-log" path="/admin/audit-log"

View File

@@ -1236,7 +1236,8 @@
"referralNetwork": "Referral Network", "referralNetwork": "Referral Network",
"news": "News", "news": "News",
"bulkActions": "Bulk Actions", "bulkActions": "Bulk Actions",
"infoPages": "Info Pages" "infoPages": "Info Pages",
"legalPages": "System pages"
}, },
"panel": { "panel": {
"title": "Admin Panel", "title": "Admin Panel",
@@ -4132,6 +4133,42 @@
"zh": "Chinese", "zh": "Chinese",
"fa": "Persian" "fa": "Persian"
} }
},
"legalPages": {
"title": "System pages",
"subtitle": "Rules, privacy policy, public offer and FAQ",
"open": "System pages",
"tabs": {
"privacy": "Privacy",
"offer": "Offer",
"rules": "Rules",
"faq": "FAQ"
},
"displayMode": "Visibility",
"displayModes": {
"bot": "Bot only",
"web": "Web only",
"both": "Bot and web"
},
"displayModeLocked": "Set via environment variable",
"enabled": "Visible to users",
"language": "Content language",
"content": "Content",
"contentPlaceholder": "HTML page content...",
"save": "Save",
"saving": "Saving...",
"saveError": "Failed to save. Please try again.",
"faqPages": "Questions and answers",
"addQuestion": "Add question",
"newQuestion": "New question",
"questionTitle": "Question",
"questionContent": "Answer (HTML supported)",
"confirmDeleteQuestion": "Delete this question?",
"noQuestions": "No questions yet",
"active": "Active",
"moveUp": "Move up",
"moveDown": "Move down",
"delete": "Delete"
} }
}, },
"adminUpdates": { "adminUpdates": {

View File

@@ -1258,7 +1258,8 @@
"referralNetwork": "Реферальная сеть", "referralNetwork": "Реферальная сеть",
"news": "Новости", "news": "Новости",
"bulkActions": "Массовые действия", "bulkActions": "Массовые действия",
"infoPages": "Инфо-страницы" "infoPages": "Инфо-страницы",
"legalPages": "Системные страницы"
}, },
"panel": { "panel": {
"title": "Панель администратора", "title": "Панель администратора",
@@ -4677,6 +4678,42 @@
"zh": "Китайский", "zh": "Китайский",
"fa": "Персидский" "fa": "Персидский"
} }
},
"legalPages": {
"title": "Системные страницы",
"subtitle": "Правила, политика конфиденциальности, оферта и FAQ",
"open": "Системные страницы",
"tabs": {
"privacy": "Политика конф.",
"offer": "Оферта",
"rules": "Правила",
"faq": "FAQ"
},
"displayMode": "Отображение",
"displayModes": {
"bot": "Только бот",
"web": "Только веб",
"both": "Бот и веб"
},
"displayModeLocked": "Задано через переменную окружения",
"enabled": "Показывать пользователям",
"language": "Язык контента",
"content": "Текст",
"contentPlaceholder": "HTML-текст страницы...",
"save": "Сохранить",
"saving": "Сохранение...",
"saveError": "Не удалось сохранить. Попробуйте ещё раз.",
"faqPages": "Вопросы и ответы",
"addQuestion": "Добавить вопрос",
"newQuestion": "Новый вопрос",
"questionTitle": "Вопрос",
"questionContent": "Ответ (поддерживается HTML)",
"confirmDeleteQuestion": "Удалить этот вопрос?",
"noQuestions": "Вопросов пока нет",
"active": "Активен",
"moveUp": "Переместить вверх",
"moveDown": "Переместить вниз",
"delete": "Удалить"
} }
}, },
"adminUpdates": { "adminUpdates": {

View File

@@ -215,6 +215,17 @@ export default function AdminInfoPages() {
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button
onClick={() => {
haptic.buttonPress();
navigate('/admin/legal-pages');
}}
className="flex min-h-[44px] items-center gap-2 rounded-lg bg-dark-800 px-4 py-2.5 text-dark-200 transition-colors hover:bg-dark-700"
aria-label={t('admin.legalPages.open')}
>
<FileTextIcon className="h-4 w-4" />
<span className="hidden sm:inline">{t('admin.legalPages.open')}</span>
</button>
<button <button
onClick={() => refetch()} onClick={() => refetch()}
className="min-h-[44px] min-w-[44px] rounded-lg bg-dark-800 p-2.5 text-dark-400 transition-colors hover:text-dark-100" className="min-h-[44px] min-w-[44px] rounded-lg bg-dark-800 p-2.5 text-dark-400 transition-colors hover:text-dark-100"

View File

@@ -0,0 +1,569 @@
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
adminLegalPagesApi,
type FaqPageItem,
type FaqSettingItem,
type LegalDisplayMode,
} from '../api/adminLegalPages';
import { AdminBackButton } from '../components/admin';
import { Toggle } from '../components/admin/Toggle';
import { useHapticFeedback } from '../platform/hooks/useHaptic';
import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog';
import { cn } from '../lib/utils';
import { ChevronDownIcon, ChevronUpIcon, PlusIcon, TrashIcon } from '@/components/icons';
type LegalTab = 'privacy' | 'offer' | 'rules' | 'faq';
const DISPLAY_MODES: LegalDisplayMode[] = ['bot', 'web', 'both'];
function extractErrorDetail(err: unknown): string | null {
const error = err as { response?: { data?: { detail?: unknown } } };
const detail = error.response?.data?.detail;
return typeof detail === 'string' ? detail : null;
}
function DisplayModeSelector({
value,
onChange,
disabled,
}: {
value: LegalDisplayMode;
onChange: (mode: LegalDisplayMode) => void;
disabled?: boolean;
}) {
const { t } = useTranslation();
return (
<div>
<label className="label">{t('admin.legalPages.displayMode')}</label>
<div className="flex gap-1" role="radiogroup">
{DISPLAY_MODES.map((mode) => (
<button
key={mode}
type="button"
role="radio"
aria-checked={value === mode}
disabled={disabled}
onClick={() => onChange(mode)}
className={cn(
'min-h-[44px] rounded-lg px-4 py-2.5 text-sm font-medium transition-colors',
value === mode
? 'bg-accent-500 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-dark-100',
disabled && 'cursor-not-allowed opacity-50',
)}
>
{t(`admin.legalPages.displayModes.${mode}`)}
</button>
))}
</div>
{disabled && (
<p className="mt-1.5 text-xs text-dark-500">{t('admin.legalPages.displayModeLocked')}</p>
)}
</div>
);
}
function LanguageTabs({
languages,
active,
onChange,
}: {
languages: string[];
active: string;
onChange: (lang: string) => void;
}) {
const { t } = useTranslation();
return (
<div>
<label className="label">{t('admin.legalPages.language')}</label>
<div className="flex flex-wrap gap-1">
{languages.map((lang) => (
<button
key={lang}
type="button"
onClick={() => onChange(lang)}
className={cn(
'min-h-[44px] rounded-lg px-4 py-2.5 text-sm font-medium uppercase transition-colors',
active === lang
? 'bg-accent-500 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-dark-100',
)}
>
{lang}
</button>
))}
</div>
</div>
);
}
function DocumentEditor({ kind }: { kind: 'privacy-policy' | 'public-offer' }) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const haptic = useHapticFeedback();
const [displayMode, setDisplayMode] = useState<LegalDisplayMode>('both');
const [contents, setContents] = useState<Record<string, string>>({});
const [enabled, setEnabled] = useState<Record<string, boolean>>({});
const [activeLang, setActiveLang] = useState('ru');
const [populated, setPopulated] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['admin', 'legal-pages', kind],
queryFn: () =>
kind === 'privacy-policy'
? adminLegalPagesApi.getPrivacyPolicy()
: adminLegalPagesApi.getPublicOffer(),
staleTime: 0,
});
useEffect(() => {
if (!data || populated) return;
setDisplayMode(data.display_mode);
const nextContents: Record<string, string> = {};
const nextEnabled: Record<string, boolean> = {};
for (const item of data.items) {
nextContents[item.language] = item.content;
nextEnabled[item.language] = item.is_enabled;
}
setContents(nextContents);
setEnabled(nextEnabled);
if (data.items.length > 0 && !data.items.some((item) => item.language === 'ru')) {
setActiveLang(data.items[0].language);
}
setPopulated(true);
}, [data, populated]);
const saveMutation = useMutation({
mutationFn: () => {
const payload = {
...(data?.display_mode_env_locked ? {} : { display_mode: displayMode }),
items: Object.keys(contents).map((language) => ({
language,
content: contents[language] ?? '',
is_enabled: enabled[language] ?? false,
})),
};
return kind === 'privacy-policy'
? adminLegalPagesApi.updatePrivacyPolicy(payload)
: adminLegalPagesApi.updatePublicOffer(payload);
},
onSuccess: () => {
haptic.success();
setSaveError(null);
queryClient.invalidateQueries({ queryKey: ['admin', 'legal-pages', kind] });
},
onError: (err) => {
haptic.error();
setSaveError(extractErrorDetail(err) ?? t('admin.legalPages.saveError'));
},
});
if (isLoading || !data) {
return <div className="skeleton h-64 w-full rounded-xl" />;
}
const languages = data.items.map((item) => item.language);
return (
<div className="space-y-5">
<DisplayModeSelector
value={displayMode}
onChange={setDisplayMode}
disabled={data.display_mode_env_locked}
/>
<LanguageTabs languages={languages} active={activeLang} onChange={setActiveLang} />
<div className="flex items-center gap-3">
<Toggle
checked={enabled[activeLang] ?? false}
onChange={() => setEnabled((prev) => ({ ...prev, [activeLang]: !prev[activeLang] }))}
aria-label={t('admin.legalPages.enabled')}
/>
<span className="text-sm text-dark-300">{t('admin.legalPages.enabled')}</span>
</div>
<div>
<label className="label">{t('admin.legalPages.content')}</label>
<textarea
value={contents[activeLang] ?? ''}
onChange={(e) => setContents((prev) => ({ ...prev, [activeLang]: e.target.value }))}
rows={16}
className="input min-h-[320px] w-full font-mono text-sm"
placeholder={t('admin.legalPages.contentPlaceholder')}
/>
</div>
{saveError && <p className="text-sm text-error-400">{saveError}</p>}
<button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
className="min-h-[44px] rounded-lg bg-accent-500 px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
>
{saveMutation.isPending ? t('admin.legalPages.saving') : t('admin.legalPages.save')}
</button>
</div>
);
}
function RulesEditor() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const haptic = useHapticFeedback();
const [displayMode, setDisplayMode] = useState<LegalDisplayMode>('both');
const [contents, setContents] = useState<Record<string, string>>({});
const [activeLang, setActiveLang] = useState('ru');
const [populated, setPopulated] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['admin', 'legal-pages', 'rules'],
queryFn: adminLegalPagesApi.getRules,
staleTime: 0,
});
useEffect(() => {
if (!data || populated) return;
setDisplayMode(data.display_mode);
const nextContents: Record<string, string> = {};
for (const item of data.items) {
nextContents[item.language] = item.content;
}
setContents(nextContents);
if (data.items.length > 0 && !data.items.some((item) => item.language === 'ru')) {
setActiveLang(data.items[0].language);
}
setPopulated(true);
}, [data, populated]);
const saveMutation = useMutation({
mutationFn: () =>
adminLegalPagesApi.updateRules({
...(data?.display_mode_env_locked ? {} : { display_mode: displayMode }),
items: Object.keys(contents).map((language) => ({
language,
content: contents[language] ?? '',
})),
}),
onSuccess: () => {
haptic.success();
setSaveError(null);
queryClient.invalidateQueries({ queryKey: ['admin', 'legal-pages', 'rules'] });
},
onError: (err) => {
haptic.error();
setSaveError(extractErrorDetail(err) ?? t('admin.legalPages.saveError'));
},
});
if (isLoading || !data) {
return <div className="skeleton h-64 w-full rounded-xl" />;
}
const languages = data.items.map((item) => item.language);
return (
<div className="space-y-5">
<DisplayModeSelector
value={displayMode}
onChange={setDisplayMode}
disabled={data.display_mode_env_locked}
/>
<LanguageTabs languages={languages} active={activeLang} onChange={setActiveLang} />
<div>
<label className="label">{t('admin.legalPages.content')}</label>
<textarea
value={contents[activeLang] ?? ''}
onChange={(e) => setContents((prev) => ({ ...prev, [activeLang]: e.target.value }))}
rows={16}
className="input min-h-[320px] w-full font-mono text-sm"
placeholder={t('admin.legalPages.contentPlaceholder')}
/>
</div>
{saveError && <p className="text-sm text-error-400">{saveError}</p>}
<button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
className="min-h-[44px] rounded-lg bg-accent-500 px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
>
{saveMutation.isPending ? t('admin.legalPages.saving') : t('admin.legalPages.save')}
</button>
</div>
);
}
function FaqQuestionRow({
page,
canMoveUp,
canMoveDown,
onMoveUp,
onMoveDown,
onDelete,
onSaved,
}: {
page: FaqPageItem;
canMoveUp: boolean;
canMoveDown: boolean;
onMoveUp: () => void;
onMoveDown: () => void;
onDelete: () => void;
onSaved: () => void;
}) {
const { t } = useTranslation();
const haptic = useHapticFeedback();
const [title, setTitle] = useState(page.title);
const [content, setContent] = useState(page.content);
const [isActive, setIsActive] = useState(page.is_active);
const saveMutation = useMutation({
mutationFn: () =>
adminLegalPagesApi.updateFaqPage(page.id, { title, content, is_active: isActive }),
onSuccess: () => {
haptic.success();
onSaved();
},
});
return (
<div className="space-y-3 rounded-xl border border-dark-700 bg-dark-800/50 p-4">
<div className="flex items-center gap-2">
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className="input flex-1"
placeholder={t('admin.legalPages.questionTitle')}
/>
<Toggle
checked={isActive}
onChange={() => setIsActive((v) => !v)}
aria-label={t('admin.legalPages.active')}
/>
<button
type="button"
onClick={onMoveUp}
disabled={!canMoveUp}
className="min-h-[44px] min-w-[44px] rounded-lg p-2.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:opacity-30"
aria-label={t('admin.legalPages.moveUp')}
>
<ChevronUpIcon className="h-4 w-4" />
</button>
<button
type="button"
onClick={onMoveDown}
disabled={!canMoveDown}
className="min-h-[44px] min-w-[44px] rounded-lg p-2.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:opacity-30"
aria-label={t('admin.legalPages.moveDown')}
>
<ChevronDownIcon className="h-4 w-4" />
</button>
<button
type="button"
onClick={onDelete}
className="min-h-[44px] min-w-[44px] rounded-lg p-2.5 text-dark-400 transition-colors hover:bg-error-500/10 hover:text-error-400"
aria-label={t('admin.legalPages.delete')}
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
rows={4}
className="input w-full font-mono text-sm"
placeholder={t('admin.legalPages.questionContent')}
/>
<button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
className="min-h-[44px] rounded-lg bg-dark-700 px-5 py-2 text-sm font-medium text-dark-100 transition-colors hover:bg-dark-600 disabled:opacity-50"
>
{saveMutation.isPending ? t('admin.legalPages.saving') : t('admin.legalPages.save')}
</button>
</div>
);
}
function FaqEditor() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const haptic = useHapticFeedback();
const confirm = useDestructiveConfirm();
const [displayMode, setDisplayMode] = useState<LegalDisplayMode>('both');
const [activeLang, setActiveLang] = useState('ru');
const [populated, setPopulated] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['admin', 'legal-pages', 'faq'],
queryFn: adminLegalPagesApi.getFaq,
staleTime: 0,
});
useEffect(() => {
if (!data || populated) return;
setDisplayMode(data.display_mode);
if (data.settings.length > 0 && !data.settings.some((s) => s.language === 'ru')) {
setActiveLang(data.settings[0].language);
}
setPopulated(true);
}, [data, populated]);
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ['admin', 'legal-pages', 'faq'] });
const settingsMutation = useMutation({
mutationFn: (payload: { display_mode?: LegalDisplayMode; settings?: FaqSettingItem[] }) =>
adminLegalPagesApi.updateFaq(payload),
onSuccess: () => {
haptic.success();
setSaveError(null);
invalidate();
},
onError: (err) => {
haptic.error();
setSaveError(extractErrorDetail(err) ?? t('admin.legalPages.saveError'));
if (data) setDisplayMode(data.display_mode);
},
});
const createMutation = useMutation({
mutationFn: () =>
adminLegalPagesApi.createFaqPage({
language: activeLang,
title: t('admin.legalPages.newQuestion'),
content: '',
}),
onSuccess: () => {
haptic.success();
invalidate();
},
});
const reorderMutation = useMutation({
mutationFn: async ({ a, b }: { a: FaqPageItem; b: FaqPageItem }) => {
await adminLegalPagesApi.updateFaqPage(a.id, { display_order: b.display_order });
await adminLegalPagesApi.updateFaqPage(b.id, { display_order: a.display_order });
},
onSuccess: () => {
haptic.success();
invalidate();
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => adminLegalPagesApi.deleteFaqPage(id),
onSuccess: () => {
haptic.success();
invalidate();
},
});
if (isLoading || !data) {
return <div className="skeleton h-64 w-full rounded-xl" />;
}
const languages = data.settings.map((s) => s.language);
const langEnabled = data.settings.find((s) => s.language === activeLang)?.is_enabled ?? false;
const pages = data.pages.filter((p) => p.language === activeLang);
return (
<div className="space-y-5">
<DisplayModeSelector
value={displayMode}
onChange={(mode) => {
setDisplayMode(mode);
settingsMutation.mutate({ display_mode: mode });
}}
disabled={data.display_mode_env_locked}
/>
<LanguageTabs languages={languages} active={activeLang} onChange={setActiveLang} />
<div className="flex items-center gap-3">
<Toggle
checked={langEnabled}
onChange={() =>
settingsMutation.mutate({
settings: [{ language: activeLang, is_enabled: !langEnabled }],
})
}
aria-label={t('admin.legalPages.enabled')}
/>
<span className="text-sm text-dark-300">{t('admin.legalPages.enabled')}</span>
</div>
{saveError && <p className="text-sm text-error-400">{saveError}</p>}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-dark-200">{t('admin.legalPages.faqPages')}</h2>
<button
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending}
className="flex min-h-[44px] items-center gap-2 rounded-lg bg-accent-500 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
>
<PlusIcon />
<span className="hidden sm:inline">{t('admin.legalPages.addQuestion')}</span>
</button>
</div>
{pages.length === 0 ? (
<p className="rounded-xl border border-dark-700 bg-dark-800/50 p-6 text-center text-sm text-dark-400">
{t('admin.legalPages.noQuestions')}
</p>
) : (
pages.map((page, index) => (
<FaqQuestionRow
key={page.id}
page={page}
canMoveUp={index > 0}
canMoveDown={index < pages.length - 1}
onMoveUp={() => reorderMutation.mutate({ a: page, b: pages[index - 1] })}
onMoveDown={() => reorderMutation.mutate({ a: page, b: pages[index + 1] })}
onDelete={async () => {
const confirmed = await confirm(t('admin.legalPages.confirmDeleteQuestion'));
if (confirmed) deleteMutation.mutate(page.id);
}}
onSaved={invalidate}
/>
))
)}
</div>
</div>
);
}
export default function AdminLegalPages() {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<LegalTab>('privacy');
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<AdminBackButton to="/admin/info-pages" />
<div>
<h1 className="text-xl font-bold text-dark-100">{t('admin.legalPages.title')}</h1>
<p className="text-sm text-dark-400">{t('admin.legalPages.subtitle')}</p>
</div>
</div>
<div className="flex flex-wrap gap-1">
{(['privacy', 'offer', 'rules', 'faq'] as const).map((tab) => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={cn(
'min-h-[44px] rounded-lg px-4 py-2.5 text-sm font-medium transition-colors',
activeTab === tab
? 'bg-accent-500 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-dark-100',
)}
>
{t(`admin.legalPages.tabs.${tab}`)}
</button>
))}
</div>
{activeTab === 'privacy' && <DocumentEditor key="privacy" kind="privacy-policy" />}
{activeTab === 'offer' && <DocumentEditor key="offer" kind="public-offer" />}
{activeTab === 'rules' && <RulesEditor />}
{activeTab === 'faq' && <FaqEditor />}
</div>
);
}

View File

@@ -303,6 +303,12 @@ const sections: AdminSection[] = [
to: '/admin/info-pages', to: '/admin/info-pages',
permission: 'info_pages:read', permission: 'info_pages:read',
}, },
{
name: 'admin.nav.legalPages',
icon: 'file-text',
to: '/admin/legal-pages',
permission: 'info_pages:read',
},
{ {
name: 'admin.nav.updates', name: 'admin.nav.updates',
icon: 'refresh', icon: 'refresh',