From 656946952a0351ad6071286b0e12f95e89782360 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 24 Apr 2026 15:01:11 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20info=20pages=20=E2=80=94=20tab=20replac?= =?UTF-8?q?ement,=20custom=20pages=20on=20/info,=20responsive=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add replaces_tab field to admin editor with conflict detection - Show custom InfoPages as extra tabs on /info page - Replace built-in tab content when replacement is configured - Rich DOMPurify sanitizer for TipTap content (images, video, iframe) - Fix query race: built-in queries wait for tab-replacements to load - Horizontal scroll for tabs on mobile, 44px touch targets - Responsive prose: scrollable tables, h-auto on img/video, light theme borders - Loyalty tier names truncation, progress bar labels stack on mobile --- src/api/infoPages.ts | 13 + src/locales/en.json | 12 +- src/locales/fa.json | 12 +- src/locales/ru.json | 12 +- src/locales/zh.json | 12 +- src/pages/AdminInfoPageEditor.tsx | 42 +++- src/pages/AdminInfoPages.tsx | 5 + src/pages/Info.tsx | 385 ++++++++++++++++++++++++++++-- src/styles/globals.css | 17 +- 9 files changed, 481 insertions(+), 29 deletions(-) diff --git a/src/api/infoPages.ts b/src/api/infoPages.ts index 994560b..39a39a4 100644 --- a/src/api/infoPages.ts +++ b/src/api/infoPages.ts @@ -2,6 +2,8 @@ import apiClient from './client'; export type InfoPageType = 'page' | 'faq'; +export type ReplacesTab = 'faq' | 'rules' | 'privacy' | 'offer'; + export interface InfoPage { id: number; slug: string; @@ -11,6 +13,7 @@ export interface InfoPage { is_active: boolean; sort_order: number; icon: string | null; + replaces_tab: ReplacesTab | null; created_at: string; updated_at: string | null; } @@ -23,6 +26,7 @@ export interface InfoPageListItem { is_active: boolean; sort_order: number; icon: string | null; + replaces_tab: ReplacesTab | null; updated_at: string | null; } @@ -34,6 +38,7 @@ export interface InfoPageCreateRequest { is_active: boolean; sort_order: number; icon: string | null; + replaces_tab: ReplacesTab | null; } export interface InfoPageUpdateRequest { @@ -44,8 +49,11 @@ export interface InfoPageUpdateRequest { is_active?: boolean; sort_order?: number; icon?: string | null; + replaces_tab?: ReplacesTab | null; } +export type TabReplacements = Record; + /** Single FAQ Q&A item stored in content JSONB. */ export interface FaqItem { q: string; @@ -58,6 +66,11 @@ export interface InfoPageReorderRequest { export const infoPagesApi = { // Public endpoints + getTabReplacements: async (): Promise => { + const response = await apiClient.get('/cabinet/info-pages/tab-replacements'); + return response.data; + }, + getPages: async (pageType?: InfoPageType): Promise => { const params = pageType ? { page_type: pageType } : undefined; const response = await apiClient.get('/cabinet/info-pages', { params }); diff --git a/src/locales/en.json b/src/locales/en.json index 46a1db5..ccf241b 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3825,8 +3825,18 @@ "isActive": "Active", "sortOrder": "Sort Order", "icon": "Icon (emoji)", - "pageType": "Page Type" + "pageType": "Page Type", + "replacesTab": "Replaces Tab" }, + "replacesTabNone": "None", + "replacesTabOptions": { + "faq": "FAQ", + "rules": "Rules", + "privacy": "Privacy", + "offer": "Offer" + }, + "replacesTabConflict": "already assigned", + "replacesTabWarning": "Another page already replaces this tab. It will be unassigned on save.", "filter": { "all": "All", "page": "Pages", diff --git a/src/locales/fa.json b/src/locales/fa.json index 056c1cf..cd99f83 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -3393,8 +3393,18 @@ "isActive": "فعال", "sortOrder": "ترتیب", "icon": "آیکون (ایموجی)", - "pageType": "نوع صفحه" + "pageType": "نوع صفحه", + "replacesTab": "جایگزینی تب" }, + "replacesTabNone": "بدون جایگزینی", + "replacesTabOptions": { + "faq": "FAQ", + "rules": "قوانین", + "privacy": "حریم خصوصی", + "offer": "پیشنهاد" + }, + "replacesTabConflict": "قبلا اختصاص داده شده", + "replacesTabWarning": "صفحه دیگری قبلا این تب را جایگزین کرده است. با ذخیره، آن صفحه حذف خواهد شد.", "filter": { "all": "همه", "page": "صفحات", diff --git a/src/locales/ru.json b/src/locales/ru.json index 873ea88..23503d1 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -4369,8 +4369,18 @@ "isActive": "Активна", "sortOrder": "Порядок сортировки", "icon": "Иконка (эмодзи)", - "pageType": "Тип страницы" + "pageType": "Тип страницы", + "replacesTab": "Заменяет таб" }, + "replacesTabNone": "Не заменяет", + "replacesTabOptions": { + "faq": "FAQ", + "rules": "Правила", + "privacy": "Конфиденциальность", + "offer": "Оферта" + }, + "replacesTabConflict": "уже занят", + "replacesTabWarning": "Другая страница уже заменяет этот таб. При сохранении она будет снята.", "filter": { "all": "Все", "page": "Страницы", diff --git a/src/locales/zh.json b/src/locales/zh.json index 9748c77..a21b631 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -3392,8 +3392,18 @@ "isActive": "已激活", "sortOrder": "排序", "icon": "图标(表情)", - "pageType": "页面类型" + "pageType": "页面类型", + "replacesTab": "替换标签" }, + "replacesTabNone": "不替换", + "replacesTabOptions": { + "faq": "FAQ", + "rules": "规则", + "privacy": "隐私", + "offer": "条款" + }, + "replacesTabConflict": "已被占用", + "replacesTabWarning": "另一个页面已替换此标签。保存后该页面将被取消替换。", "filter": { "all": "全部", "page": "页面", diff --git a/src/pages/AdminInfoPageEditor.tsx b/src/pages/AdminInfoPageEditor.tsx index 1cac8bd..1fe39ca 100644 --- a/src/pages/AdminInfoPageEditor.tsx +++ b/src/pages/AdminInfoPageEditor.tsx @@ -17,7 +17,7 @@ import { AdminBackButton } from '../components/admin'; import { Toggle } from '../components/admin/Toggle'; import { useHapticFeedback } from '../platform/hooks/useHaptic'; import { cn } from '../lib/utils'; -import type { InfoPageType, FaqItem } from '../api/infoPages'; +import type { InfoPageType, FaqItem, ReplacesTab } from '../api/infoPages'; const AVAILABLE_LOCALES = ['ru', 'en', 'zh', 'fa'] as const; type LocaleCode = (typeof AVAILABLE_LOCALES)[number]; @@ -410,6 +410,7 @@ export default function AdminInfoPageEditor() { const [isActive, setIsActive] = useState(true); const [sortOrder, setSortOrder] = useState(0); const [pageType, setPageType] = useState(initialPageType); + const [replacesTab, setReplacesTab] = useState(null); const [saveError, setSaveError] = useState(null); // FAQ Q&A state per locale @@ -591,6 +592,13 @@ export default function AdminInfoPageEditor() { [handleMediaUpload], ); + // Fetch all admin pages to detect tab conflicts + const { data: allAdminPages } = useQuery({ + queryKey: ['admin', 'info-pages', 'list', 'all'], + queryFn: () => infoPagesApi.getAdminPages(), + staleTime: 30_000, + }); + // Fetch page for editing const { data: pageData, isLoading: isLoadingPage } = useQuery({ queryKey: ['admin', 'info-pages', 'page', pageId], @@ -616,6 +624,7 @@ export default function AdminInfoPageEditor() { setIsActive(pageData.is_active); setSortOrder(pageData.sort_order); setPageType(pageData.page_type ?? 'page'); + setReplacesTab(pageData.replaces_tab ?? null); setTitles(pageData.title); if (pageData.page_type === 'faq') { @@ -682,6 +691,7 @@ export default function AdminInfoPageEditor() { is_active: boolean; sort_order: number; icon: string | null; + replaces_tab: ReplacesTab | null; }) => { if (isEdit && pageId != null) { return infoPagesApi.updatePage(pageId, data); @@ -730,6 +740,7 @@ export default function AdminInfoPageEditor() { is_active: isActive, sort_order: sortOrder, icon: icon.trim() || null, + replaces_tab: replacesTab, }; haptic.buttonPress(); @@ -854,6 +865,35 @@ export default function AdminInfoPageEditor() { + {/* Replaces tab selector */} +
+ + + {replacesTab && + allAdminPages?.some((p) => p.replaces_tab === replacesTab && p.id !== pageId) && ( +

+ {t('admin.infoPages.replacesTabWarning')} +

+ )} +
+ {/* Locale tabs */}
diff --git a/src/pages/AdminInfoPages.tsx b/src/pages/AdminInfoPages.tsx index 0570745..be774f8 100644 --- a/src/pages/AdminInfoPages.tsx +++ b/src/pages/AdminInfoPages.tsx @@ -139,6 +139,11 @@ const PageRow = memo(function PageRow({ > {page.is_active ? t('admin.infoPages.active') : t('admin.infoPages.inactive')} + {page.replaces_tab && ( + + {t(`admin.infoPages.replacesTabOptions.${page.replaces_tab}`)} + + )} #{page.id}
diff --git a/src/pages/Info.tsx b/src/pages/Info.tsx index 24b73b5..bba2d4f 100644 --- a/src/pages/Info.tsx +++ b/src/pages/Info.tsx @@ -1,9 +1,11 @@ -import { useState } from 'react'; +import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import DOMPurify from 'dompurify'; import { infoApi, FaqPage } from '../api/info'; +import { infoPagesApi } from '../api/infoPages'; import { promoApi, LoyaltyTierInfo } from '../api/promo'; +import type { FaqItem, ReplacesTab } from '../api/infoPages'; const InfoIcon = () => ( @@ -67,7 +69,7 @@ const ChevronIcon = ({ expanded }: { expanded: boolean }) => ( ); -type TabType = 'faq' | 'rules' | 'privacy' | 'offer' | 'loyalty'; +const BUILTIN_TABS = new Set(['faq', 'rules', 'privacy', 'offer', 'loyalty']); // Sanitize HTML content to prevent XSS const sanitizeHtml = (html: string): string => { @@ -105,6 +107,136 @@ const sanitizeHtml = (html: string): string => { }); }; +// Rich sanitizer for custom InfoPage content (TipTap editor output with media) +const ALLOWED_IFRAME_HOSTS = new Set([ + 'www.youtube.com', + 'youtube.com', + 'player.vimeo.com', + 'www.youtube-nocookie.com', +]); + +const infoPagePurify = DOMPurify(window); + +infoPagePurify.addHook('afterSanitizeAttributes', (node) => { + if (node.tagName === 'IFRAME') { + const src = node.getAttribute('src') ?? ''; + try { + const url = new URL(src); + if (url.protocol !== 'https:' || !ALLOWED_IFRAME_HOSTS.has(url.hostname)) { + node.remove(); + return; + } + } catch { + node.remove(); + return; + } + node.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-presentation'); + node.setAttribute('allow', 'autoplay; encrypted-media; picture-in-picture'); + } + 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'); + } + if (node.tagName === 'A') { + node.setAttribute('target', '_blank'); + node.setAttribute('rel', 'noopener noreferrer'); + } + 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'); + } + } +}); + +const RICH_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'], +}; + +const sanitizeRichHtml = (html: string): string => { + return infoPagePurify.sanitize(html, RICH_SANITIZE_CONFIG); +}; + // Convert content to formatted HTML (handles Telegram HTML + plain text) const formatContent = (content: string): string => { if (!content) return ''; @@ -154,15 +286,165 @@ const formatContent = (content: string): string => { return sanitizeHtml(result); }; +// --- FAQ Accordion for tab replacements --- + +function ReplacementFaqItem({ + 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]); + + 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(() => sanitizeRichHtml(item.a), [item.a]); + + return ( +
+ +
+
+
+
+
+
+ ); +} + +function ReplacementFaqView({ items }: { items: FaqItem[] }) { + const [openKey, setOpenKey] = useState(null); + + const handleToggle = useCallback((key: string) => { + setOpenKey((prev) => (prev === key ? null : key)); + }, []); + + return ( +
+ {items.map((item, index) => { + const key = `${index}-${item.q.slice(0, 50)}`; + return ( + handleToggle(key)} + /> + ); + })} +
+ ); +} + export default function Info() { - const { t } = useTranslation(); - const [activeTab, setActiveTab] = useState('faq'); + const { t, i18n } = useTranslation(); + const [activeTab, setActiveTab] = useState('faq'); const [expandedFaq, setExpandedFaq] = useState(null); + const locale = i18n.language.split('-')[0]; + + // Fetch tab replacements + const { data: tabReplacements } = useQuery({ + queryKey: ['info-pages', 'tab-replacements'], + queryFn: infoPagesApi.getTabReplacements, + staleTime: 60_000, + }); + + // Fetch custom InfoPages (active pages without replaces_tab — shown as extra tabs) + const { data: customPages } = useQuery({ + queryKey: ['info-pages', 'list'], + queryFn: () => infoPagesApi.getPages(), + staleTime: 60_000, + }); + + // Filter to only pages that don't replace a built-in tab + const extraPages = useMemo( + () => (customPages ?? []).filter((p) => !p.replaces_tab), + [customPages], + ); + + // Determine if we're on a built-in tab or a custom page tab + const isCustomTab = !BUILTIN_TABS.has(activeTab); + const customTabSlug = isCustomTab ? activeTab : null; + + // Check if current built-in tab has a replacement + const currentTabSlug = + !isCustomTab && activeTab !== 'loyalty' + ? (tabReplacements?.[activeTab as ReplacesTab] ?? null) + : null; + + // Slug to fetch: either a custom page tab or a tab replacement + const pageSlugToFetch = customTabSlug ?? currentTabSlug; + + // Wait for tab replacements before firing built-in queries + const replacementsLoaded = tabReplacements !== undefined; + + // Fetch the InfoPage when needed (replacement or custom tab) + const { data: infoPage, isLoading: infoPageLoading } = useQuery({ + queryKey: ['info-pages', 'page', pageSlugToFetch], + queryFn: () => { + if (!pageSlugToFetch) throw new Error('No slug'); + return infoPagesApi.getPageBySlug(pageSlugToFetch); + }, + enabled: !!pageSlugToFetch, + staleTime: 60_000, + }); + + // Parse FAQ items from InfoPage content + const infoPageFaqItems = useMemo((): FaqItem[] => { + if (!infoPage || infoPage.page_type !== 'faq') return []; + const raw = + infoPage.content[locale] || infoPage.content['ru'] || infoPage.content['en'] || '[]'; + try { + const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + }, [infoPage, locale]); + + // Sanitize regular InfoPage HTML content + const infoPageHtml = useMemo(() => { + if (!infoPage || infoPage.page_type === 'faq') return ''; + const rawContent = + infoPage.content[locale] || infoPage.content['ru'] || infoPage.content['en'] || ''; + return sanitizeRichHtml(rawContent); + }, [infoPage, locale]); const { data: faqPages, isLoading: faqLoading } = useQuery({ queryKey: ['faq-pages'], queryFn: infoApi.getFaqPages, - enabled: activeTab === 'faq', + enabled: activeTab === 'faq' && !currentTabSlug && replacementsLoaded, staleTime: 0, refetchOnMount: 'always', }); @@ -170,7 +452,7 @@ export default function Info() { const { data: rules, isLoading: rulesLoading } = useQuery({ queryKey: ['rules'], queryFn: infoApi.getRules, - enabled: activeTab === 'rules', + enabled: activeTab === 'rules' && !currentTabSlug && replacementsLoaded, staleTime: 0, refetchOnMount: 'always', }); @@ -178,7 +460,7 @@ export default function Info() { const { data: privacy, isLoading: privacyLoading } = useQuery({ queryKey: ['privacy-policy'], queryFn: infoApi.getPrivacyPolicy, - enabled: activeTab === 'privacy', + enabled: activeTab === 'privacy' && !currentTabSlug && replacementsLoaded, staleTime: 0, refetchOnMount: 'always', }); @@ -186,7 +468,7 @@ export default function Info() { const { data: offer, isLoading: offerLoading } = useQuery({ queryKey: ['public-offer'], queryFn: infoApi.getPublicOffer, - enabled: activeTab === 'offer', + enabled: activeTab === 'offer' && !currentTabSlug && replacementsLoaded, staleTime: 0, refetchOnMount: 'always', }); @@ -199,19 +481,76 @@ export default function Info() { refetchOnMount: 'always', }); - const tabs = [ - { id: 'faq' as TabType, label: t('info.faq'), icon: QuestionIcon }, - { id: 'rules' as TabType, label: t('info.rules'), icon: DocumentIcon }, - { id: 'privacy' as TabType, label: t('info.privacy'), icon: ShieldIcon }, - { id: 'offer' as TabType, label: t('info.offer'), icon: DocumentIcon }, - { id: 'loyalty' as TabType, label: t('info.loyalty'), icon: StarIcon }, + const builtinTabs: Array<{ id: string; label: string; icon: React.FC; emoji?: string }> = [ + { id: 'faq', label: t('info.faq'), icon: QuestionIcon }, + { id: 'rules', label: t('info.rules'), icon: DocumentIcon }, + { id: 'privacy', label: t('info.privacy'), icon: ShieldIcon }, + { id: 'offer', label: t('info.offer'), icon: DocumentIcon }, + { id: 'loyalty', label: t('info.loyalty'), icon: StarIcon }, ]; + const customTabs = extraPages.map((p) => { + const label = p.title[locale] || p.title['ru'] || p.title['en'] || p.slug; + return { id: p.slug, label, icon: DocumentIcon, emoji: p.icon ?? undefined }; + }); + + const tabs = [...builtinTabs, ...customTabs]; + const toggleFaq = (id: number) => { setExpandedFaq(expandedFaq === id ? null : id); }; + const renderInfoPageContent = () => { + if (infoPageLoading) { + return ( +
+
+
+ ); + } + + if (!infoPage) { + return
{t('info.noContent')}
; + } + + if (infoPage.page_type === 'faq') { + if (infoPageFaqItems.length === 0) { + return
{t('info.noFaq')}
; + } + return ; + } + + if (!infoPageHtml) { + return
{t('info.noContent')}
; + } + + return ( +
+
+
+ ); + }; + const renderContent = () => { + // Custom page tab — always render InfoPage content + if (isCustomTab) { + return renderInfoPageContent(); + } + + // Show spinner while tab replacements are loading (prevents flash of wrong content) + if (!replacementsLoaded) { + return ( +
+
+
+ ); + } + + // Built-in tab replaced by an InfoPage + if (currentTabSlug) { + return renderInfoPageContent(); + } + if (activeTab === 'faq') { if (faqLoading) { return ( @@ -231,7 +570,7 @@ export default function Info() {
))}
diff --git a/src/styles/globals.css b/src/styles/globals.css index ba512f9..cab9a2c 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -967,6 +967,9 @@ img.twemoji { .prose table { @apply mb-4 w-full border-collapse; + display: block; + overflow-x: auto; + -webkit-overflow-scrolling: touch; } .prose th { @@ -978,7 +981,7 @@ img.twemoji { } .prose img { - @apply my-4 max-w-full rounded-xl; + @apply my-4 h-auto max-w-full rounded-xl; } .prose mark { @@ -989,6 +992,10 @@ img.twemoji { @apply my-4 block aspect-video w-full max-w-2xl rounded-xl; } + .prose video { + @apply my-4 block h-auto w-full max-w-full rounded-xl; + } + /* Light theme prose styles */ .light .prose { @apply text-champagne-800; @@ -1070,6 +1077,14 @@ img.twemoji { @apply border border-champagne-300; } + .light .prose video { + @apply border border-champagne-300; + } + + .light .prose img { + @apply border border-champagne-300; + } + /* Support for plain text with line breaks */ .prose-plain { @apply whitespace-pre-line;