fix(admin): harden legal pages editor state handling

This commit is contained in:
Boris Kovalskii
2026-06-11 09:36:31 +10:00
parent 39758a18b0
commit 515c3d73b8
4 changed files with 154 additions and 33 deletions

View File

@@ -1,6 +1,7 @@
import apiClient from './client'; import apiClient from './client';
import type { InfoPageDisplayMode } from './infoPages';
export type LegalDisplayMode = 'bot' | 'web' | 'both'; export type LegalDisplayMode = InfoPageDisplayMode;
export interface LegalDocumentItem { export interface LegalDocumentItem {
language: string; language: string;
@@ -26,7 +27,7 @@ export interface RulesItem {
updated_at: string | null; updated_at: string | null;
} }
export interface RulesResponse { export interface AdminRulesResponse {
display_mode: LegalDisplayMode; display_mode: LegalDisplayMode;
display_mode_env_locked: boolean; display_mode_env_locked: boolean;
items: RulesItem[]; items: RulesItem[];
@@ -110,13 +111,16 @@ export const adminLegalPagesApi = {
return response.data; return response.data;
}, },
getRules: async (): Promise<RulesResponse> => { getRules: async (): Promise<AdminRulesResponse> => {
const response = await apiClient.get<RulesResponse>('/cabinet/admin/legal-pages/rules'); const response = await apiClient.get<AdminRulesResponse>('/cabinet/admin/legal-pages/rules');
return response.data; return response.data;
}, },
updateRules: async (data: RulesUpdateRequest): Promise<RulesResponse> => { updateRules: async (data: RulesUpdateRequest): Promise<AdminRulesResponse> => {
const response = await apiClient.put<RulesResponse>('/cabinet/admin/legal-pages/rules', data); const response = await apiClient.put<AdminRulesResponse>(
'/cabinet/admin/legal-pages/rules',
data,
);
return response.data; return response.data;
}, },

View File

@@ -4158,6 +4158,7 @@
"save": "Save", "save": "Save",
"saving": "Saving...", "saving": "Saving...",
"saveError": "Failed to save. Please try again.", "saveError": "Failed to save. Please try again.",
"unsavedWarning": "Unsaved changes will be lost. Continue?",
"faqPages": "Questions and answers", "faqPages": "Questions and answers",
"addQuestion": "Add question", "addQuestion": "Add question",
"newQuestion": "New question", "newQuestion": "New question",

View File

@@ -4703,6 +4703,7 @@
"save": "Сохранить", "save": "Сохранить",
"saving": "Сохранение...", "saving": "Сохранение...",
"saveError": "Не удалось сохранить. Попробуйте ещё раз.", "saveError": "Не удалось сохранить. Попробуйте ещё раз.",
"unsavedWarning": "Несохранённые изменения будут потеряны. Продолжить?",
"faqPages": "Вопросы и ответы", "faqPages": "Вопросы и ответы",
"addQuestion": "Добавить вопрос", "addQuestion": "Добавить вопрос",
"newQuestion": "Новый вопрос", "newQuestion": "Новый вопрос",

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@@ -99,7 +99,13 @@ function LanguageTabs({
); );
} }
function DocumentEditor({ kind }: { kind: 'privacy-policy' | 'public-offer' }) { function DocumentEditor({
kind,
onDirtyChange,
}: {
kind: 'privacy-policy' | 'public-offer';
onDirtyChange: (dirty: boolean) => void;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const haptic = useHapticFeedback(); const haptic = useHapticFeedback();
@@ -110,17 +116,18 @@ function DocumentEditor({ kind }: { kind: 'privacy-policy' | 'public-offer' }) {
const [populated, setPopulated] = useState(false); const [populated, setPopulated] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
const { data, isLoading } = useQuery({ const { data, isLoading, isFetching } = useQuery({
queryKey: ['admin', 'legal-pages', kind], queryKey: ['admin', 'legal-pages', kind],
queryFn: () => queryFn: () =>
kind === 'privacy-policy' kind === 'privacy-policy'
? adminLegalPagesApi.getPrivacyPolicy() ? adminLegalPagesApi.getPrivacyPolicy()
: adminLegalPagesApi.getPublicOffer(), : adminLegalPagesApi.getPublicOffer(),
staleTime: 0, staleTime: 0,
gcTime: 0,
}); });
useEffect(() => { useEffect(() => {
if (!data || populated) return; if (!data || isFetching || populated) return;
setDisplayMode(data.display_mode); setDisplayMode(data.display_mode);
const nextContents: Record<string, string> = {}; const nextContents: Record<string, string> = {};
const nextEnabled: Record<string, boolean> = {}; const nextEnabled: Record<string, boolean> = {};
@@ -134,7 +141,21 @@ function DocumentEditor({ kind }: { kind: 'privacy-policy' | 'public-offer' }) {
setActiveLang(data.items[0].language); setActiveLang(data.items[0].language);
} }
setPopulated(true); setPopulated(true);
}, [data, populated]); }, [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({ const saveMutation = useMutation({
mutationFn: () => { mutationFn: () => {
@@ -187,7 +208,10 @@ function DocumentEditor({ kind }: { kind: 'privacy-policy' | 'public-offer' }) {
<label className="label">{t('admin.legalPages.content')}</label> <label className="label">{t('admin.legalPages.content')}</label>
<textarea <textarea
value={contents[activeLang] ?? ''} value={contents[activeLang] ?? ''}
onChange={(e) => setContents((prev) => ({ ...prev, [activeLang]: e.target.value }))} onChange={(e) => {
setSaveError(null);
setContents((prev) => ({ ...prev, [activeLang]: e.target.value }));
}}
rows={16} rows={16}
className="input min-h-[320px] w-full font-mono text-sm" className="input min-h-[320px] w-full font-mono text-sm"
placeholder={t('admin.legalPages.contentPlaceholder')} placeholder={t('admin.legalPages.contentPlaceholder')}
@@ -205,7 +229,7 @@ function DocumentEditor({ kind }: { kind: 'privacy-policy' | 'public-offer' }) {
); );
} }
function RulesEditor() { function RulesEditor({ onDirtyChange }: { onDirtyChange: (dirty: boolean) => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const haptic = useHapticFeedback(); const haptic = useHapticFeedback();
@@ -215,14 +239,15 @@ function RulesEditor() {
const [populated, setPopulated] = useState(false); const [populated, setPopulated] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
const { data, isLoading } = useQuery({ const { data, isLoading, isFetching } = useQuery({
queryKey: ['admin', 'legal-pages', 'rules'], queryKey: ['admin', 'legal-pages', 'rules'],
queryFn: adminLegalPagesApi.getRules, queryFn: adminLegalPagesApi.getRules,
staleTime: 0, staleTime: 0,
gcTime: 0,
}); });
useEffect(() => { useEffect(() => {
if (!data || populated) return; if (!data || isFetching || populated) return;
setDisplayMode(data.display_mode); setDisplayMode(data.display_mode);
const nextContents: Record<string, string> = {}; const nextContents: Record<string, string> = {};
for (const item of data.items) { for (const item of data.items) {
@@ -233,7 +258,17 @@ function RulesEditor() {
setActiveLang(data.items[0].language); setActiveLang(data.items[0].language);
} }
setPopulated(true); setPopulated(true);
}, [data, populated]); }, [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({ const saveMutation = useMutation({
mutationFn: () => mutationFn: () =>
@@ -273,7 +308,10 @@ function RulesEditor() {
<label className="label">{t('admin.legalPages.content')}</label> <label className="label">{t('admin.legalPages.content')}</label>
<textarea <textarea
value={contents[activeLang] ?? ''} value={contents[activeLang] ?? ''}
onChange={(e) => setContents((prev) => ({ ...prev, [activeLang]: e.target.value }))} onChange={(e) => {
setSaveError(null);
setContents((prev) => ({ ...prev, [activeLang]: e.target.value }));
}}
rows={16} rows={16}
className="input min-h-[320px] w-full font-mono text-sm" className="input min-h-[320px] w-full font-mono text-sm"
placeholder={t('admin.legalPages.contentPlaceholder')} placeholder={t('admin.legalPages.contentPlaceholder')}
@@ -299,6 +337,7 @@ function FaqQuestionRow({
onMoveDown, onMoveDown,
onDelete, onDelete,
onSaved, onSaved,
onDirtyChange,
}: { }: {
page: FaqPageItem; page: FaqPageItem;
canMoveUp: boolean; canMoveUp: boolean;
@@ -307,20 +346,34 @@ function FaqQuestionRow({
onMoveDown: () => void; onMoveDown: () => void;
onDelete: () => void; onDelete: () => void;
onSaved: () => void; onSaved: () => void;
onDirtyChange: (id: number, dirty: boolean) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const haptic = useHapticFeedback(); const haptic = useHapticFeedback();
const [title, setTitle] = useState(page.title); const [title, setTitle] = useState(page.title);
const [content, setContent] = useState(page.content); const [content, setContent] = useState(page.content);
const [isActive, setIsActive] = useState(page.is_active); const [isActive, setIsActive] = useState(page.is_active);
const [saveError, setSaveError] = useState<string | null>(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({ const saveMutation = useMutation({
mutationFn: () => mutationFn: () =>
adminLegalPagesApi.updateFaqPage(page.id, { title, content, is_active: isActive }), adminLegalPagesApi.updateFaqPage(page.id, { title, content, is_active: isActive }),
onSuccess: () => { onSuccess: () => {
haptic.success(); haptic.success();
setSaveError(null);
onSaved(); onSaved();
}, },
onError: (err) => {
haptic.error();
setSaveError(extractErrorDetail(err) ?? t('admin.legalPages.saveError'));
},
}); });
return ( return (
@@ -328,7 +381,10 @@ function FaqQuestionRow({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => {
setSaveError(null);
setTitle(e.target.value);
}}
className="input flex-1" className="input flex-1"
placeholder={t('admin.legalPages.questionTitle')} placeholder={t('admin.legalPages.questionTitle')}
/> />
@@ -366,11 +422,15 @@ function FaqQuestionRow({
</div> </div>
<textarea <textarea
value={content} value={content}
onChange={(e) => setContent(e.target.value)} onChange={(e) => {
setSaveError(null);
setContent(e.target.value);
}}
rows={4} rows={4}
className="input w-full font-mono text-sm" className="input w-full font-mono text-sm"
placeholder={t('admin.legalPages.questionContent')} placeholder={t('admin.legalPages.questionContent')}
/> />
{saveError && <p className="text-sm text-error-400">{saveError}</p>}
<button <button
onClick={() => saveMutation.mutate()} onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending} disabled={saveMutation.isPending}
@@ -382,7 +442,7 @@ function FaqQuestionRow({
); );
} }
function FaqEditor() { function FaqEditor({ onDirtyChange }: { onDirtyChange: (dirty: boolean) => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const haptic = useHapticFeedback(); const haptic = useHapticFeedback();
@@ -391,21 +451,36 @@ function FaqEditor() {
const [activeLang, setActiveLang] = useState('ru'); const [activeLang, setActiveLang] = useState('ru');
const [populated, setPopulated] = useState(false); const [populated, setPopulated] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
const [dirtyRows, setDirtyRows] = useState<number[]>([]);
const { data, isLoading } = useQuery({ const { data, isLoading, isFetching } = useQuery({
queryKey: ['admin', 'legal-pages', 'faq'], queryKey: ['admin', 'legal-pages', 'faq'],
queryFn: adminLegalPagesApi.getFaq, queryFn: adminLegalPagesApi.getFaq,
staleTime: 0, staleTime: 0,
gcTime: 0,
}); });
useEffect(() => { useEffect(() => {
if (!data || populated) return; if (!data || isFetching || populated) return;
setDisplayMode(data.display_mode); setDisplayMode(data.display_mode);
if (data.settings.length > 0 && !data.settings.some((s) => s.language === 'ru')) { if (data.settings.length > 0 && !data.settings.some((s) => s.language === 'ru')) {
setActiveLang(data.settings[0].language); setActiveLang(data.settings[0].language);
} }
setPopulated(true); setPopulated(true);
}, [data, populated]); }, [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 = () => const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ['admin', 'legal-pages', 'faq'] }); queryClient.invalidateQueries({ queryKey: ['admin', 'legal-pages', 'faq'] });
@@ -413,10 +488,10 @@ function FaqEditor() {
const settingsMutation = useMutation({ const settingsMutation = useMutation({
mutationFn: (payload: { display_mode?: LegalDisplayMode; settings?: FaqSettingItem[] }) => mutationFn: (payload: { display_mode?: LegalDisplayMode; settings?: FaqSettingItem[] }) =>
adminLegalPagesApi.updateFaq(payload), adminLegalPagesApi.updateFaq(payload),
onSuccess: () => { onSuccess: (resp) => {
haptic.success(); haptic.success();
setSaveError(null); setSaveError(null);
invalidate(); queryClient.setQueryData(['admin', 'legal-pages', 'faq'], resp);
}, },
onError: (err) => { onError: (err) => {
haptic.error(); haptic.error();
@@ -434,8 +509,13 @@ function FaqEditor() {
}), }),
onSuccess: () => { onSuccess: () => {
haptic.success(); haptic.success();
setSaveError(null);
invalidate(); invalidate();
}, },
onError: (err) => {
haptic.error();
setSaveError(extractErrorDetail(err) ?? t('admin.legalPages.saveError'));
},
}); });
const reorderMutation = useMutation({ const reorderMutation = useMutation({
@@ -445,6 +525,13 @@ function FaqEditor() {
}, },
onSuccess: () => { onSuccess: () => {
haptic.success(); haptic.success();
setSaveError(null);
},
onError: (err) => {
haptic.error();
setSaveError(extractErrorDetail(err) ?? t('admin.legalPages.saveError'));
},
onSettled: () => {
invalidate(); invalidate();
}, },
}); });
@@ -453,8 +540,13 @@ function FaqEditor() {
mutationFn: (id: number) => adminLegalPagesApi.deleteFaqPage(id), mutationFn: (id: number) => adminLegalPagesApi.deleteFaqPage(id),
onSuccess: () => { onSuccess: () => {
haptic.success(); haptic.success();
setSaveError(null);
invalidate(); invalidate();
}, },
onError: (err) => {
haptic.error();
setSaveError(extractErrorDetail(err) ?? t('admin.legalPages.saveError'));
},
}); });
if (isLoading || !data) { if (isLoading || !data) {
@@ -475,7 +567,15 @@ function FaqEditor() {
}} }}
disabled={data.display_mode_env_locked} disabled={data.display_mode_env_locked}
/> />
<LanguageTabs languages={languages} active={activeLang} onChange={setActiveLang} /> <LanguageTabs
languages={languages}
active={activeLang}
onChange={async (lang) => {
if (lang === activeLang) return;
if (hasDirtyRows && !(await confirm(t('admin.legalPages.unsavedWarning')))) return;
setActiveLang(lang);
}}
/>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Toggle <Toggle
checked={langEnabled} checked={langEnabled}
@@ -484,6 +584,7 @@ function FaqEditor() {
settings: [{ language: activeLang, is_enabled: !langEnabled }], settings: [{ language: activeLang, is_enabled: !langEnabled }],
}) })
} }
disabled={settingsMutation.isPending}
aria-label={t('admin.legalPages.enabled')} aria-label={t('admin.legalPages.enabled')}
/> />
<span className="text-sm text-dark-300">{t('admin.legalPages.enabled')}</span> <span className="text-sm text-dark-300">{t('admin.legalPages.enabled')}</span>
@@ -511,8 +612,8 @@ function FaqEditor() {
<FaqQuestionRow <FaqQuestionRow
key={page.id} key={page.id}
page={page} page={page}
canMoveUp={index > 0} canMoveUp={index > 0 && !reorderMutation.isPending}
canMoveDown={index < pages.length - 1} canMoveDown={index < pages.length - 1 && !reorderMutation.isPending}
onMoveUp={() => reorderMutation.mutate({ a: page, b: pages[index - 1] })} onMoveUp={() => reorderMutation.mutate({ a: page, b: pages[index - 1] })}
onMoveDown={() => reorderMutation.mutate({ a: page, b: pages[index + 1] })} onMoveDown={() => reorderMutation.mutate({ a: page, b: pages[index + 1] })}
onDelete={async () => { onDelete={async () => {
@@ -520,6 +621,7 @@ function FaqEditor() {
if (confirmed) deleteMutation.mutate(page.id); if (confirmed) deleteMutation.mutate(page.id);
}} }}
onSaved={invalidate} onSaved={invalidate}
onDirtyChange={handleRowDirtyChange}
/> />
)) ))
)} )}
@@ -530,7 +632,16 @@ function FaqEditor() {
export default function AdminLegalPages() { export default function AdminLegalPages() {
const { t } = useTranslation(); const { t } = useTranslation();
const confirm = useDestructiveConfirm();
const [activeTab, setActiveTab] = useState<LegalTab>('privacy'); const [activeTab, setActiveTab] = useState<LegalTab>('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 ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -547,7 +658,7 @@ export default function AdminLegalPages() {
<button <button
key={tab} key={tab}
type="button" type="button"
onClick={() => setActiveTab(tab)} onClick={() => handleTabChange(tab)}
className={cn( className={cn(
'min-h-[44px] rounded-lg px-4 py-2.5 text-sm font-medium transition-colors', 'min-h-[44px] rounded-lg px-4 py-2.5 text-sm font-medium transition-colors',
activeTab === tab activeTab === tab
@@ -560,10 +671,14 @@ export default function AdminLegalPages() {
))} ))}
</div> </div>
{activeTab === 'privacy' && <DocumentEditor key="privacy" kind="privacy-policy" />} {activeTab === 'privacy' && (
{activeTab === 'offer' && <DocumentEditor key="offer" kind="public-offer" />} <DocumentEditor key="privacy" kind="privacy-policy" onDirtyChange={setDirty} />
{activeTab === 'rules' && <RulesEditor />} )}
{activeTab === 'faq' && <FaqEditor />} {activeTab === 'offer' && (
<DocumentEditor key="offer" kind="public-offer" onDirtyChange={setDirty} />
)}
{activeTab === 'rules' && <RulesEditor onDirtyChange={setDirty} />}
{activeTab === 'faq' && <FaqEditor onDirtyChange={setDirty} />}
</div> </div>
); );
} }