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:
Fringg
2026-04-24 14:02:03 +03:00
parent 596f638cdd
commit 1ee0f18343
8 changed files with 781 additions and 192 deletions

View File

@@ -1,4 +1,4 @@
import { useCallback, memo } from 'react';
import { useCallback, useState, memo } from 'react';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -7,7 +7,10 @@ import { AdminBackButton } from '../components/admin';
import { Toggle } from '../components/admin/Toggle';
import { useHapticFeedback } from '../platform/hooks/useHaptic';
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
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">
/{page.slug}
</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
className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${
page.is_active
@@ -212,14 +224,17 @@ export default function AdminInfoPages() {
const haptic = useHapticFeedback();
const confirm = useDestructiveConfirm();
const currentLocale = i18n.language.split('-')[0];
const [activeFilter, setActiveFilter] = useState<FilterTab>('all');
const filterParam: InfoPageType | undefined = activeFilter === 'all' ? undefined : activeFilter;
const {
data: pages,
isLoading,
refetch,
} = useQuery({
queryKey: ['admin', 'info-pages', 'list'],
queryFn: () => infoPagesApi.getAdminPages(),
queryKey: ['admin', 'info-pages', 'list', activeFilter],
queryFn: () => infoPagesApi.getAdminPages(filterParam),
staleTime: 30_000,
});
@@ -283,6 +298,17 @@ export default function AdminInfoPages() {
>
<RefreshIcon />
</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
onClick={() => {
haptic.buttonPress();
@@ -297,6 +323,25 @@ export default function AdminInfoPages() {
</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 */}
{isLoading ? (
<div className="space-y-3">