, ) 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({
diff --git a/src/pages/PublicLegal.tsx b/src/pages/PublicLegal.tsx
new file mode 100644
index 0000000..024b17b
--- /dev/null
+++ b/src/pages/PublicLegal.tsx
@@ -0,0 +1,88 @@
+import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { Link } from 'react-router';
+import { infoApi } from '../api/info';
+import { formatContent } from '../utils/legalContent';
+import LanguageSwitcher from '../components/LanguageSwitcher';
+
+export type PublicLegalDoc = 'offer' | 'privacy';
+
+interface PublicLegalProps {
+ doc: PublicLegalDoc;
+}
+
+const DOC_CONFIG: Record<
+ PublicLegalDoc,
+ {
+ queryKey: string;
+ titleKey: string;
+ titleFallback: string;
+ fetch: () => Promise<{ content: string; updated_at: string | null }>;
+ }
+> = {
+ offer: {
+ queryKey: 'public-offer',
+ titleKey: 'footer.offer',
+ titleFallback: 'Публичная оферта',
+ fetch: infoApi.getPublicOffer,
+ },
+ privacy: {
+ queryKey: 'privacy-policy',
+ titleKey: 'footer.privacy',
+ titleFallback: 'Политика конфиденциальности',
+ fetch: infoApi.getPrivacyPolicy,
+ },
+};
+
+// Public, unauthenticated viewer for the legal documents linked from the login
+// footer. Reads the same public /cabinet/info endpoints the authenticated Info page
+// uses, so the pages are reachable before login instead of bouncing to /login.
+export default function PublicLegal({ doc }: PublicLegalProps) {
+ const { t } = useTranslation();
+ const config = DOC_CONFIG[doc];
+
+ const { data, isLoading, isError } = useQuery({
+ queryKey: ['public-legal', config.queryKey],
+ queryFn: config.fetch,
+ staleTime: 5 * 60 * 1000,
+ });
+
+ const title = t(config.titleKey, config.titleFallback);
+
+ return (
+
+
+
+
+
+
+ {title}
+
+ {isLoading ? (
+
+
+
+ ) : isError || !data?.content ? (
+
+ {t('info.documentUnavailable', 'Документ пока недоступен.')}
+
+ ) : (
+
+
+ {data.updated_at && (
+
+ {t('info.updatedAt', 'Обновлено')}: {new Date(data.updated_at).toLocaleDateString()}
+
+ )}
+
+ )}
+
+
+
+ {t('auth.backToLogin', 'Вернуться ко входу')}
+
+
+
+
+ );
+}
diff --git a/src/utils/legalContent.ts b/src/utils/legalContent.ts
new file mode 100644
index 0000000..b9a767d
--- /dev/null
+++ b/src/utils/legalContent.ts
@@ -0,0 +1,88 @@
+import DOMPurify from 'dompurify';
+
+// Sanitize HTML content to prevent XSS. Shared by the authenticated Info page and
+// the public legal pages (offer / privacy) reachable from the login footer.
+export const sanitizeHtml = (html: string): string => {
+ return DOMPurify.sanitize(html, {
+ ALLOWED_TAGS: [
+ 'p',
+ 'br',
+ 'b',
+ 'i',
+ 'u',
+ 'strong',
+ 'em',
+ 'a',
+ 'ul',
+ 'ol',
+ 'li',
+ 'h1',
+ 'h2',
+ 'h3',
+ 'h4',
+ 'h5',
+ 'h6',
+ 'blockquote',
+ 'code',
+ 'pre',
+ 's',
+ 'del',
+ 'ins',
+ 'span',
+ 'div',
+ 'tg-spoiler',
+ ],
+ ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'start'],
+ ALLOW_DATA_ATTR: false,
+ });
+};
+
+// Render legal/document content that may be either full block-level HTML or plain
+// text with Telegram-style inline tags and newline structure.
+export const formatContent = (content: string): string => {
+ if (!content) return '';
+
+ // Check if content has block-level HTML (full HTML document)
+ const hasBlockHtml = /<(p|div|h[1-6]|ul|ol|blockquote)\b/i.test(content);
+
+ if (hasBlockHtml) {
+ return sanitizeHtml(content);
+ }
+
+ // Content may have inline Telegram HTML (, , , , ) 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);
+};