mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
feat: info pages — tab replacement, custom pages on /info, responsive fixes
- 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
This commit is contained in:
@@ -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<ReplacesTab, string | null>;
|
||||
|
||||
/** 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<TabReplacements> => {
|
||||
const response = await apiClient.get<TabReplacements>('/cabinet/info-pages/tab-replacements');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getPages: async (pageType?: InfoPageType): Promise<InfoPageListItem[]> => {
|
||||
const params = pageType ? { page_type: pageType } : undefined;
|
||||
const response = await apiClient.get<InfoPageListItem[]>('/cabinet/info-pages', { params });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3393,8 +3393,18 @@
|
||||
"isActive": "فعال",
|
||||
"sortOrder": "ترتیب",
|
||||
"icon": "آیکون (ایموجی)",
|
||||
"pageType": "نوع صفحه"
|
||||
"pageType": "نوع صفحه",
|
||||
"replacesTab": "جایگزینی تب"
|
||||
},
|
||||
"replacesTabNone": "بدون جایگزینی",
|
||||
"replacesTabOptions": {
|
||||
"faq": "FAQ",
|
||||
"rules": "قوانین",
|
||||
"privacy": "حریم خصوصی",
|
||||
"offer": "پیشنهاد"
|
||||
},
|
||||
"replacesTabConflict": "قبلا اختصاص داده شده",
|
||||
"replacesTabWarning": "صفحه دیگری قبلا این تب را جایگزین کرده است. با ذخیره، آن صفحه حذف خواهد شد.",
|
||||
"filter": {
|
||||
"all": "همه",
|
||||
"page": "صفحات",
|
||||
|
||||
@@ -4369,8 +4369,18 @@
|
||||
"isActive": "Активна",
|
||||
"sortOrder": "Порядок сортировки",
|
||||
"icon": "Иконка (эмодзи)",
|
||||
"pageType": "Тип страницы"
|
||||
"pageType": "Тип страницы",
|
||||
"replacesTab": "Заменяет таб"
|
||||
},
|
||||
"replacesTabNone": "Не заменяет",
|
||||
"replacesTabOptions": {
|
||||
"faq": "FAQ",
|
||||
"rules": "Правила",
|
||||
"privacy": "Конфиденциальность",
|
||||
"offer": "Оферта"
|
||||
},
|
||||
"replacesTabConflict": "уже занят",
|
||||
"replacesTabWarning": "Другая страница уже заменяет этот таб. При сохранении она будет снята.",
|
||||
"filter": {
|
||||
"all": "Все",
|
||||
"page": "Страницы",
|
||||
|
||||
@@ -3392,8 +3392,18 @@
|
||||
"isActive": "已激活",
|
||||
"sortOrder": "排序",
|
||||
"icon": "图标(表情)",
|
||||
"pageType": "页面类型"
|
||||
"pageType": "页面类型",
|
||||
"replacesTab": "替换标签"
|
||||
},
|
||||
"replacesTabNone": "不替换",
|
||||
"replacesTabOptions": {
|
||||
"faq": "FAQ",
|
||||
"rules": "规则",
|
||||
"privacy": "隐私",
|
||||
"offer": "条款"
|
||||
},
|
||||
"replacesTabConflict": "已被占用",
|
||||
"replacesTabWarning": "另一个页面已替换此标签。保存后该页面将被取消替换。",
|
||||
"filter": {
|
||||
"all": "全部",
|
||||
"page": "页面",
|
||||
|
||||
@@ -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<InfoPageType>(initialPageType);
|
||||
const [replacesTab, setReplacesTab] = useState<ReplacesTab | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Replaces tab selector */}
|
||||
<div>
|
||||
<label className="label">{t('admin.infoPages.fields.replacesTab')}</label>
|
||||
<select
|
||||
value={replacesTab ?? ''}
|
||||
onChange={(e) => setReplacesTab((e.target.value || null) as ReplacesTab | null)}
|
||||
className="input max-w-xs"
|
||||
>
|
||||
<option value="">{t('admin.infoPages.replacesTabNone')}</option>
|
||||
{(['faq', 'rules', 'privacy', 'offer'] as const).map((tab) => {
|
||||
const conflict = allAdminPages?.find(
|
||||
(p) => p.replaces_tab === tab && p.id !== pageId,
|
||||
);
|
||||
return (
|
||||
<option key={tab} value={tab}>
|
||||
{t(`admin.infoPages.replacesTabOptions.${tab}`)}
|
||||
{conflict ? ` (${t('admin.infoPages.replacesTabConflict')})` : ''}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{replacesTab &&
|
||||
allAdminPages?.some((p) => p.replaces_tab === replacesTab && p.id !== pageId) && (
|
||||
<p className="mt-1 text-xs text-warning-400">
|
||||
{t('admin.infoPages.replacesTabWarning')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Locale tabs */}
|
||||
<div>
|
||||
<label className="label">{t('admin.infoPages.localeLabel')}</label>
|
||||
|
||||
@@ -139,6 +139,11 @@ const PageRow = memo(function PageRow({
|
||||
>
|
||||
{page.is_active ? t('admin.infoPages.active') : t('admin.infoPages.inactive')}
|
||||
</span>
|
||||
{page.replaces_tab && (
|
||||
<span className="rounded-full bg-purple-500/20 px-2 py-0.5 text-[10px] font-medium text-purple-400">
|
||||
{t(`admin.infoPages.replacesTabOptions.${page.replaces_tab}`)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-dark-500">#{page.id}</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 = () => (
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
@@ -67,7 +69,7 @@ const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
type TabType = 'faq' | 'rules' | 'privacy' | 'offer' | 'loyalty';
|
||||
const BUILTIN_TABS = new Set<string>(['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<HTMLDivElement>(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 (
|
||||
<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 expanded={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 ReplacementFaqView({ items }: { items: FaqItem[] }) {
|
||||
const [openKey, setOpenKey] = useState<string | null>(null);
|
||||
|
||||
const handleToggle = useCallback((key: string) => {
|
||||
setOpenKey((prev) => (prev === key ? null : key));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((item, index) => {
|
||||
const key = `${index}-${item.q.slice(0, 50)}`;
|
||||
return (
|
||||
<ReplacementFaqItem
|
||||
key={key}
|
||||
item={item}
|
||||
isOpen={openKey === key}
|
||||
onToggle={() => handleToggle(key)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Info() {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('faq');
|
||||
const { t, i18n } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<string>('faq');
|
||||
const [expandedFaq, setExpandedFaq] = useState<number | null>(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 (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!infoPage) {
|
||||
return <div className="py-8 text-center text-dark-400">{t('info.noContent')}</div>;
|
||||
}
|
||||
|
||||
if (infoPage.page_type === 'faq') {
|
||||
if (infoPageFaqItems.length === 0) {
|
||||
return <div className="py-8 text-center text-dark-400">{t('info.noFaq')}</div>;
|
||||
}
|
||||
return <ReplacementFaqView items={infoPageFaqItems} />;
|
||||
}
|
||||
|
||||
if (!infoPageHtml) {
|
||||
return <div className="py-8 text-center text-dark-400">{t('info.noContent')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bento-card prose prose-invert max-w-none !overflow-visible">
|
||||
<div dangerouslySetInnerHTML={{ __html: infoPageHtml }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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() {
|
||||
<div key={faq.id} className="bento-card overflow-hidden p-0">
|
||||
<button
|
||||
onClick={() => toggleFaq(faq.id)}
|
||||
className="flex w-full items-center justify-between px-4 py-3 text-left transition-colors hover:bg-dark-800/50"
|
||||
className="flex min-h-[52px] w-full items-center justify-between px-4 py-3 text-left transition-colors hover:bg-dark-800/50"
|
||||
>
|
||||
<span className="font-medium">{faq.title}</span>
|
||||
<ChevronIcon expanded={expandedFaq === faq.id} />
|
||||
@@ -399,7 +738,7 @@ export default function Info() {
|
||||
{/* Progress bar to next tier */}
|
||||
{loyaltyData.next_tier_name && loyaltyData.next_tier_threshold_rubles ? (
|
||||
<div>
|
||||
<div className="mb-2 flex justify-between text-xs text-dark-400">
|
||||
<div className="mb-2 flex flex-col gap-1 text-xs text-dark-400 sm:flex-row sm:justify-between">
|
||||
<span>
|
||||
{t('info.nextStatus')}: {loyaltyData.next_tier_name}
|
||||
</span>
|
||||
@@ -453,14 +792,14 @@ export default function Info() {
|
||||
>
|
||||
<StarIcon />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-dark-50">{tier.name}</h4>
|
||||
<div className="min-w-0">
|
||||
<h4 className="truncate font-semibold text-dark-50">{tier.name}</h4>
|
||||
<p className="text-xs text-dark-400">
|
||||
{t('info.threshold')}: {formatCurrency(tier.threshold_rubles)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{getStatusBadge(tier)}
|
||||
<span className="shrink-0">{getStatusBadge(tier)}</span>
|
||||
</div>
|
||||
|
||||
{/* Discounts */}
|
||||
@@ -514,19 +853,19 @@ export default function Info() {
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="scrollbar-hide flex gap-2 overflow-x-auto pb-1 sm:flex-wrap sm:overflow-x-visible">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
|
||||
className={`flex min-h-[44px] shrink-0 items-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-accent-500 text-white'
|
||||
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
|
||||
}`}
|
||||
>
|
||||
<tab.icon />
|
||||
{tab.label}
|
||||
{tab.emoji ? <span className="text-base">{tab.emoji}</span> : <tab.icon />}
|
||||
<span className="max-w-[140px] truncate">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user