This commit is contained in:
2026-06-19 12:43:29 +03:00
parent 990f7c8544
commit 663f6dc268
54 changed files with 2884 additions and 2207 deletions

View File

@@ -0,0 +1,109 @@
# Юридические страницы (`entities/legal-doc`)
Общий механизм для **пяти статичных юридических страниц**. Раньше каждая была
самостоятельным `.tsx` с захардкоженной разметкой (200300 строк JSX) и почти
идентичным собственным `.module.css`. После рефакторинга весь текст вынесен в
**данные** (`LegalDoc`-объекты), а рендерит их **один шаблон** `LegalDoc` с единым
стилем. Результат пункта чек-листа `app/providers/ROUTES_REFACTOR.md` → «Юридические
страницы … статичный текст вынести в данные, рендерить общим шаблоном».
## Какие страницы
| Маршрут (`ROUTES`) | Страница (`pages/*`) | Данные | h1 |
|---|---|---|---|
| `/publichnaya-oferta` | `publichnaya-oferta` | `model/content.ts → publichnayaOfertaDoc` | ПУБЛИЧНЫЙ ДОГОВОР ОФЕРТЫ |
| `/politika-personalnyh-dannyh` | `politika-personalnyh-dannyh` | `politikaPersonalnyhDannyhDoc` | ПОЛИТИКА ОБРАБОТКИ ПД |
| `/politika-cookie` | `politika-cookie` | `politikaCookieDoc` | ПОЛИТИКА COOKIE |
| `/soglasie-personalnyh-dannyh` | `soglasie-personalnyh-dannyh` | `soglasieDoc` | СОГЛАСИЕ НА ОБРАБОТКУ ПД |
| `/reestr-pd-rkn` | `reestr-pd-rkn` | `reestrDoc` | Реестр операторов ПД |
Все пять — **публичные** (вне `ProtectedRoute`/`GuestRoute`), подключены в
`app/providers/RouterProvider.tsx`.
## Архитектура (FSD: `pages → entities`)
```
entities/legal-doc/ ← общий, переиспользуемый механизм (без контента)
model/types.ts ← типы LegalDoc / LegalSection / LegalBlock
ui/LegalDoc.tsx ← единый рендерер (main→container→title→sections→blocks)
ui/LegalDoc.module.css ← единый стиль (слит из 5 старых модулей)
index.ts ← export { LegalDoc }, типы (LegalDocData = тип данных)
README.md ← этот файл
pages/<slug>/ ← каждая страница = контент + тонкая обёртка
model/content.ts ← <slug>Doc: LegalDocData — ВЕСЬ текст страницы
ui/<Name>Page.tsx ← <Header/> <LegalDoc doc={<slug>Doc}/> <Footer/>
index.ts ← export { <Name>Page } (не менялся)
```
> **Почему `entities`, а не `widgets`.** `Header`/`Footer` — это виджеты. Если бы шаблон
> рендерил их сам, он стал бы виджетом, импортирующим другие виджеты (межвиджетный импорт,
> который другой пункт чек-листа просит как раз убирать). Поэтому `LegalDoc` рендерит
> **только тело** документа (`<main>…`), а `Header`/`Footer` добавляет страница-обёртка.
> Слой `entities` ниже `widgets` — нарушения порядка слоёв нет. Структура повторяет
> соседний `entities/commission` (`model/` + `ui/` + barrel).
## Модель данных (`model/types.ts`)
```ts
LegalDoc = { title: string; subtitle?: string; sections: LegalSection[] }
LegalSection = { title?: string; blocks: LegalBlock[] } // title → h3; нет title → секция без заголовка
```
`LegalBlock` — размеченный union (поле `kind`). Порядок блоков в массиве = порядок на
экране (подзаголовки и абзацы чередуются как в исходнике).
| `kind` | Поля | Рендер | Где используется |
|---|---|---|---|
| `text` | `text`, `variant?` | `<p class=text>` | везде |
| `subheading` | `text` | `<h4 class=subheading>` (был `subSectionTitle`) | политики, согласие, cookie |
| `list` | `items: LegalListItem[]` | `<ul class=list>` | везде |
| `definitions` | `items: {term,text}[]` | боксы с левым акцентом | оферта, политика ПД |
| `goals` | `items: {title,items[]}[]` | боксы «заголовок + вложенный список» | политика ПД (раздел «Цели сбора») |
| `infoBox` | `lines: LegalListItem[]` | бокс из `<p>`-строк | реквизиты, контакты, данные оператора |
| `link` | `href`, `text` | центрированная кнопка-ссылка (`target=_blank`) | только реестр РКН |
`variant` у `text`: `'example'` (курсивный акцент-бокс), `'warning'` (жёлтый бокс),
`'emphasis'` (полужирный абзац, был `.confirmation`). Без `variant` — обычный абзац.
`LegalListItem` = либо строка, либо `{ strong, text? }` (жирный зачин + остаток).
Используется в `list` и в `infoBox`.
### Важный нюанс: разделитель (`—`, ``, `:`) хранится в данных
Рендерер `{strong,text}` выводит как `<strong>{strong}</strong> {text}`**без** своего
разделителя. Поэтому тире/двоеточие пишется прямо в данных:
- cookie: `{ strong: 'Добровольность', text: '— согласие дается…' }` → «**Добровольность** — …»
- реестр: `{ strong: 'ИНН:', text: '9810001062' }` → «**ИНН:** 9810001062»
Аналогично у `definitions` тире входит в `text` (`{ term: 'Агент', text: ' юридическое лицо…' }`),
т.к. в оферте `` (en-dash), а в политике ПД `—` (em-dash) — единый разделитель в коде
исказил бы исходник.
## Что изменилось визуально (унификация стилей)
Пять старых `.module.css` были на 90% одинаковы. При слиянии разнобойные боксы
`requisites` / `info` / `contacts` / `operatorInfo` сведены к одному классу `.infoBox`
(фон-стекло + левый акцент). Единственное реальное изменение: у **реквизитов оферты**
раньше была сплошная рамка, теперь — левый акцент, как у остальных боксов.
> Сохранён как было (намеренно, чтобы рефакторинг не менял поведение): `text variant=warning`
> — светло-жёлтый фон `#fff3cd` с белым текстом (низкий контраст). Это исторический баг
> исходного cookie-CSS; чинить — отдельной задачей, не в рамках этого пункта.
## Как добавить / отредактировать страницу
- **Поправить текст** — только `pages/<slug>/model/content.ts`. Шаблон и стиль не трогать.
- **Новая юридическая страница** — добавить `ROUTES`, `<Route>` в `RouterProvider`,
папку `pages/<slug>` с `model/content.ts` (объект `LegalDocData`) и тонкой обёрткой
`<Header/> <LegalDoc doc={…}/> <Footer/>`. Новых CSS-файлов не нужно.
- **Новый тип блока** — добавить вариант в `LegalBlock` (`model/types.ts`), ветку в
`Block` (`ui/LegalDoc.tsx`) и стиль в `ui/LegalDoc.module.css`.
## Проверка
```bash
npm run build # tsc -b + vite build (тестов в проекте нет)
npm run lint
npm run dev # ручная проверка пяти /publichnaya-oferta, /politika-*, /soglasie-*, /reestr-pd-rkn
```

View File

@@ -0,0 +1,2 @@
export { LegalDoc } from './ui/LegalDoc'
export type { LegalDoc as LegalDocData, LegalSection, LegalBlock, LegalListItem } from './model/types'

View File

@@ -0,0 +1,38 @@
/**
* Модель данных для юридических страниц (оферта, политики, согласие, реестр).
* Весь статичный текст описывается этими типами и рендерится одним шаблоном
* `LegalDoc` (см. `../ui/LegalDoc.tsx`). README — в корне `entities/legal-doc`.
*/
/** Элемент списка: простая строка или «жирный зачин + остаток». */
export type LegalListItem = string | { strong: string; text?: string }
/** Блок контента внутри секции. Порядок блоков в секции сохраняется как есть. */
export type LegalBlock =
/** Абзац. variant управляет оформлением (пример / предупреждение / акцент). */
| { kind: 'text'; text: string; variant?: 'example' | 'warning' | 'emphasis' }
/** Подзаголовок внутри секции (был `h4.subSectionTitle`). */
| { kind: 'subheading'; text: string }
/** Маркированный список. */
| { kind: 'list'; items: LegalListItem[] }
/** Список определений (термин + расшифровка) в виде боксов. */
| { kind: 'definitions'; items: { term: string; text: string }[] }
/** Список целей: заголовок + вложенный список — в виде боксов. */
| { kind: 'goals'; items: { title: string; items: string[] }[] }
/** Информационный бокс со строками (реквизиты, контакты, данные оператора). */
| { kind: 'infoBox'; lines: LegalListItem[] }
/** Кнопка-ссылка (внешний ресурс), по центру в отдельном блоке. */
| { kind: 'link'; href: string; text: string }
/** Секция документа: необязательный заголовок (h3) + блоки контента. */
export interface LegalSection {
title?: string
blocks: LegalBlock[]
}
/** Документ целиком: заголовок (h1), необязательный подзаголовок (h2), секции. */
export interface LegalDoc {
title: string
subtitle?: string
sections: LegalSection[]
}

View File

@@ -0,0 +1,226 @@
/* Единый стиль юридических страниц. Слит из пяти почти одинаковых *.module.css.
Боксы info/contacts/operatorInfo/requisites унифицированы в .infoBox. */
.main {
padding: 40px 20px;
max-width: 1200px;
margin: 0 auto;
}
.container {
background: var(--bg-mid, #1b1547);
padding: 40px;
border-radius: 8px;
}
.title {
font-size: 28px;
font-weight: 700;
margin-bottom: 20px;
text-align: center;
color: var(--text-primary, #ffffff);
}
.subtitle {
font-size: 22px;
font-weight: 600;
margin-bottom: 30px;
text-align: center;
color: var(--text-secondary, #b5b0cc);
}
.section {
margin-bottom: 40px;
}
.sectionTitle {
font-size: 18px;
font-weight: 600;
margin-bottom: 15px;
color: var(--text-primary, #ffffff);
border-bottom: 2px solid var(--interactive, #4a6dff);
padding-bottom: 10px;
}
.subheading {
font-size: 16px;
font-weight: 600;
margin-top: 20px;
margin-bottom: 10px;
color: var(--text-primary, #ffffff);
}
.text {
line-height: 1.7;
color: var(--text-primary, #ffffff);
margin: 0 0 12px;
}
/* Вариант абзаца: пример (курсивный акцент-бокс). */
.example {
padding: 10px 15px;
background: var(--glass-bg, rgba(255, 255, 255, 0.04));
border-left: 3px solid var(--interactive, #4a6dff);
border-radius: 4px;
font-style: italic;
margin: 10px 0;
}
/* Вариант абзаца: предупреждение.
NB: исторический контраст (светлый фон + белый текст) сохранён как было. */
.warning {
padding: 15px;
background: #fff3cd;
border-left: 4px solid #ffc107;
border-radius: 4px;
margin: 15px 0;
font-weight: 500;
}
/* Вариант абзаца: акцент (был `.confirmation`). */
.emphasis {
font-weight: 500;
}
.list {
list-style: disc;
margin: 0 0 12px 20px;
line-height: 1.8;
color: var(--text-primary, #ffffff);
}
.list li {
margin-bottom: 8px;
}
.list strong {
color: var(--text-primary, #ffffff);
}
.definitions {
display: flex;
flex-direction: column;
gap: 15px;
margin: 0 0 12px;
}
.definition {
padding: 15px;
background: var(--glass-bg, rgba(255, 255, 255, 0.04));
border-left: 4px solid var(--interactive, #4a6dff);
border-radius: 4px;
line-height: 1.6;
}
.goals {
display: flex;
flex-direction: column;
gap: 20px;
margin: 0 0 12px;
}
.goal {
padding: 15px;
background: var(--glass-bg, rgba(255, 255, 255, 0.04));
border-left: 4px solid var(--interactive, #4a6dff);
border-radius: 4px;
}
.goal strong {
display: block;
margin-bottom: 10px;
color: var(--text-primary, #ffffff);
}
.goal ul {
list-style: disc;
margin-left: 20px;
line-height: 1.6;
}
.goal li {
margin-bottom: 6px;
color: var(--text-primary, #ffffff);
}
.infoBox {
padding: 20px;
background: var(--glass-bg, rgba(255, 255, 255, 0.04));
border-left: 4px solid var(--interactive, #4a6dff);
border-radius: 4px;
margin: 15px 0;
line-height: 1.8;
}
.infoBox p {
margin: 8px 0;
color: var(--text-primary, #ffffff);
}
.infoBox strong {
color: var(--text-primary, #ffffff);
}
.linkBlock {
text-align: center;
padding: 30px;
background: var(--glass-bg, rgba(255, 255, 255, 0.04));
border-radius: 8px;
margin: 30px 0;
}
.button {
display: inline-block;
padding: 15px 40px;
background: var(--interactive, #4a6dff);
color: #fff;
text-decoration: none;
border-radius: 6px;
font-weight: 600;
transition: all 0.3s ease;
border: 2px solid var(--interactive, #4a6dff);
}
.button:hover {
background: transparent;
color: var(--interactive, #4a6dff);
}
@media (max-width: 768px) {
.container {
padding: 20px;
}
.title {
font-size: 22px;
}
.subtitle {
font-size: 18px;
}
.sectionTitle {
font-size: 16px;
}
.subheading {
font-size: 14px;
}
.list {
margin-left: 15px;
}
.infoBox {
padding: 15px;
}
.linkBlock {
padding: 20px;
}
.button {
padding: 12px 30px;
font-size: 14px;
}
}

View File

@@ -0,0 +1,101 @@
import type { ReactNode } from 'react'
import type { LegalBlock, LegalDoc as LegalDocData, LegalListItem } from '../model/types'
import styles from './LegalDoc.module.css'
/** «Жирный зачин + остаток» либо простая строка. Разделитель (—, :) пишется в text. */
function renderItem(item: LegalListItem): ReactNode {
if (typeof item === 'string') return item
return (
<>
<strong>{item.strong}</strong>
{item.text ? ` ${item.text}` : null}
</>
)
}
function Block({ block }: { block: LegalBlock }) {
switch (block.kind) {
case 'text':
return (
<p className={block.variant ? `${styles.text} ${styles[block.variant]}` : styles.text}>
{block.text}
</p>
)
case 'subheading':
return <h4 className={styles.subheading}>{block.text}</h4>
case 'list':
return (
<ul className={styles.list}>
{block.items.map((item, i) => (
<li key={i}>{renderItem(item)}</li>
))}
</ul>
)
case 'definitions':
return (
<div className={styles.definitions}>
{block.items.map((d, i) => (
<div key={i} className={styles.definition}>
<strong>{d.term}</strong> {d.text}
</div>
))}
</div>
)
case 'goals':
return (
<div className={styles.goals}>
{block.items.map((g, i) => (
<div key={i} className={styles.goal}>
<strong>{g.title}</strong>
<ul>
{g.items.map((t, j) => (
<li key={j}>{t}</li>
))}
</ul>
</div>
))}
</div>
)
case 'infoBox':
return (
<div className={styles.infoBox}>
{block.lines.map((line, i) => (
<p key={i}>{renderItem(line)}</p>
))}
</div>
)
case 'link':
return (
<div className={styles.linkBlock}>
<a href={block.href} target="_blank" rel="noopener noreferrer" className={styles.button}>
{block.text}
</a>
</div>
)
}
}
/**
* Единый шаблон юридической страницы. Получает данные документа (`LegalDoc`) и
* рендерит `main → container → title/subtitle → sections → blocks`.
* Header/Footer добавляет страница-обёртка, не этот компонент (entities < widgets).
*/
export function LegalDoc({ doc }: { doc: LegalDocData }) {
return (
<main className={styles.main}>
<div className={styles.container}>
<h1 className={styles.title}>{doc.title}</h1>
{doc.subtitle && <h2 className={styles.subtitle}>{doc.subtitle}</h2>}
{doc.sections.map((section, i) => (
<section key={i} className={styles.section}>
{section.title && <h3 className={styles.sectionTitle}>{section.title}</h3>}
{section.blocks.map((block, j) => (
<Block key={j} block={block} />
))}
</section>
))}
</div>
</main>
)
}