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 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"
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
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',
|
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',
|
||||||
|
|||||||
Reference in New Issue
Block a user