mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
feat: FAQ pages — Q&A builder in admin, accordion view for users
Admin editor: - page_type selector: "Страница" / "FAQ" radio buttons - When FAQ: FaqBuilder component replaces TipTap editor - Question input + answer textarea per Q&A item - Add/remove items, up/down reorder buttons - Per-locale Q&A (stored as JSON array in content JSONB) - "Создать FAQ" button in list alongside "Создать страницу" - Filter tabs: Все / Страницы / FAQ - Page type badge on each row (accent for pages, amber for FAQ) Public view (InfoPageView): - Auto-detects page_type='faq' and renders FaqView component - Accordion with smooth height animation (300ms ease-in-out) - Search filter appears when >3 questions - DOMPurify sanitization on FAQ answers - Single-item expand (click to open/close) i18n: full FAQ keys for all 4 locales (questions, answers, search, counts, empty states, builder labels)
This commit is contained in:
@@ -1,10 +1,13 @@
|
|||||||
import apiClient from './client';
|
import apiClient from './client';
|
||||||
|
|
||||||
|
export type InfoPageType = 'page' | 'faq';
|
||||||
|
|
||||||
export interface InfoPage {
|
export interface InfoPage {
|
||||||
id: number;
|
id: number;
|
||||||
slug: string;
|
slug: string;
|
||||||
title: Record<string, string>;
|
title: Record<string, string>;
|
||||||
content: Record<string, string>;
|
content: Record<string, string>;
|
||||||
|
page_type: InfoPageType;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
@@ -16,6 +19,7 @@ export interface InfoPageListItem {
|
|||||||
id: number;
|
id: number;
|
||||||
slug: string;
|
slug: string;
|
||||||
title: Record<string, string>;
|
title: Record<string, string>;
|
||||||
|
page_type: InfoPageType;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
@@ -26,6 +30,7 @@ export interface InfoPageCreateRequest {
|
|||||||
slug: string;
|
slug: string;
|
||||||
title: Record<string, string>;
|
title: Record<string, string>;
|
||||||
content: Record<string, string>;
|
content: Record<string, string>;
|
||||||
|
page_type: InfoPageType;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
@@ -35,19 +40,27 @@ export interface InfoPageUpdateRequest {
|
|||||||
slug?: string;
|
slug?: string;
|
||||||
title?: Record<string, string>;
|
title?: Record<string, string>;
|
||||||
content?: Record<string, string>;
|
content?: Record<string, string>;
|
||||||
|
page_type?: InfoPageType;
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
sort_order?: number;
|
sort_order?: number;
|
||||||
icon?: string | null;
|
icon?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Single FAQ Q&A item stored in content JSONB. */
|
||||||
|
export interface FaqItem {
|
||||||
|
q: string;
|
||||||
|
a: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface InfoPageReorderRequest {
|
export interface InfoPageReorderRequest {
|
||||||
items: Array<{ id: number; sort_order: number }>;
|
items: Array<{ id: number; sort_order: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const infoPagesApi = {
|
export const infoPagesApi = {
|
||||||
// Public endpoints
|
// Public endpoints
|
||||||
getPages: async (): Promise<InfoPageListItem[]> => {
|
getPages: async (pageType?: InfoPageType): Promise<InfoPageListItem[]> => {
|
||||||
const response = await apiClient.get<InfoPageListItem[]>('/cabinet/info-pages');
|
const params = pageType ? { page_type: pageType } : undefined;
|
||||||
|
const response = await apiClient.get<InfoPageListItem[]>('/cabinet/info-pages', { params });
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -59,8 +72,11 @@ export const infoPagesApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Admin endpoints
|
// Admin endpoints
|
||||||
getAdminPages: async (): Promise<InfoPageListItem[]> => {
|
getAdminPages: async (pageType?: InfoPageType): Promise<InfoPageListItem[]> => {
|
||||||
const response = await apiClient.get<InfoPageListItem[]>('/cabinet/admin/info-pages');
|
const params = pageType ? { page_type: pageType } : undefined;
|
||||||
|
const response = await apiClient.get<InfoPageListItem[]>('/cabinet/admin/info-pages', {
|
||||||
|
params,
|
||||||
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -3804,6 +3804,7 @@
|
|||||||
"title": "Information Pages",
|
"title": "Information Pages",
|
||||||
"subtitle": "Manage static information pages",
|
"subtitle": "Manage static information pages",
|
||||||
"create": "Create Page",
|
"create": "Create Page",
|
||||||
|
"createFaq": "Create FAQ",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"saving": "Saving...",
|
"saving": "Saving...",
|
||||||
@@ -3815,6 +3816,7 @@
|
|||||||
"notFound": "Page not found",
|
"notFound": "Page not found",
|
||||||
"active": "Active",
|
"active": "Active",
|
||||||
"inactive": "Inactive",
|
"inactive": "Inactive",
|
||||||
|
"typePage": "Page",
|
||||||
"localeLabel": "Content Language",
|
"localeLabel": "Content Language",
|
||||||
"fields": {
|
"fields": {
|
||||||
"slug": "URL Slug",
|
"slug": "URL Slug",
|
||||||
@@ -3822,7 +3824,34 @@
|
|||||||
"content": "Content",
|
"content": "Content",
|
||||||
"isActive": "Active",
|
"isActive": "Active",
|
||||||
"sortOrder": "Sort Order",
|
"sortOrder": "Sort Order",
|
||||||
"icon": "Icon (emoji)"
|
"icon": "Icon (emoji)",
|
||||||
|
"pageType": "Page Type"
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"all": "All",
|
||||||
|
"page": "Pages",
|
||||||
|
"faq": "FAQ"
|
||||||
|
},
|
||||||
|
"pageTypes": {
|
||||||
|
"page": "Page",
|
||||||
|
"faq": "FAQ"
|
||||||
|
},
|
||||||
|
"faq": {
|
||||||
|
"questions": "Questions & Answers",
|
||||||
|
"questionsCount": "questions",
|
||||||
|
"noQuestions": "No questions yet. Click the button below to add one.",
|
||||||
|
"questionNumber": "Question #{{n}}",
|
||||||
|
"question": "Question",
|
||||||
|
"answer": "Answer",
|
||||||
|
"questionPlaceholder": "Enter question...",
|
||||||
|
"answerPlaceholder": "Enter answer (HTML supported)...",
|
||||||
|
"htmlHint": "HTML markup is supported",
|
||||||
|
"addQuestion": "Add Question",
|
||||||
|
"removeQuestion": "Remove question",
|
||||||
|
"moveUp": "Move up",
|
||||||
|
"moveDown": "Move down",
|
||||||
|
"searchPlaceholder": "Search questions...",
|
||||||
|
"noResults": "No results found"
|
||||||
},
|
},
|
||||||
"locales": {
|
"locales": {
|
||||||
"ru": "Russian",
|
"ru": "Russian",
|
||||||
|
|||||||
@@ -3372,6 +3372,7 @@
|
|||||||
"title": "صفحات اطلاعات",
|
"title": "صفحات اطلاعات",
|
||||||
"subtitle": "مدیریت صفحات اطلاعات ثابت",
|
"subtitle": "مدیریت صفحات اطلاعات ثابت",
|
||||||
"create": "ایجاد صفحه",
|
"create": "ایجاد صفحه",
|
||||||
|
"createFaq": "ایجاد FAQ",
|
||||||
"edit": "ویرایش",
|
"edit": "ویرایش",
|
||||||
"save": "ذخیره",
|
"save": "ذخیره",
|
||||||
"saving": "در حال ذخیره...",
|
"saving": "در حال ذخیره...",
|
||||||
@@ -3383,6 +3384,7 @@
|
|||||||
"notFound": "صفحه پیدا نشد",
|
"notFound": "صفحه پیدا نشد",
|
||||||
"active": "فعال",
|
"active": "فعال",
|
||||||
"inactive": "غیرفعال",
|
"inactive": "غیرفعال",
|
||||||
|
"typePage": "صفحه",
|
||||||
"localeLabel": "زبان محتوا",
|
"localeLabel": "زبان محتوا",
|
||||||
"fields": {
|
"fields": {
|
||||||
"slug": "شناسه URL",
|
"slug": "شناسه URL",
|
||||||
@@ -3390,7 +3392,34 @@
|
|||||||
"content": "محتوا",
|
"content": "محتوا",
|
||||||
"isActive": "فعال",
|
"isActive": "فعال",
|
||||||
"sortOrder": "ترتیب",
|
"sortOrder": "ترتیب",
|
||||||
"icon": "آیکون (ایموجی)"
|
"icon": "آیکون (ایموجی)",
|
||||||
|
"pageType": "نوع صفحه"
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"all": "همه",
|
||||||
|
"page": "صفحات",
|
||||||
|
"faq": "FAQ"
|
||||||
|
},
|
||||||
|
"pageTypes": {
|
||||||
|
"page": "صفحه",
|
||||||
|
"faq": "FAQ"
|
||||||
|
},
|
||||||
|
"faq": {
|
||||||
|
"questions": "سوالات و پاسخها",
|
||||||
|
"questionsCount": "سوال",
|
||||||
|
"noQuestions": "هنوز سوالی وجود ندارد. روی دکمه زیر کلیک کنید.",
|
||||||
|
"questionNumber": "سوال #{{n}}",
|
||||||
|
"question": "سوال",
|
||||||
|
"answer": "پاسخ",
|
||||||
|
"questionPlaceholder": "سوال را وارد کنید...",
|
||||||
|
"answerPlaceholder": "پاسخ را وارد کنید (HTML پشتیبانی میشود)...",
|
||||||
|
"htmlHint": "نشانهگذاری HTML پشتیبانی میشود",
|
||||||
|
"addQuestion": "افزودن سوال",
|
||||||
|
"removeQuestion": "حذف سوال",
|
||||||
|
"moveUp": "انتقال به بالا",
|
||||||
|
"moveDown": "انتقال به پایین",
|
||||||
|
"searchPlaceholder": "جستجو در سوالات...",
|
||||||
|
"noResults": "نتیجهای یافت نشد"
|
||||||
},
|
},
|
||||||
"locales": {
|
"locales": {
|
||||||
"ru": "روسی",
|
"ru": "روسی",
|
||||||
|
|||||||
@@ -4348,6 +4348,7 @@
|
|||||||
"title": "Информационные страницы",
|
"title": "Информационные страницы",
|
||||||
"subtitle": "Управление статическими информационными страницами",
|
"subtitle": "Управление статическими информационными страницами",
|
||||||
"create": "Создать страницу",
|
"create": "Создать страницу",
|
||||||
|
"createFaq": "Создать FAQ",
|
||||||
"edit": "Редактировать",
|
"edit": "Редактировать",
|
||||||
"save": "Сохранить",
|
"save": "Сохранить",
|
||||||
"saving": "Сохранение...",
|
"saving": "Сохранение...",
|
||||||
@@ -4359,6 +4360,7 @@
|
|||||||
"notFound": "Страница не найдена",
|
"notFound": "Страница не найдена",
|
||||||
"active": "Активна",
|
"active": "Активна",
|
||||||
"inactive": "Неактивна",
|
"inactive": "Неактивна",
|
||||||
|
"typePage": "Страница",
|
||||||
"localeLabel": "Язык контента",
|
"localeLabel": "Язык контента",
|
||||||
"fields": {
|
"fields": {
|
||||||
"slug": "URL-адрес",
|
"slug": "URL-адрес",
|
||||||
@@ -4366,7 +4368,34 @@
|
|||||||
"content": "Содержание",
|
"content": "Содержание",
|
||||||
"isActive": "Активна",
|
"isActive": "Активна",
|
||||||
"sortOrder": "Порядок сортировки",
|
"sortOrder": "Порядок сортировки",
|
||||||
"icon": "Иконка (эмодзи)"
|
"icon": "Иконка (эмодзи)",
|
||||||
|
"pageType": "Тип страницы"
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"all": "Все",
|
||||||
|
"page": "Страницы",
|
||||||
|
"faq": "FAQ"
|
||||||
|
},
|
||||||
|
"pageTypes": {
|
||||||
|
"page": "Страница",
|
||||||
|
"faq": "FAQ"
|
||||||
|
},
|
||||||
|
"faq": {
|
||||||
|
"questions": "Вопросы и ответы",
|
||||||
|
"questionsCount": "вопросов",
|
||||||
|
"noQuestions": "Вопросов пока нет. Нажмите кнопку ниже, чтобы добавить.",
|
||||||
|
"questionNumber": "Вопрос #{{n}}",
|
||||||
|
"question": "Вопрос",
|
||||||
|
"answer": "Ответ",
|
||||||
|
"questionPlaceholder": "Введите вопрос...",
|
||||||
|
"answerPlaceholder": "Введите ответ (поддерживается HTML)...",
|
||||||
|
"htmlHint": "Поддерживается HTML-разметка",
|
||||||
|
"addQuestion": "Добавить вопрос",
|
||||||
|
"removeQuestion": "Удалить вопрос",
|
||||||
|
"moveUp": "Переместить вверх",
|
||||||
|
"moveDown": "Переместить вниз",
|
||||||
|
"searchPlaceholder": "Поиск по вопросам...",
|
||||||
|
"noResults": "Ничего не найдено"
|
||||||
},
|
},
|
||||||
"locales": {
|
"locales": {
|
||||||
"ru": "Русский",
|
"ru": "Русский",
|
||||||
|
|||||||
@@ -3371,6 +3371,7 @@
|
|||||||
"title": "信息页面",
|
"title": "信息页面",
|
||||||
"subtitle": "管理静态信息页面",
|
"subtitle": "管理静态信息页面",
|
||||||
"create": "创建页面",
|
"create": "创建页面",
|
||||||
|
"createFaq": "创建FAQ",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
"saving": "保存中...",
|
"saving": "保存中...",
|
||||||
@@ -3382,6 +3383,7 @@
|
|||||||
"notFound": "页面未找到",
|
"notFound": "页面未找到",
|
||||||
"active": "已激活",
|
"active": "已激活",
|
||||||
"inactive": "未激活",
|
"inactive": "未激活",
|
||||||
|
"typePage": "页面",
|
||||||
"localeLabel": "内容语言",
|
"localeLabel": "内容语言",
|
||||||
"fields": {
|
"fields": {
|
||||||
"slug": "URL标识",
|
"slug": "URL标识",
|
||||||
@@ -3389,7 +3391,34 @@
|
|||||||
"content": "内容",
|
"content": "内容",
|
||||||
"isActive": "已激活",
|
"isActive": "已激活",
|
||||||
"sortOrder": "排序",
|
"sortOrder": "排序",
|
||||||
"icon": "图标(表情)"
|
"icon": "图标(表情)",
|
||||||
|
"pageType": "页面类型"
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"all": "全部",
|
||||||
|
"page": "页面",
|
||||||
|
"faq": "FAQ"
|
||||||
|
},
|
||||||
|
"pageTypes": {
|
||||||
|
"page": "页面",
|
||||||
|
"faq": "FAQ"
|
||||||
|
},
|
||||||
|
"faq": {
|
||||||
|
"questions": "问答",
|
||||||
|
"questionsCount": "个问题",
|
||||||
|
"noQuestions": "暂无问题。点击下方按钮添加。",
|
||||||
|
"questionNumber": "问题 #{{n}}",
|
||||||
|
"question": "问题",
|
||||||
|
"answer": "答案",
|
||||||
|
"questionPlaceholder": "输入问题...",
|
||||||
|
"answerPlaceholder": "输入答案(支持HTML)...",
|
||||||
|
"htmlHint": "支持HTML标记",
|
||||||
|
"addQuestion": "添加问题",
|
||||||
|
"removeQuestion": "删除问题",
|
||||||
|
"moveUp": "上移",
|
||||||
|
"moveDown": "下移",
|
||||||
|
"searchPlaceholder": "搜索问题...",
|
||||||
|
"noResults": "未找到结果"
|
||||||
},
|
},
|
||||||
"locales": {
|
"locales": {
|
||||||
"ru": "俄语",
|
"ru": "俄语",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router';
|
import { useNavigate, useParams, useSearchParams } from 'react-router';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useEditor, EditorContent } from '@tiptap/react';
|
import { useEditor, EditorContent } from '@tiptap/react';
|
||||||
@@ -17,6 +17,7 @@ import { AdminBackButton } from '../components/admin';
|
|||||||
import { Toggle } from '../components/admin/Toggle';
|
import { Toggle } from '../components/admin/Toggle';
|
||||||
import { useHapticFeedback } from '../platform/hooks/useHaptic';
|
import { useHapticFeedback } from '../platform/hooks/useHaptic';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
|
import type { InfoPageType, FaqItem } from '../api/infoPages';
|
||||||
|
|
||||||
const AVAILABLE_LOCALES = ['ru', 'en', 'zh', 'fa'] as const;
|
const AVAILABLE_LOCALES = ['ru', 'en', 'zh', 'fa'] as const;
|
||||||
type LocaleCode = (typeof AVAILABLE_LOCALES)[number];
|
type LocaleCode = (typeof AVAILABLE_LOCALES)[number];
|
||||||
@@ -204,14 +205,203 @@ function generateSlug(title: string): string {
|
|||||||
.substring(0, 100);
|
.substring(0, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- FAQ Q&A Item Icons ---
|
||||||
|
const ChevronUpIcon = () => (
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ChevronDownIcon = () => (
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const TrashSmallIcon = () => (
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PlusSmallIcon = () => (
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- FAQ Q&A Builder ---
|
||||||
|
interface FaqBuilderProps {
|
||||||
|
items: FaqItem[];
|
||||||
|
onChange: (items: FaqItem[]) => void;
|
||||||
|
locale: string;
|
||||||
|
localeLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FaqBuilder({ items, onChange, locale, localeLabel }: FaqBuilderProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const handleQuestionChange = useCallback(
|
||||||
|
(index: number, value: string) => {
|
||||||
|
const updated = items.map((item, i) => (i === index ? { ...item, q: value } : item));
|
||||||
|
onChange(updated);
|
||||||
|
},
|
||||||
|
[items, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAnswerChange = useCallback(
|
||||||
|
(index: number, value: string) => {
|
||||||
|
const updated = items.map((item, i) => (i === index ? { ...item, a: value } : item));
|
||||||
|
onChange(updated);
|
||||||
|
},
|
||||||
|
[items, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRemove = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
onChange(items.filter((_, i) => i !== index));
|
||||||
|
},
|
||||||
|
[items, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAdd = useCallback(() => {
|
||||||
|
onChange([...items, { q: '', a: '' }]);
|
||||||
|
}, [items, onChange]);
|
||||||
|
|
||||||
|
const handleMoveUp = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
if (index === 0) return;
|
||||||
|
const updated = [...items];
|
||||||
|
[updated[index - 1], updated[index]] = [updated[index], updated[index - 1]];
|
||||||
|
onChange(updated);
|
||||||
|
},
|
||||||
|
[items, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMoveDown = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
if (index >= items.length - 1) return;
|
||||||
|
const updated = [...items];
|
||||||
|
[updated[index], updated[index + 1]] = [updated[index + 1], updated[index]];
|
||||||
|
onChange(updated);
|
||||||
|
},
|
||||||
|
[items, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
void locale;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="label">
|
||||||
|
{t('admin.infoPages.faq.questions')} ({localeLabel})
|
||||||
|
</label>
|
||||||
|
<span className="text-xs text-dark-500">
|
||||||
|
{items.length} {t('admin.infoPages.faq.questionsCount')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length === 0 && (
|
||||||
|
<div className="rounded-xl border border-dashed border-dark-700 bg-dark-800/30 p-6 text-center text-sm text-dark-500">
|
||||||
|
{t('admin.infoPages.faq.noQuestions')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="rounded-xl border border-dark-700 bg-dark-800/50 p-4 transition-all hover:border-dark-600"
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-dark-400">
|
||||||
|
{t('admin.infoPages.faq.questionNumber', { n: index + 1 })}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleMoveUp(index)}
|
||||||
|
disabled={index === 0}
|
||||||
|
className="min-h-[36px] min-w-[36px] rounded-lg p-2 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:cursor-not-allowed disabled:opacity-30"
|
||||||
|
title={t('admin.infoPages.faq.moveUp')}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleMoveDown(index)}
|
||||||
|
disabled={index >= items.length - 1}
|
||||||
|
className="min-h-[36px] min-w-[36px] rounded-lg p-2 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:cursor-not-allowed disabled:opacity-30"
|
||||||
|
title={t('admin.infoPages.faq.moveDown')}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemove(index)}
|
||||||
|
className="min-h-[36px] min-w-[36px] rounded-lg p-2 text-dark-400 transition-colors hover:bg-error-500/10 hover:text-error-400"
|
||||||
|
title={t('admin.infoPages.faq.removeQuestion')}
|
||||||
|
>
|
||||||
|
<TrashSmallIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-dark-400">
|
||||||
|
{t('admin.infoPages.faq.question')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={item.q}
|
||||||
|
onChange={(e) => handleQuestionChange(index, e.target.value)}
|
||||||
|
className="input text-sm"
|
||||||
|
placeholder={t('admin.infoPages.faq.questionPlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-dark-400">
|
||||||
|
{t('admin.infoPages.faq.answer')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={item.a}
|
||||||
|
onChange={(e) => handleAnswerChange(index, e.target.value)}
|
||||||
|
className="input min-h-[80px] text-sm"
|
||||||
|
placeholder={t('admin.infoPages.faq.answerPlaceholder')}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-[10px] text-dark-500">{t('admin.infoPages.faq.htmlHint')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAdd}
|
||||||
|
className="flex min-h-[44px] w-full items-center justify-center gap-2 rounded-xl border border-dashed border-dark-600 bg-dark-800/30 py-3 text-sm font-medium text-dark-300 transition-colors hover:border-dark-500 hover:bg-dark-800/50 hover:text-dark-100"
|
||||||
|
>
|
||||||
|
<PlusSmallIcon />
|
||||||
|
{t('admin.infoPages.faq.addQuestion')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminInfoPageEditor() {
|
export default function AdminInfoPageEditor() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { id: rawId } = useParams<{ id: string }>();
|
const { id: rawId } = useParams<{ id: string }>();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const haptic = useHapticFeedback();
|
const haptic = useHapticFeedback();
|
||||||
const pageId = rawId != null ? Number(rawId) : undefined;
|
const pageId = rawId != null ? Number(rawId) : undefined;
|
||||||
const isEdit = pageId != null && !Number.isNaN(pageId);
|
const isEdit = pageId != null && !Number.isNaN(pageId);
|
||||||
|
const initialPageType = (searchParams.get('type') === 'faq' ? 'faq' : 'page') as InfoPageType;
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [slug, setSlug] = useState('');
|
const [slug, setSlug] = useState('');
|
||||||
@@ -219,8 +409,12 @@ export default function AdminInfoPageEditor() {
|
|||||||
const [icon, setIcon] = useState('');
|
const [icon, setIcon] = useState('');
|
||||||
const [isActive, setIsActive] = useState(true);
|
const [isActive, setIsActive] = useState(true);
|
||||||
const [sortOrder, setSortOrder] = useState(0);
|
const [sortOrder, setSortOrder] = useState(0);
|
||||||
|
const [pageType, setPageType] = useState<InfoPageType>(initialPageType);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// FAQ Q&A state per locale
|
||||||
|
const [faqItems, setFaqItems] = useState<Record<string, FaqItem[]>>({});
|
||||||
|
|
||||||
// Multi-locale state
|
// Multi-locale state
|
||||||
const [activeLocale, setActiveLocale] = useState<LocaleCode>('ru');
|
const [activeLocale, setActiveLocale] = useState<LocaleCode>('ru');
|
||||||
const [titles, setTitles] = useState<Record<string, string>>({});
|
const [titles, setTitles] = useState<Record<string, string>>({});
|
||||||
@@ -411,7 +605,7 @@ export default function AdminInfoPageEditor() {
|
|||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Populate form when page data loads (once only — not on locale switch)
|
// Populate form when page data loads (once only -- not on locale switch)
|
||||||
const editorPopulated = useRef(false);
|
const editorPopulated = useRef(false);
|
||||||
const formPopulated = useRef(false);
|
const formPopulated = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -421,8 +615,26 @@ export default function AdminInfoPageEditor() {
|
|||||||
setIcon(pageData.icon ?? '');
|
setIcon(pageData.icon ?? '');
|
||||||
setIsActive(pageData.is_active);
|
setIsActive(pageData.is_active);
|
||||||
setSortOrder(pageData.sort_order);
|
setSortOrder(pageData.sort_order);
|
||||||
|
setPageType(pageData.page_type ?? 'page');
|
||||||
setTitles(pageData.title);
|
setTitles(pageData.title);
|
||||||
setContents(pageData.content);
|
|
||||||
|
if (pageData.page_type === 'faq') {
|
||||||
|
// For FAQ pages, content stores Q&A arrays per locale
|
||||||
|
const parsed: Record<string, FaqItem[]> = {};
|
||||||
|
for (const [loc, val] of Object.entries(pageData.content)) {
|
||||||
|
try {
|
||||||
|
const arr = typeof val === 'string' ? JSON.parse(val) : val;
|
||||||
|
parsed[loc] = Array.isArray(arr) ? arr : [];
|
||||||
|
} catch {
|
||||||
|
parsed[loc] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setFaqItems(parsed);
|
||||||
|
setContents({});
|
||||||
|
} else {
|
||||||
|
setContents(pageData.content);
|
||||||
|
}
|
||||||
|
|
||||||
formPopulated.current = true;
|
formPopulated.current = true;
|
||||||
}, [pageData]);
|
}, [pageData]);
|
||||||
|
|
||||||
@@ -466,6 +678,7 @@ export default function AdminInfoPageEditor() {
|
|||||||
slug: string;
|
slug: string;
|
||||||
title: Record<string, string>;
|
title: Record<string, string>;
|
||||||
content: Record<string, string>;
|
content: Record<string, string>;
|
||||||
|
page_type: InfoPageType;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
@@ -491,18 +704,29 @@ export default function AdminInfoPageEditor() {
|
|||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
if (!slug.trim()) return;
|
if (!slug.trim()) return;
|
||||||
|
|
||||||
// Capture current editor content for the active locale
|
let finalContents: Record<string, string>;
|
||||||
const currentHtml = editor?.getHTML() ?? '';
|
|
||||||
const isEmpty = currentHtml === '<p></p>' || currentHtml === '';
|
if (pageType === 'faq') {
|
||||||
const finalContents = {
|
// Serialize FAQ items as JSON strings per locale
|
||||||
...contents,
|
finalContents = {};
|
||||||
[activeLocale]: isEmpty ? '' : currentHtml,
|
for (const [loc, items] of Object.entries(faqItems)) {
|
||||||
};
|
finalContents[loc] = JSON.stringify(items);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Capture current editor content for the active locale
|
||||||
|
const currentHtml = editor?.getHTML() ?? '';
|
||||||
|
const isEmpty = currentHtml === '<p></p>' || currentHtml === '';
|
||||||
|
finalContents = {
|
||||||
|
...contents,
|
||||||
|
[activeLocale]: isEmpty ? '' : currentHtml,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
slug: slug.trim(),
|
slug: slug.trim(),
|
||||||
title: titles,
|
title: titles,
|
||||||
content: finalContents,
|
content: finalContents,
|
||||||
|
page_type: pageType,
|
||||||
is_active: isActive,
|
is_active: isActive,
|
||||||
sort_order: sortOrder,
|
sort_order: sortOrder,
|
||||||
icon: icon.trim() || null,
|
icon: icon.trim() || null,
|
||||||
@@ -606,6 +830,30 @@ export default function AdminInfoPageEditor() {
|
|||||||
<span className="text-sm text-dark-300">{t('admin.infoPages.fields.isActive')}</span>
|
<span className="text-sm text-dark-300">{t('admin.infoPages.fields.isActive')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Page type selector */}
|
||||||
|
<div>
|
||||||
|
<label className="label">{t('admin.infoPages.fields.pageType')}</label>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(['page', 'faq'] as const).map((pt) => (
|
||||||
|
<button
|
||||||
|
key={pt}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPageType(pt)}
|
||||||
|
className={cn(
|
||||||
|
'min-h-[44px] rounded-lg px-4 py-2.5 text-sm font-medium transition-colors',
|
||||||
|
pageType === pt
|
||||||
|
? pt === 'faq'
|
||||||
|
? 'bg-warning-500 text-white'
|
||||||
|
: 'bg-accent-500 text-white'
|
||||||
|
: 'bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-dark-100',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t(`admin.infoPages.pageTypes.${pt}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Locale tabs */}
|
{/* Locale tabs */}
|
||||||
<div>
|
<div>
|
||||||
<label className="label">{t('admin.infoPages.localeLabel')}</label>
|
<label className="label">{t('admin.infoPages.localeLabel')}</label>
|
||||||
@@ -641,182 +889,191 @@ export default function AdminInfoPageEditor() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content editor */}
|
{/* Content: TipTap editor for pages, FAQ builder for FAQ */}
|
||||||
<div>
|
{pageType === 'faq' ? (
|
||||||
<label className="label">
|
<FaqBuilder
|
||||||
{t('admin.infoPages.fields.content')} ({t(`admin.infoPages.locales.${activeLocale}`)})
|
items={faqItems[activeLocale] ?? []}
|
||||||
</label>
|
onChange={(items) => setFaqItems((prev) => ({ ...prev, [activeLocale]: items }))}
|
||||||
<div
|
locale={activeLocale}
|
||||||
className="relative overflow-hidden rounded-xl border border-dark-700 bg-dark-800/50"
|
localeLabel={t(`admin.infoPages.locales.${activeLocale}`)}
|
||||||
onDragOver={handleEditorDragOver}
|
/>
|
||||||
onDragLeave={handleEditorDragLeave}
|
) : (
|
||||||
onDrop={handleEditorDrop}
|
<div>
|
||||||
>
|
<label className="label">
|
||||||
{/* Upload progress overlay */}
|
{t('admin.infoPages.fields.content')} ({t(`admin.infoPages.locales.${activeLocale}`)})
|
||||||
{isUploading && (
|
</label>
|
||||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-xl bg-dark-900/60 backdrop-blur-sm">
|
<div
|
||||||
<div className="flex flex-col items-center gap-3">
|
className="relative overflow-hidden rounded-xl border border-dark-700 bg-dark-800/50"
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-400 border-t-transparent" />
|
onDragOver={handleEditorDragOver}
|
||||||
<span className="text-sm font-medium text-dark-200">
|
onDragLeave={handleEditorDragLeave}
|
||||||
{t('news.admin.uploading')}
|
onDrop={handleEditorDrop}
|
||||||
|
>
|
||||||
|
{/* Upload progress overlay */}
|
||||||
|
{isUploading && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-xl bg-dark-900/60 backdrop-blur-sm">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-400 border-t-transparent" />
|
||||||
|
<span className="text-sm font-medium text-dark-200">
|
||||||
|
{t('news.admin.uploading')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Drag overlay */}
|
||||||
|
{isDragging && !isUploading && (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-xl border-2 border-dashed border-accent-400 bg-accent-400/10">
|
||||||
|
<span className="text-sm font-semibold text-accent-400">
|
||||||
|
{t('news.admin.dropMedia')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Drag overlay */}
|
{/* Toolbar */}
|
||||||
{isDragging && !isUploading && (
|
{editor && (
|
||||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-xl border-2 border-dashed border-accent-400 bg-accent-400/10">
|
<div className="flex flex-wrap items-center gap-0.5 border-b border-dark-700 bg-dark-800 p-2">
|
||||||
<span className="text-sm font-semibold text-accent-400">
|
<ToolbarButton
|
||||||
{t('news.admin.dropMedia')}
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||||
</span>
|
isActive={editor.isActive('bold')}
|
||||||
</div>
|
title={t('news.admin.toolbar.bold')}
|
||||||
)}
|
>
|
||||||
|
<BoldIcon />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||||
|
isActive={editor.isActive('italic')}
|
||||||
|
title={t('news.admin.toolbar.italic')}
|
||||||
|
>
|
||||||
|
<ItalicIcon />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||||
|
isActive={editor.isActive('underline')}
|
||||||
|
title={t('news.admin.toolbar.underline')}
|
||||||
|
>
|
||||||
|
<UnderlineIcon />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||||
|
isActive={editor.isActive('strike')}
|
||||||
|
title={t('news.admin.toolbar.strikethrough')}
|
||||||
|
>
|
||||||
|
<StrikeIcon />
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
{/* Toolbar */}
|
<div className="mx-1 h-5 w-px bg-dark-700" />
|
||||||
{editor && (
|
|
||||||
<div className="flex flex-wrap items-center gap-0.5 border-b border-dark-700 bg-dark-800 p-2">
|
|
||||||
<ToolbarButton
|
|
||||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
|
||||||
isActive={editor.isActive('bold')}
|
|
||||||
title={t('news.admin.toolbar.bold')}
|
|
||||||
>
|
|
||||||
<BoldIcon />
|
|
||||||
</ToolbarButton>
|
|
||||||
<ToolbarButton
|
|
||||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
|
||||||
isActive={editor.isActive('italic')}
|
|
||||||
title={t('news.admin.toolbar.italic')}
|
|
||||||
>
|
|
||||||
<ItalicIcon />
|
|
||||||
</ToolbarButton>
|
|
||||||
<ToolbarButton
|
|
||||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
|
||||||
isActive={editor.isActive('underline')}
|
|
||||||
title={t('news.admin.toolbar.underline')}
|
|
||||||
>
|
|
||||||
<UnderlineIcon />
|
|
||||||
</ToolbarButton>
|
|
||||||
<ToolbarButton
|
|
||||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
|
||||||
isActive={editor.isActive('strike')}
|
|
||||||
title={t('news.admin.toolbar.strikethrough')}
|
|
||||||
>
|
|
||||||
<StrikeIcon />
|
|
||||||
</ToolbarButton>
|
|
||||||
|
|
||||||
<div className="mx-1 h-5 w-px bg-dark-700" />
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||||
|
isActive={editor.isActive('heading', { level: 1 })}
|
||||||
|
title={t('news.admin.toolbar.heading1')}
|
||||||
|
>
|
||||||
|
<H1Icon />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||||
|
isActive={editor.isActive('heading', { level: 2 })}
|
||||||
|
title={t('news.admin.toolbar.heading2')}
|
||||||
|
>
|
||||||
|
<H2Icon />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||||
|
isActive={editor.isActive('heading', { level: 3 })}
|
||||||
|
title={t('news.admin.toolbar.heading3')}
|
||||||
|
>
|
||||||
|
<H3Icon />
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
<ToolbarButton
|
<div className="mx-1 h-5 w-px bg-dark-700" />
|
||||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
|
||||||
isActive={editor.isActive('heading', { level: 1 })}
|
|
||||||
title={t('news.admin.toolbar.heading1')}
|
|
||||||
>
|
|
||||||
<H1Icon />
|
|
||||||
</ToolbarButton>
|
|
||||||
<ToolbarButton
|
|
||||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
|
||||||
isActive={editor.isActive('heading', { level: 2 })}
|
|
||||||
title={t('news.admin.toolbar.heading2')}
|
|
||||||
>
|
|
||||||
<H2Icon />
|
|
||||||
</ToolbarButton>
|
|
||||||
<ToolbarButton
|
|
||||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
|
||||||
isActive={editor.isActive('heading', { level: 3 })}
|
|
||||||
title={t('news.admin.toolbar.heading3')}
|
|
||||||
>
|
|
||||||
<H3Icon />
|
|
||||||
</ToolbarButton>
|
|
||||||
|
|
||||||
<div className="mx-1 h-5 w-px bg-dark-700" />
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||||
|
isActive={editor.isActive('bulletList')}
|
||||||
|
title={t('news.admin.toolbar.bulletList')}
|
||||||
|
>
|
||||||
|
<ListBulletIcon />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||||
|
isActive={editor.isActive('orderedList')}
|
||||||
|
title={t('news.admin.toolbar.orderedList')}
|
||||||
|
>
|
||||||
|
<ListOrderedIcon />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||||
|
isActive={editor.isActive('blockquote')}
|
||||||
|
title={t('news.admin.toolbar.blockquote')}
|
||||||
|
>
|
||||||
|
<QuoteIcon />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||||
|
isActive={editor.isActive('codeBlock')}
|
||||||
|
title={t('news.admin.toolbar.codeBlock')}
|
||||||
|
>
|
||||||
|
<CodeBlockIcon />
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
<ToolbarButton
|
<div className="mx-1 h-5 w-px bg-dark-700" />
|
||||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
|
||||||
isActive={editor.isActive('bulletList')}
|
|
||||||
title={t('news.admin.toolbar.bulletList')}
|
|
||||||
>
|
|
||||||
<ListBulletIcon />
|
|
||||||
</ToolbarButton>
|
|
||||||
<ToolbarButton
|
|
||||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
|
||||||
isActive={editor.isActive('orderedList')}
|
|
||||||
title={t('news.admin.toolbar.orderedList')}
|
|
||||||
>
|
|
||||||
<ListOrderedIcon />
|
|
||||||
</ToolbarButton>
|
|
||||||
<ToolbarButton
|
|
||||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
|
||||||
isActive={editor.isActive('blockquote')}
|
|
||||||
title={t('news.admin.toolbar.blockquote')}
|
|
||||||
>
|
|
||||||
<QuoteIcon />
|
|
||||||
</ToolbarButton>
|
|
||||||
<ToolbarButton
|
|
||||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
|
||||||
isActive={editor.isActive('codeBlock')}
|
|
||||||
title={t('news.admin.toolbar.codeBlock')}
|
|
||||||
>
|
|
||||||
<CodeBlockIcon />
|
|
||||||
</ToolbarButton>
|
|
||||||
|
|
||||||
<div className="mx-1 h-5 w-px bg-dark-700" />
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().setTextAlign('left').run()}
|
||||||
|
isActive={editor.isActive({ textAlign: 'left' })}
|
||||||
|
title={t('news.admin.toolbar.alignLeft')}
|
||||||
|
>
|
||||||
|
<AlignLeftIcon />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().setTextAlign('center').run()}
|
||||||
|
isActive={editor.isActive({ textAlign: 'center' })}
|
||||||
|
title={t('news.admin.toolbar.alignCenter')}
|
||||||
|
>
|
||||||
|
<AlignCenterIcon />
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
<ToolbarButton
|
<div className="mx-1 h-5 w-px bg-dark-700" />
|
||||||
onClick={() => editor.chain().focus().setTextAlign('left').run()}
|
|
||||||
isActive={editor.isActive({ textAlign: 'left' })}
|
|
||||||
title={t('news.admin.toolbar.alignLeft')}
|
|
||||||
>
|
|
||||||
<AlignLeftIcon />
|
|
||||||
</ToolbarButton>
|
|
||||||
<ToolbarButton
|
|
||||||
onClick={() => editor.chain().focus().setTextAlign('center').run()}
|
|
||||||
isActive={editor.isActive({ textAlign: 'center' })}
|
|
||||||
title={t('news.admin.toolbar.alignCenter')}
|
|
||||||
>
|
|
||||||
<AlignCenterIcon />
|
|
||||||
</ToolbarButton>
|
|
||||||
|
|
||||||
<div className="mx-1 h-5 w-px bg-dark-700" />
|
<ToolbarButton
|
||||||
|
onClick={() => editor.chain().focus().toggleHighlight().run()}
|
||||||
|
isActive={editor.isActive('highlight')}
|
||||||
|
title={t('news.admin.toolbar.highlight')}
|
||||||
|
>
|
||||||
|
<HighlightIcon />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton onClick={addLink} title={t('news.admin.toolbar.link')}>
|
||||||
|
<LinkIcon />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => mediaInputRef.current?.click()}
|
||||||
|
disabled={isUploading}
|
||||||
|
title={t('news.admin.toolbar.image')}
|
||||||
|
>
|
||||||
|
{isUploading ? (
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-accent-400 border-t-transparent" />
|
||||||
|
) : (
|
||||||
|
<ImageIcon />
|
||||||
|
)}
|
||||||
|
</ToolbarButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<ToolbarButton
|
{/* Editor content */}
|
||||||
onClick={() => editor.chain().focus().toggleHighlight().run()}
|
<EditorContent editor={editor} />
|
||||||
isActive={editor.isActive('highlight')}
|
</div>
|
||||||
title={t('news.admin.toolbar.highlight')}
|
|
||||||
>
|
|
||||||
<HighlightIcon />
|
|
||||||
</ToolbarButton>
|
|
||||||
<ToolbarButton onClick={addLink} title={t('news.admin.toolbar.link')}>
|
|
||||||
<LinkIcon />
|
|
||||||
</ToolbarButton>
|
|
||||||
<ToolbarButton
|
|
||||||
onClick={() => mediaInputRef.current?.click()}
|
|
||||||
disabled={isUploading}
|
|
||||||
title={t('news.admin.toolbar.image')}
|
|
||||||
>
|
|
||||||
{isUploading ? (
|
|
||||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-accent-400 border-t-transparent" />
|
|
||||||
) : (
|
|
||||||
<ImageIcon />
|
|
||||||
)}
|
|
||||||
</ToolbarButton>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Editor content */}
|
{/* Hidden file inputs */}
|
||||||
<EditorContent editor={editor} />
|
<input
|
||||||
|
ref={mediaInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,video/mp4,video/webm"
|
||||||
|
onChange={handleFileInputChange}
|
||||||
|
className="hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{/* Hidden file inputs */}
|
|
||||||
<input
|
|
||||||
ref={mediaInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/jpeg,image/png,image/webp,video/mp4,video/webm"
|
|
||||||
onChange={handleFileInputChange}
|
|
||||||
className="hidden"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error feedback */}
|
{/* Error feedback */}
|
||||||
{saveError && (
|
{saveError && (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, memo } from 'react';
|
import { useCallback, useState, memo } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -7,7 +7,10 @@ import { AdminBackButton } from '../components/admin';
|
|||||||
import { Toggle } from '../components/admin/Toggle';
|
import { Toggle } from '../components/admin/Toggle';
|
||||||
import { useHapticFeedback } from '../platform/hooks/useHaptic';
|
import { useHapticFeedback } from '../platform/hooks/useHaptic';
|
||||||
import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog';
|
import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog';
|
||||||
import type { InfoPageListItem } from '../api/infoPages';
|
import { cn } from '../lib/utils';
|
||||||
|
import type { InfoPageListItem, InfoPageType } from '../api/infoPages';
|
||||||
|
|
||||||
|
type FilterTab = 'all' | 'page' | 'faq';
|
||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
const PlusIcon = () => (
|
const PlusIcon = () => (
|
||||||
@@ -118,6 +121,15 @@ const PageRow = memo(function PageRow({
|
|||||||
<span className="rounded-full bg-dark-700 px-2 py-0.5 font-mono text-[10px] font-medium text-dark-300">
|
<span className="rounded-full bg-dark-700 px-2 py-0.5 font-mono text-[10px] font-medium text-dark-300">
|
||||||
/{page.slug}
|
/{page.slug}
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
||||||
|
page.page_type === 'faq'
|
||||||
|
? 'bg-warning-500/20 text-warning-400'
|
||||||
|
: 'bg-accent-500/20 text-accent-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{page.page_type === 'faq' ? 'FAQ' : t('admin.infoPages.typePage')}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
||||||
page.is_active
|
page.is_active
|
||||||
@@ -212,14 +224,17 @@ export default function AdminInfoPages() {
|
|||||||
const haptic = useHapticFeedback();
|
const haptic = useHapticFeedback();
|
||||||
const confirm = useDestructiveConfirm();
|
const confirm = useDestructiveConfirm();
|
||||||
const currentLocale = i18n.language.split('-')[0];
|
const currentLocale = i18n.language.split('-')[0];
|
||||||
|
const [activeFilter, setActiveFilter] = useState<FilterTab>('all');
|
||||||
|
|
||||||
|
const filterParam: InfoPageType | undefined = activeFilter === 'all' ? undefined : activeFilter;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: pages,
|
data: pages,
|
||||||
isLoading,
|
isLoading,
|
||||||
refetch,
|
refetch,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ['admin', 'info-pages', 'list'],
|
queryKey: ['admin', 'info-pages', 'list', activeFilter],
|
||||||
queryFn: () => infoPagesApi.getAdminPages(),
|
queryFn: () => infoPagesApi.getAdminPages(filterParam),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -283,6 +298,17 @@ export default function AdminInfoPages() {
|
|||||||
>
|
>
|
||||||
<RefreshIcon />
|
<RefreshIcon />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
haptic.buttonPress();
|
||||||
|
navigate('/admin/info-pages/create?type=faq');
|
||||||
|
}}
|
||||||
|
className="flex min-h-[44px] items-center gap-2 rounded-lg bg-warning-500/80 px-4 py-2.5 text-white transition-colors hover:bg-warning-500"
|
||||||
|
aria-label={t('admin.infoPages.createFaq')}
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
<span className="hidden sm:inline">{t('admin.infoPages.createFaq')}</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
haptic.buttonPress();
|
haptic.buttonPress();
|
||||||
@@ -297,6 +323,25 @@ export default function AdminInfoPages() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Filter tabs */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(['all', 'page', 'faq'] as const).map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveFilter(tab)}
|
||||||
|
className={cn(
|
||||||
|
'min-h-[44px] rounded-lg px-4 py-2.5 text-sm font-medium transition-colors',
|
||||||
|
activeFilter === tab
|
||||||
|
? 'bg-accent-500 text-white'
|
||||||
|
: 'bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-dark-100',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t(`admin.infoPages.filter.${tab}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Pages list */}
|
{/* Pages list */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useMemo, useRef } from 'react';
|
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router';
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import { infoPagesApi } from '../api/infoPages';
|
import { infoPagesApi } from '../api/infoPages';
|
||||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
|
import type { FaqItem } from '../api/infoPages';
|
||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
const BackIcon = () => (
|
const BackIcon = () => (
|
||||||
@@ -168,6 +169,141 @@ function sanitizeHtml(html: string): string {
|
|||||||
return infoPagePurify.sanitize(html, SANITIZE_CONFIG);
|
return infoPagePurify.sanitize(html, SANITIZE_CONFIG);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- FAQ Accordion ---
|
||||||
|
const ChevronIcon = ({ open }: { open: boolean }) => (
|
||||||
|
<svg
|
||||||
|
className={`h-5 w-5 text-dark-400 transition-transform duration-300 ${open ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SearchIcon = () => (
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4 text-dark-500"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
function FaqAccordionItem({
|
||||||
|
item,
|
||||||
|
isOpen,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
item: FaqItem;
|
||||||
|
isOpen: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [height, setHeight] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (contentRef.current) {
|
||||||
|
setHeight(isOpen ? contentRef.current.scrollHeight : 0);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const sanitizedAnswer = useMemo(() => sanitizeHtml(item.a), [item.a]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-xl border border-dark-700 bg-dark-800/50 transition-all hover:border-dark-600">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
className="flex min-h-[52px] w-full items-center justify-between gap-3 px-5 py-4 text-left"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium text-dark-100 sm:text-base">{item.q}</span>
|
||||||
|
<ChevronIcon open={isOpen} />
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
style={{ height }}
|
||||||
|
className="overflow-hidden transition-[height] duration-300 ease-in-out"
|
||||||
|
>
|
||||||
|
<div ref={contentRef} className="border-t border-dark-700/50 px-5 pb-4 pt-3">
|
||||||
|
<div
|
||||||
|
className="prose prose-sm max-w-none text-dark-300"
|
||||||
|
dangerouslySetInnerHTML={{ __html: sanitizedAnswer }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FaqView({ items }: { items: FaqItem[] }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
const handleToggle = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
setOpenIndex(openIndex === index ? null : index);
|
||||||
|
},
|
||||||
|
[openIndex],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
if (!search.trim()) return items;
|
||||||
|
const lower = search.toLowerCase();
|
||||||
|
return items.filter((item) => item.q.toLowerCase().includes(lower));
|
||||||
|
}, [items, search]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Search */}
|
||||||
|
{items.length > 3 && (
|
||||||
|
<div className="relative">
|
||||||
|
<div className="pointer-events-none absolute inset-y-0 left-3 flex items-center">
|
||||||
|
<SearchIcon />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
setOpenIndex(null);
|
||||||
|
}}
|
||||||
|
placeholder={t('admin.infoPages.faq.searchPlaceholder')}
|
||||||
|
className="input pl-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Accordion items */}
|
||||||
|
{filteredItems.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-6 text-center text-sm text-dark-400">
|
||||||
|
{search ? t('admin.infoPages.faq.noResults') : t('admin.infoPages.faq.noQuestions')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filteredItems.map((item, index) => (
|
||||||
|
<FaqAccordionItem
|
||||||
|
key={index}
|
||||||
|
item={item}
|
||||||
|
isOpen={openIndex === index}
|
||||||
|
onToggle={() => handleToggle(index)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function InfoPageView() {
|
export default function InfoPageView() {
|
||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
@@ -206,12 +342,26 @@ export default function InfoPageView() {
|
|||||||
return page.title[locale] || page.title['ru'] || page.title['en'] || '';
|
return page.title[locale] || page.title['ru'] || page.title['en'] || '';
|
||||||
}, [page, locale]);
|
}, [page, locale]);
|
||||||
|
|
||||||
|
const isFaq = page?.page_type === 'faq';
|
||||||
|
|
||||||
|
// Parse FAQ items from content
|
||||||
|
const faqItems = useMemo((): FaqItem[] => {
|
||||||
|
if (!page || !isFaq) return [];
|
||||||
|
const raw = page.content[locale] || page.content['ru'] || page.content['en'] || '[]';
|
||||||
|
try {
|
||||||
|
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [page, locale, isFaq]);
|
||||||
|
|
||||||
// Content is sanitized with DOMPurify before rendering
|
// Content is sanitized with DOMPurify before rendering
|
||||||
const sanitizedContent = useMemo(() => {
|
const sanitizedContent = useMemo(() => {
|
||||||
if (!page) return '';
|
if (!page || isFaq) return '';
|
||||||
const rawContent = page.content[locale] || page.content['ru'] || page.content['en'] || '';
|
const rawContent = page.content[locale] || page.content['ru'] || page.content['en'] || '';
|
||||||
return sanitizeHtml(rawContent);
|
return sanitizeHtml(rawContent);
|
||||||
}, [page, locale]);
|
}, [page, locale, isFaq]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -269,11 +419,16 @@ export default function InfoPageView() {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Page content - sanitized with DOMPurify (strict allowlist) */}
|
{/* Page content */}
|
||||||
<div
|
{isFaq ? (
|
||||||
className="prose max-w-none overflow-x-auto lg:max-w-3xl"
|
<FaqView items={faqItems} />
|
||||||
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
|
) : (
|
||||||
/>
|
/* Regular page content - sanitized with DOMPurify (strict allowlist) */
|
||||||
|
<div
|
||||||
|
className="prose max-w-none overflow-x-auto lg:max-w-3xl"
|
||||||
|
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user