mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
feat: add system pages admin section
This commit is contained in:
11
src/App.tsx
11
src/App.tsx
@@ -155,6 +155,7 @@ const AdminNewsCreate = lazyWithRetry(() => import('./pages/AdminNewsCreate'));
|
||||
const InfoPageView = lazyWithRetry(() => import('./pages/InfoPageView'));
|
||||
const AdminInfoPages = lazyWithRetry(() => import('./pages/AdminInfoPages'));
|
||||
const AdminInfoPageEditor = lazyWithRetry(() => import('./pages/AdminInfoPageEditor'));
|
||||
const AdminLegalPages = lazyWithRetry(() => import('./pages/AdminLegalPages'));
|
||||
|
||||
function ProtectedRoute({
|
||||
children,
|
||||
@@ -1345,6 +1346,16 @@ function App() {
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/legal-pages"
|
||||
element={
|
||||
<PermissionRoute permission="info_pages:read">
|
||||
<LazyPage>
|
||||
<AdminLegalPages />
|
||||
</LazyPage>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/admin/audit-log"
|
||||
|
||||
@@ -1236,7 +1236,8 @@
|
||||
"referralNetwork": "Referral Network",
|
||||
"news": "News",
|
||||
"bulkActions": "Bulk Actions",
|
||||
"infoPages": "Info Pages"
|
||||
"infoPages": "Info Pages",
|
||||
"legalPages": "System pages"
|
||||
},
|
||||
"panel": {
|
||||
"title": "Admin Panel",
|
||||
@@ -4132,6 +4133,42 @@
|
||||
"zh": "Chinese",
|
||||
"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": {
|
||||
|
||||
@@ -1258,7 +1258,8 @@
|
||||
"referralNetwork": "Реферальная сеть",
|
||||
"news": "Новости",
|
||||
"bulkActions": "Массовые действия",
|
||||
"infoPages": "Инфо-страницы"
|
||||
"infoPages": "Инфо-страницы",
|
||||
"legalPages": "Системные страницы"
|
||||
},
|
||||
"panel": {
|
||||
"title": "Панель администратора",
|
||||
@@ -4677,6 +4678,42 @@
|
||||
"zh": "Китайский",
|
||||
"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": {
|
||||
|
||||
@@ -215,6 +215,17 @@ export default function AdminInfoPages() {
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
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"
|
||||
|
||||
569
src/pages/AdminLegalPages.tsx
Normal file
569
src/pages/AdminLegalPages.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -303,6 +303,12 @@ const sections: AdminSection[] = [
|
||||
to: '/admin/info-pages',
|
||||
permission: 'info_pages:read',
|
||||
},
|
||||
{
|
||||
name: 'admin.nav.legalPages',
|
||||
icon: 'file-text',
|
||||
to: '/admin/legal-pages',
|
||||
permission: 'info_pages:read',
|
||||
},
|
||||
{
|
||||
name: 'admin.nav.updates',
|
||||
icon: 'refresh',
|
||||
|
||||
Reference in New Issue
Block a user