import { useCallback, 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 (
{DISPLAY_MODES.map((mode) => (
))}
{disabled && (
{t('admin.legalPages.displayModeLocked')}
)}
);
}
function LanguageTabs({
languages,
active,
onChange,
}: {
languages: string[];
active: string;
onChange: (lang: string) => void;
}) {
const { t } = useTranslation();
return (
{languages.map((lang) => (
))}
);
}
function DocumentEditor({
kind,
onDirtyChange,
}: {
kind: 'privacy-policy' | 'public-offer';
onDirtyChange: (dirty: boolean) => void;
}) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const haptic = useHapticFeedback();
const [displayMode, setDisplayMode] = useState('both');
const [contents, setContents] = useState>({});
const [enabled, setEnabled] = useState>({});
const [activeLang, setActiveLang] = useState('ru');
const [populated, setPopulated] = useState(false);
const [saveError, setSaveError] = useState(null);
const { data, isLoading, isFetching } = useQuery({
queryKey: ['admin', 'legal-pages', kind],
queryFn: () =>
kind === 'privacy-policy'
? adminLegalPagesApi.getPrivacyPolicy()
: adminLegalPagesApi.getPublicOffer(),
staleTime: 0,
gcTime: 0,
});
useEffect(() => {
if (!data || isFetching || populated) return;
setDisplayMode(data.display_mode);
const nextContents: Record = {};
const nextEnabled: Record = {};
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, isFetching, populated]);
const isDirty =
populated &&
!!data &&
(displayMode !== data.display_mode ||
data.items.some(
(item) =>
(contents[item.language] ?? '') !== item.content ||
(enabled[item.language] ?? false) !== item.is_enabled,
));
useEffect(() => {
onDirtyChange(isDirty);
}, [isDirty, onDirtyChange]);
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 ;
}
const languages = data.items.map((item) => item.language);
return (
setEnabled((prev) => ({ ...prev, [activeLang]: !prev[activeLang] }))}
aria-label={t('admin.legalPages.enabled')}
/>
{t('admin.legalPages.enabled')}
{saveError &&
{saveError}
}
);
}
function RulesEditor({ onDirtyChange }: { onDirtyChange: (dirty: boolean) => void }) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const haptic = useHapticFeedback();
const [displayMode, setDisplayMode] = useState('both');
const [contents, setContents] = useState>({});
const [activeLang, setActiveLang] = useState('ru');
const [populated, setPopulated] = useState(false);
const [saveError, setSaveError] = useState(null);
const { data, isLoading, isFetching } = useQuery({
queryKey: ['admin', 'legal-pages', 'rules'],
queryFn: adminLegalPagesApi.getRules,
staleTime: 0,
gcTime: 0,
});
useEffect(() => {
if (!data || isFetching || populated) return;
setDisplayMode(data.display_mode);
const nextContents: Record = {};
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, isFetching, populated]);
const isDirty =
populated &&
!!data &&
(displayMode !== data.display_mode ||
data.items.some((item) => (contents[item.language] ?? '') !== item.content));
useEffect(() => {
onDirtyChange(isDirty);
}, [isDirty, onDirtyChange]);
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 ;
}
const languages = data.items.map((item) => item.language);
return (
{saveError &&
{saveError}
}
);
}
function FaqQuestionRow({
page,
canMoveUp,
canMoveDown,
onMoveUp,
onMoveDown,
onDelete,
onSaved,
onDirtyChange,
}: {
page: FaqPageItem;
canMoveUp: boolean;
canMoveDown: boolean;
onMoveUp: () => void;
onMoveDown: () => void;
onDelete: () => void;
onSaved: () => void;
onDirtyChange: (id: number, dirty: boolean) => 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 [saveError, setSaveError] = useState(null);
const isDirty = title !== page.title || content !== page.content || isActive !== page.is_active;
useEffect(() => {
onDirtyChange(page.id, isDirty);
return () => onDirtyChange(page.id, false);
}, [page.id, isDirty, onDirtyChange]);
const saveMutation = useMutation({
mutationFn: () =>
adminLegalPagesApi.updateFaqPage(page.id, { title, content, is_active: isActive }),
onSuccess: () => {
haptic.success();
setSaveError(null);
onSaved();
},
onError: (err) => {
haptic.error();
setSaveError(extractErrorDetail(err) ?? t('admin.legalPages.saveError'));
},
});
return (
);
}
function FaqEditor({ onDirtyChange }: { onDirtyChange: (dirty: boolean) => void }) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const haptic = useHapticFeedback();
const confirm = useDestructiveConfirm();
const [displayMode, setDisplayMode] = useState('both');
const [activeLang, setActiveLang] = useState('ru');
const [populated, setPopulated] = useState(false);
const [saveError, setSaveError] = useState(null);
const [dirtyRows, setDirtyRows] = useState([]);
const { data, isLoading, isFetching } = useQuery({
queryKey: ['admin', 'legal-pages', 'faq'],
queryFn: adminLegalPagesApi.getFaq,
staleTime: 0,
gcTime: 0,
});
useEffect(() => {
if (!data || isFetching || 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, isFetching, populated]);
const handleRowDirtyChange = useCallback((id: number, dirty: boolean) => {
setDirtyRows((prev) => {
if (dirty) return prev.includes(id) ? prev : [...prev, id];
return prev.includes(id) ? prev.filter((rowId) => rowId !== id) : prev;
});
}, []);
const hasDirtyRows = dirtyRows.length > 0;
useEffect(() => {
onDirtyChange(hasDirtyRows);
}, [hasDirtyRows, onDirtyChange]);
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ['admin', 'legal-pages', 'faq'] });
const settingsMutation = useMutation({
mutationFn: (payload: { display_mode?: LegalDisplayMode; settings?: FaqSettingItem[] }) =>
adminLegalPagesApi.updateFaq(payload),
onSuccess: (resp) => {
haptic.success();
setSaveError(null);
queryClient.setQueryData(['admin', 'legal-pages', 'faq'], resp);
},
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();
setSaveError(null);
invalidate();
},
onError: (err) => {
haptic.error();
setSaveError(extractErrorDetail(err) ?? t('admin.legalPages.saveError'));
},
});
const reorderMutation = useMutation({
mutationFn: async ({ a, b }: { a: FaqPageItem; b: FaqPageItem }) => {
await adminLegalPagesApi.updateFaqPage(a.id, { display_order: b.display_order });
try {
await adminLegalPagesApi.updateFaqPage(b.id, { display_order: a.display_order });
} catch (err) {
await adminLegalPagesApi
.updateFaqPage(a.id, { display_order: a.display_order })
.catch(() => {});
throw err;
}
},
onSuccess: () => {
haptic.success();
setSaveError(null);
},
onError: (err) => {
haptic.error();
setSaveError(extractErrorDetail(err) ?? t('admin.legalPages.saveError'));
},
onSettled: () => {
invalidate();
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => adminLegalPagesApi.deleteFaqPage(id),
onSuccess: () => {
haptic.success();
setSaveError(null);
invalidate();
},
onError: (err) => {
haptic.error();
setSaveError(extractErrorDetail(err) ?? t('admin.legalPages.saveError'));
},
});
if (isLoading || !data) {
return ;
}
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 (
{
setDisplayMode(mode);
settingsMutation.mutate({ display_mode: mode });
}}
disabled={data.display_mode_env_locked}
/>
{
if (lang === activeLang) return;
if (hasDirtyRows && !(await confirm(t('admin.legalPages.unsavedWarning')))) return;
setActiveLang(lang);
}}
/>
settingsMutation.mutate({
settings: [{ language: activeLang, is_enabled: !langEnabled }],
})
}
disabled={settingsMutation.isPending}
aria-label={t('admin.legalPages.enabled')}
/>
{t('admin.legalPages.enabled')}
{saveError && {saveError}
}
{t('admin.legalPages.faqPages')}
{pages.length === 0 ? (
{t('admin.legalPages.noQuestions')}
) : (
pages.map((page, index) => (
0 && !reorderMutation.isPending}
canMoveDown={index < pages.length - 1 && !reorderMutation.isPending}
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}
onDirtyChange={handleRowDirtyChange}
/>
))
)}
);
}
export default function AdminLegalPages() {
const { t } = useTranslation();
const confirm = useDestructiveConfirm();
const [activeTab, setActiveTab] = useState('privacy');
const [dirty, setDirty] = useState(false);
const handleTabChange = async (tab: LegalTab) => {
if (tab === activeTab) return;
if (dirty && !(await confirm(t('admin.legalPages.unsavedWarning')))) return;
setDirty(false);
setActiveTab(tab);
};
return (
{t('admin.legalPages.title')}
{t('admin.legalPages.subtitle')}
{(['privacy', 'offer', 'rules', 'faq'] as const).map((tab) => (
))}
{activeTab === 'privacy' && (
)}
{activeTab === 'offer' && (
)}
{activeTab === 'rules' &&
}
{activeTab === 'faq' &&
}
);
}