refactor
This commit is contained in:
109
src/entities/legal-doc/README.md
Normal file
109
src/entities/legal-doc/README.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# Юридические страницы (`entities/legal-doc`)
|
||||
|
||||
Общий механизм для **пяти статичных юридических страниц**. Раньше каждая была
|
||||
самостоятельным `.tsx` с захардкоженной разметкой (200–300 строк 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
|
||||
```
|
||||
2
src/entities/legal-doc/index.ts
Normal file
2
src/entities/legal-doc/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { LegalDoc } from './ui/LegalDoc'
|
||||
export type { LegalDoc as LegalDocData, LegalSection, LegalBlock, LegalListItem } from './model/types'
|
||||
38
src/entities/legal-doc/model/types.ts
Normal file
38
src/entities/legal-doc/model/types.ts
Normal 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[]
|
||||
}
|
||||
226
src/entities/legal-doc/ui/LegalDoc.module.css
Normal file
226
src/entities/legal-doc/ui/LegalDoc.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
101
src/entities/legal-doc/ui/LegalDoc.tsx
Normal file
101
src/entities/legal-doc/ui/LegalDoc.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user