, ) but uses
// newlines for structure. Convert newlines to paragraphs while preserving inline tags.
const result = content
.split(/\n\n+/)
.map((paragraph) => {
const trimmed = paragraph.trim();
if (!trimmed) return '';
// Check if it's a markdown header
if (/^#{1,4}\s/.test(trimmed)) {
const level = trimmed.match(/^(#{1,4})/)?.[1].length || 1;
const text = trimmed.replace(/^#{1,4}\s*/, '');
return `${text} `;
}
// Check for list items
if (/^[-•]\s/.test(trimmed) || /^\d+[.)]\s/.test(trimmed)) {
const lines = trimmed.split('\n');
const isOrdered = /^\d+[.)]\s/.test(lines[0]);
const startNum = isOrdered ? parseInt(lines[0].match(/^(\d+)/)?.[1] || '1', 10) : 1;
const listItems = lines
.map((line) => line.replace(/^[-•]\s*/, '').replace(/^\d+[.)]\s*/, ''))
.filter((line) => line.trim())
.map((line) => `${line} `)
.join('');
return isOrdered ? `${listItems}
` : `${listItems}
`;
}
// Regular paragraph — single newlines become
const formatted = trimmed.split('\n').join('
');
return `${formatted}
`;
})
.filter(Boolean)
.join('');
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, i18n } = useTranslation();
const [activeTab, setActiveTab] = useState('faq');
const [expandedFaq, setExpandedFaq] = useState(null);
const locale = i18n.language.split('-')[0];
// Fetch tab replacements
const { data: tabReplacements, isError: replacementsError } = 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 and don't collide with built-in IDs
const extraPages = useMemo(
() => (customPages ?? []).filter((p) => !p.replaces_tab && !BUILTIN_TABS.has(p.slug)),
[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 (also proceed on error — graceful degradation)
const replacementsLoaded = tabReplacements !== undefined || replacementsError;
// 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' && !currentTabSlug && replacementsLoaded,
staleTime: 0,
refetchOnMount: 'always',
});
const { data: rules, isLoading: rulesLoading } = useQuery({
queryKey: ['rules'],
queryFn: infoApi.getRules,
enabled: activeTab === 'rules' && !currentTabSlug && replacementsLoaded,
staleTime: 0,
refetchOnMount: 'always',
});
const { data: privacy, isLoading: privacyLoading } = useQuery({
queryKey: ['privacy-policy'],
queryFn: infoApi.getPrivacyPolicy,
enabled: activeTab === 'privacy' && !currentTabSlug && replacementsLoaded,
staleTime: 0,
refetchOnMount: 'always',
});
const { data: offer, isLoading: offerLoading } = useQuery({
queryKey: ['public-offer'],
queryFn: infoApi.getPublicOffer,
enabled: activeTab === 'offer' && !currentTabSlug && replacementsLoaded,
staleTime: 0,
refetchOnMount: 'always',
});
const { data: loyaltyData, isLoading: loyaltyLoading } = useQuery({
queryKey: ['loyalty-tiers'],
queryFn: promoApi.getLoyaltyTiers,
enabled: activeTab === 'loyalty',
staleTime: 0,
refetchOnMount: 'always',
});
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 = useCallback((id: number) => {
setExpandedFaq((prev) => (prev === 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 (
);
}
if (!faqPages || faqPages.length === 0) {
return {t('info.noFaq')};
}
return (
{faqPages.map((faq: FaqPage) => (
{expandedFaq === faq.id && (
)}
))}
);
}
if (activeTab === 'rules') {
if (rulesLoading) {
return (
);
}
if (!rules?.content) {
return {t('info.noContent')};
}
return (
{rules.updated_at && (
{t('info.updatedAt')}: {new Date(rules.updated_at).toLocaleDateString()}
)}
);
}
if (activeTab === 'privacy') {
if (privacyLoading) {
return (
);
}
if (!privacy?.content) {
return {t('info.noContent')};
}
return (
{privacy.updated_at && (
{t('info.updatedAt')}: {new Date(privacy.updated_at).toLocaleDateString()}
)}
);
}
if (activeTab === 'offer') {
if (offerLoading) {
return (
);
}
if (!offer?.content) {
return {t('info.noContent')};
}
return (
{offer.updated_at && (
{t('info.updatedAt')}: {new Date(offer.updated_at).toLocaleDateString()}
)}
);
}
if (activeTab === 'loyalty') {
if (loyaltyLoading) {
return (
);
}
if (!loyaltyData || loyaltyData.tiers.length === 0) {
return {t('info.noLoyaltyTiers')};
}
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
};
const getStatusBadge = (tier: LoyaltyTierInfo) => {
if (tier.is_current) {
return (
{t('info.statusCurrent')}
);
}
if (tier.is_achieved) {
return (
{t('info.statusAchieved')}
);
}
return (
{t('info.statusLocked')}
);
};
const hasAnyDiscount = (tier: LoyaltyTierInfo) => {
return (
tier.server_discount_percent > 0 ||
tier.traffic_discount_percent > 0 ||
tier.device_discount_percent > 0 ||
Object.keys(tier.period_discounts).length > 0
);
};
return (
{/* Progress Card */}
{t('info.yourProgress')}
{t('info.totalSpent')}
{formatCurrency(loyaltyData.current_spent_rubles)}
{t('info.currentStatus')}
{loyaltyData.current_tier_name || '-'}
{/* Progress bar to next tier */}
{loyaltyData.next_tier_name && loyaltyData.next_tier_threshold_rubles ? (
{t('info.nextStatus')}: {loyaltyData.next_tier_name}
{t('info.toNextStatus')}:{' '}
{formatCurrency(
Math.max(
0,
loyaltyData.next_tier_threshold_rubles - loyaltyData.current_spent_rubles,
),
)}
{loyaltyData.progress_percent.toFixed(1)}%
) : (
{t('info.allStatusesAchieved')}
)}
{/* Tiers List */}
{loyaltyData.tiers.map((tier) => (
{tier.name}
{t('info.threshold')}: {formatCurrency(tier.threshold_rubles)}
{getStatusBadge(tier)}
{/* Discounts */}
{hasAnyDiscount(tier) ? (
{t('info.discounts')}:
{tier.server_discount_percent > 0 && (
{t('info.serverDiscount')}: -{tier.server_discount_percent}%
)}
{tier.traffic_discount_percent > 0 && (
{t('info.trafficDiscount')}: -{tier.traffic_discount_percent}%
)}
{tier.device_discount_percent > 0 && (
{t('info.deviceDiscount')}: -{tier.device_discount_percent}%
)}
{Object.entries(tier.period_discounts).map(([days, percent]) => (
{t('info.periodDiscount', { days })}: -{percent}%
))}
) : (
{t('info.noDiscounts')}
)}
))}
);
}
return null;
};
return (
{t('info.title')}
{/* Tabs */}
{tabs.map((tab) => (
))}
{/* Content */}
{renderContent()}
);
}