import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import DOMPurify from 'dompurify'; import { infoPagesApi } from '../api/infoPages'; import { usePlatform } from '../platform/hooks/usePlatform'; import type { FaqItem } from '../api/infoPages'; // Icons const BackIcon = () => ( ); /** * Sanitization config — same strict allowlist as NewsArticlePage. * All HTML content is sanitized with DOMPurify before rendering. */ const ALLOWED_IFRAME_HOSTS = new Set([ 'www.youtube.com', 'youtube.com', 'player.vimeo.com', 'www.youtube-nocookie.com', ]); function isAllowedIframeSrc(src: string): boolean { try { const url = new URL(src); return url.protocol === 'https:' && ALLOWED_IFRAME_HOSTS.has(url.hostname); } catch { return false; } } const SANITIZE_CONFIG = { ALLOWED_TAGS: [ 'p', 'div', 'br', 'hr', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'pre', 'code', 'ul', 'ol', 'li', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'a', 'strong', 'b', 'em', 'i', 'u', 's', 'del', 'ins', 'span', 'mark', 'sub', 'sup', 'small', 'img', 'video', 'iframe', 'figure', 'figcaption', ], ALLOWED_ATTR: [ 'href', 'target', 'rel', 'src', 'alt', 'title', 'width', 'height', 'loading', 'class', 'start', 'reversed', 'type', 'controls', 'preload', 'frameborder', 'allowfullscreen', 'allow', 'sandbox', 'style', ], ALLOW_DATA_ATTR: false, ADD_ATTR: ['target'], }; /** * Isolated DOMPurify instance for info page content sanitization. * All user-generated HTML is sanitized before being rendered. */ const infoPagePurify = DOMPurify(window); infoPagePurify.addHook('afterSanitizeAttributes', (node) => { if (node.tagName === 'IFRAME') { const src = node.getAttribute('src') ?? ''; if (!isAllowedIframeSrc(src)) { node.remove(); return; } node.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-presentation'); node.setAttribute('allow', 'autoplay; encrypted-media; picture-in-picture'); } }); infoPagePurify.addHook('afterSanitizeAttributes', (node) => { if (node.tagName === 'VIDEO') { const src = node.getAttribute('src') ?? ''; try { const url = new URL(src); if (url.protocol !== 'https:' && url.protocol !== 'http:') { node.remove(); return; } } catch { node.remove(); return; } node.setAttribute('controls', ''); node.setAttribute('preload', 'metadata'); } }); infoPagePurify.addHook('afterSanitizeAttributes', (node) => { if (node.tagName === 'A') { node.setAttribute('target', '_blank'); node.setAttribute('rel', 'noopener noreferrer'); } }); infoPagePurify.addHook('afterSanitizeAttributes', (node) => { if (node.hasAttribute('style')) { const style = node.getAttribute('style') ?? ''; const match = style.match(/text-align\s*:\s*(left|center|right|justify)/i); if (match) { node.setAttribute('style', `text-align: ${match[1]}`); } else { node.removeAttribute('style'); } } }); function sanitizeHtml(html: string): string { return infoPagePurify.sanitize(html, SANITIZE_CONFIG); } // --- FAQ Accordion --- const ChevronIcon = ({ open }: { open: boolean }) => ( ); const SearchIcon = () => ( ); function FaqAccordionItem({ item, isOpen, onToggle, }: { item: FaqItem; isOpen: boolean; onToggle: () => void; }) { const contentRef = useRef(null); const [height, setHeight] = useState(0); useEffect(() => { if (contentRef.current) { setHeight(isOpen ? contentRef.current.scrollHeight : 0); } }, [isOpen]); // Update height when content resizes (images loading, viewport rotation) useEffect(() => { if (!isOpen || !contentRef.current) return; const observer = new ResizeObserver(() => { if (contentRef.current) setHeight(contentRef.current.scrollHeight); }); observer.observe(contentRef.current); return () => observer.disconnect(); }, [isOpen]); const sanitizedAnswer = useMemo(() => sanitizeHtml(item.a), [item.a]); return (
); } function FaqView({ items }: { items: FaqItem[] }) { const { t } = useTranslation(); const [openKey, setOpenKey] = useState(null); const [search, setSearch] = useState(''); const handleToggle = useCallback((key: string) => { setOpenKey((prev) => (prev === key ? null : key)); }, []); const filteredItems = useMemo(() => { if (!search.trim()) return items; const lower = search.toLowerCase(); return items.filter((item) => item.q.toLowerCase().includes(lower)); }, [items, search]); return (
{/* Search */} {items.length > 3 && (
{ setSearch(e.target.value); setOpenKey(null); }} placeholder={t('admin.infoPages.faq.searchPlaceholder')} className="input pl-9 text-sm" />
)} {/* Accordion items */} {filteredItems.length === 0 ? (
{search ? t('admin.infoPages.faq.noResults') : t('admin.infoPages.faq.noQuestions')}
) : (
{filteredItems.map((item, index) => { const key = `${index}-${item.q.slice(0, 50)}`; return ( handleToggle(key)} /> ); })}
)}
); } export default function InfoPageView() { const { slug } = useParams<{ slug: string }>(); const { t, i18n } = useTranslation(); const navigate = useNavigate(); const { capabilities, backButton } = usePlatform(); const navigateRef = useRef(navigate); useEffect(() => { navigateRef.current = navigate; }, [navigate]); useEffect(() => { if (!capabilities.hasBackButton) return; backButton.show(() => navigateRef.current(-1)); return () => backButton.hide(); }, [capabilities.hasBackButton, backButton]); const { data: page, isLoading, isError, } = useQuery({ queryKey: ['info-pages', 'page', slug], queryFn: () => { if (!slug) throw new Error('Missing slug parameter'); return infoPagesApi.getPageBySlug(slug); }, enabled: !!slug, staleTime: 60_000, }); const locale = i18n.language.split('-')[0]; const resolvedTitle = useMemo(() => { if (!page) return ''; return page.title[locale] || page.title['ru'] || page.title['en'] || ''; }, [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 const sanitizedContent = useMemo(() => { if (!page || isFaq) return ''; const rawContent = page.content[locale] || page.content['ru'] || page.content['en'] || ''; return sanitizeHtml(rawContent); }, [page, locale, isFaq]); if (isLoading) { return (
); } if (isError || !page) { return (
{!capabilities.hasBackButton && ( )}
{t('admin.infoPages.notFound')}
); } return (
{/* Back button */} {!capabilities.hasBackButton && ( )} {/* Page header */}
{page.icon && {page.icon}}

{resolvedTitle}

{/* Page content */} {isFaq ? ( ) : ( /* Regular page content - sanitized with DOMPurify (strict allowlist) */
)}
); }