mirror of
https://github.com/gromlab-ru/slm-design.git
synced 2026-08-22 07:30:16 +03:00
chore: sync
This commit is contained in:
@@ -1,14 +0,0 @@
|
||||
# Черновики SLM
|
||||
|
||||
> Материалы в `DRAFT` являются рабочими черновиками и не задают нормативную спецификацию SLM.
|
||||
|
||||
## Материалы
|
||||
|
||||
- [Архитектура](./architecture/README.md) - слои, модули, публичные API, фасеты и зависимости.
|
||||
- [Правила](./rules/README.md) - канонические наборы, формат и правила формулировки.
|
||||
|
||||
## Соглашение
|
||||
|
||||
Черновики могут содержать определения, правила, рекомендации, примеры и открытые вопросы.
|
||||
|
||||
Нормативные определения задаются [терминологией](./architecture/terminology.md). Только блокирующие правила получают код SLM; тематические черновики ссылаются на канонические правила и не повторяют их формулировки.
|
||||
@@ -1,42 +0,0 @@
|
||||
# Архитектура SLM
|
||||
|
||||
> Статус: рабочий черновик. Документы в этой папке не являются спецификацией.
|
||||
|
||||
SLM задаёт структурную основу приложения: слои, модули, публичные границы, зависимости, фасеты сред выполнения и владение жизненным циклом.
|
||||
|
||||
## Область SLM
|
||||
|
||||
Черновик описывает слои, модули, группы, сегменты, компоненты, публичный API, зависимости и владение жизненным циклом ресурсов.
|
||||
|
||||
SLM не задаёт обязательную внутреннюю форму модуля, обязательный поток данных, правила монорепозиториев или полный файловый стайлгайд. Форма модуля следует его ответственности и реальным потребителям.
|
||||
|
||||
## Виды утверждений
|
||||
|
||||
- **Определение** нормативно задаёт смысл архитектурного термина, но не получает код правила.
|
||||
- **Правило** задаёт блокирующий архитектурный инвариант и объявляется в каноническом реестре.
|
||||
- **Рекомендация** помогает принять решение, но не делает архитектуру невалидной.
|
||||
- **Пример** иллюстрирует модель и не задаёт обязательную структуру.
|
||||
|
||||
Нормативные определения находятся в [терминологии](./terminology.md). Канонический набор требований находится в [реестре правил SLM](../rules/registry.md). Формат и требования к правилам описаны отдельно в разделе [Правила SLM](../rules/).
|
||||
|
||||
Остальные документы объясняют и иллюстрируют модель, но не владеют точными формулировками определений и правил.
|
||||
|
||||
## Основная идея
|
||||
|
||||
Модуль является основной архитектурной единицей SLM. Слой задаёт его роль и направление зависимостей: модули слоя `domains` владеют предметными ответственностями на тех же основаниях, что и остальные модули. Группа помогает навигации, сегмент организует внутреннее содержимое, а компонент всегда принадлежит модулю.
|
||||
|
||||
SLM требует отдельную папку и единый логический публичный API модуля. По умолчанию API представлен корневым `index`; при необходимости модуль добавляет environment-фасеты `client`, `browser` и `server`. Внутренняя файловая форма модулей, сегментов и компонентов определяется стайлгайдом проекта.
|
||||
|
||||
## Карта черновика
|
||||
|
||||
- [Терминология](./terminology.md)
|
||||
- [Слои](./layers.md)
|
||||
- [Модули слоя domains](./domains.md)
|
||||
- [Зависимости](./dependencies.md)
|
||||
- [Модули](./modules.md)
|
||||
- [Группы](./groups.md)
|
||||
- [Сегменты](./segments.md)
|
||||
- [Компоненты](./components.md)
|
||||
- [Вложенные модули](./nested-modules.md)
|
||||
- [Жизненный цикл](./lifecycle.md)
|
||||
- [Проверка](./validation.md)
|
||||
@@ -1,65 +0,0 @@
|
||||
# Компоненты SLM
|
||||
|
||||
> Пояснение нормативной модели компонентов SLM.
|
||||
|
||||
Компонент является строительным элементом интерфейса, а не самостоятельной архитектурной единицей.
|
||||
|
||||
## Связанные правила
|
||||
|
||||
- [`SLM-COMPONENT-R009`](../rules/registry.md#slm-component-r009)
|
||||
- [`SLM-LAYER-A002`](../rules/registry.md#slm-layer-a002)
|
||||
- [`SLM-MODULE-A004`](../rules/registry.md#slm-module-a004)
|
||||
- [`SLM-DEPENDENCY-A005`](../rules/registry.md#slm-dependency-a005)
|
||||
- [`SLM-MODULE-R011`](../rules/registry.md#slm-module-r011)
|
||||
- [`SLM-LIFECYCLE-R013`](../rules/registry.md#slm-lifecycle-r013)
|
||||
|
||||
## Файловая форма
|
||||
|
||||
Файловую форму компонента определяет стайлгайд. Компонент может быть одним файлом фреймворка или каталогом со вспомогательными файлами.
|
||||
|
||||
```text
|
||||
landing/
|
||||
└── ui/
|
||||
└── hero.tsx
|
||||
```
|
||||
|
||||
```text
|
||||
landing/
|
||||
└── ui/
|
||||
└── hero/
|
||||
├── hero.tsx
|
||||
├── styles/
|
||||
│ └── hero.module.css
|
||||
└── types/
|
||||
└── hero-props.type.ts
|
||||
```
|
||||
|
||||
Наличие каталога, типов, стилей или локального `index.ts` не превращает компонент в модуль.
|
||||
|
||||
## Реализация
|
||||
|
||||
Компонент может отображать входные данные, вызывать переданные обработчики, условно строить интерфейс и хранить локальное состояние представления.
|
||||
|
||||
SLM не вводит отдельного запрета на импорты, выполняемые кодом компонента. Каждый такой импорт считается зависимостью родительского модуля и должен соблюдать направление слоёв, публичные фасеты и запрет циклов.
|
||||
|
||||
Доступ к данным, состояние, контекст или код жизненного цикла внутри компонента сами по себе не создают новую архитектурную границу. Их источники, зависимости и область жизни определяет родительский модуль.
|
||||
|
||||
Провайдер может технически реализовывать контекст и жизненный цикл фреймворка, но владельцем состояния и ресурсов остаётся родительский модуль.
|
||||
|
||||
Файл в `app` может технически быть компонентом React или Vue. Архитектурно он является точкой входа фреймворка, а не компонентом SLM.
|
||||
|
||||
## Компонент и модуль
|
||||
|
||||
| Признак | Компонент | Модуль |
|
||||
|---|---|---|
|
||||
| Самостоятельная ответственность | Нет | Да |
|
||||
| Собственный публичный API | Нет | Да |
|
||||
| Собственная граница зависимостей | Нет | Да |
|
||||
| Вспомогательные файлы | Может иметь | Может иметь |
|
||||
| Сегменты и вложенные модули | Нет | Может иметь |
|
||||
|
||||
Модуль может состоять всего из одного корневого компонента. Различие определяется владением, а не количеством файлов.
|
||||
|
||||
## Когда нужен вложенный модуль
|
||||
|
||||
Если часть интерфейса получает самостоятельную ответственность, публичный API, собственную границу зависимостей, область жизни или внутреннюю модульную декомпозицию, она является модулем. При локальном использовании такой модуль может размещаться как вложенный.
|
||||
@@ -1,87 +0,0 @@
|
||||
# Зависимости SLM
|
||||
|
||||
> Пояснение нормативной модели зависимостей SLM.
|
||||
|
||||
Матрица слоёв задаёт допустимые зависимости между модулями.
|
||||
|
||||
## Что считается зависимостью
|
||||
|
||||
- Обычный импорт, импорт типа и реэкспорт одинаково создают архитектурную зависимость.
|
||||
- Импорт между файлами одного модуля не пересекает модульную границу.
|
||||
- Импорт любого внутреннего файла, сегмента или компонента считается зависимостью ближайшего модуля-владельца.
|
||||
- Вложенный модуль имеет собственную границу зависимостей.
|
||||
- Группы, сегменты и компоненты не имеют самостоятельных границ зависимостей.
|
||||
|
||||
Точка входа фреймворка не является модулем. Её импорты участвуют в проверке направления слоёв.
|
||||
|
||||
Ресурс `shared` также не является модулем. Его прямой импорт участвует в проверке направления слоёв, но не нарушает требование о публичном API модуля.
|
||||
|
||||
## Допустимые связи
|
||||
|
||||
- Модуль может импортировать модули своего слоя и слоёв, разрешённых нормативной матрицей.
|
||||
- Модули одного слоя могут импортировать друг друга.
|
||||
- Промежуточный слой не является обязательным посредником.
|
||||
- `infra` может импортировать `ui` для визуального представления технической возможности; `ui` не импортирует `infra`.
|
||||
|
||||
Модуль слоя `domains` может импортировать публичный API другого разрешённого модуля. Runtime- и type-only импорты одинаково создают архитектурную зависимость, а циклические зависимости запрещены.
|
||||
|
||||
```ts
|
||||
// domains/orders
|
||||
import type { Product } from '@/domains/catalog'
|
||||
```
|
||||
|
||||
SLM не требует отдельного механизма инъекции между модулями слоя `domains`.
|
||||
|
||||
Матрица слоёв определена в [Слоях](./layers.md).
|
||||
|
||||
## Связанные правила
|
||||
|
||||
- [`SLM-LAYER-A002`](../rules/registry.md#slm-layer-a002)
|
||||
- [`SLM-MODULE-A004`](../rules/registry.md#slm-module-a004)
|
||||
- [`SLM-DEPENDENCY-A005`](../rules/registry.md#slm-dependency-a005)
|
||||
- [`SLM-NESTED_MODULE-A010`](../rules/registry.md#slm-nested_module-a010)
|
||||
- [`SLM-ENVIRONMENT-R016`](../rules/registry.md#slm-environment-r016)
|
||||
- [`SLM-ENVIRONMENT-R017`](../rules/registry.md#slm-environment-r017)
|
||||
- [`SLM-ENVIRONMENT-R018`](../rules/registry.md#slm-environment-r018)
|
||||
- [`SLM-ENVIRONMENT-R019`](../rules/registry.md#slm-environment-r019)
|
||||
|
||||
## Публичный API
|
||||
|
||||
```ts
|
||||
// Допустимо
|
||||
import { Button } from '@/ui/button'
|
||||
|
||||
// Недопустимо
|
||||
import { Button } from '@/ui/button/button'
|
||||
```
|
||||
|
||||
Если модуль объявляет специализированный фасет, его путь также является публичным:
|
||||
|
||||
```ts
|
||||
import type { Session } from '@/domains/auth'
|
||||
import { AuthProvider } from '@/domains/auth/client'
|
||||
import { getServerSession } from '@/domains/auth/server'
|
||||
```
|
||||
|
||||
## Фасеты сред выполнения
|
||||
|
||||
Ограничения фасета распространяются на все его runtime-импорты и реэкспорты, включая транзитивные. Type-only import остаётся архитектурной зависимостью, но не добавляет исполняемый код в среду фасета.
|
||||
|
||||
`index` не импортирует и не реэкспортирует `client`, `browser` или `server`. `client` может использовать универсальную внутреннюю реализацию модуля, но не импортирует `browser` или `server`. `browser` и `server` могут использовать универсальную внутреннюю реализацию, но не импортируют друг друга.
|
||||
|
||||
```ts
|
||||
// Browser-only фасет подключается только за границей без SSR.
|
||||
const BrowserEditor = dynamic(
|
||||
() => import('@/domains/editor/browser').then(({ BrowserEditor }) => BrowserEditor),
|
||||
{ ssr: false },
|
||||
)
|
||||
```
|
||||
|
||||
Обычный статический импорт `@/domains/editor/browser` запрещён даже внутри Client Component. Tree shaking, проверка `typeof window` и обещание не вызывать экспорт при SSR не заменяют динамическую границу с отключённым SSR.
|
||||
|
||||
## Циклы
|
||||
|
||||
```text
|
||||
ui/modal → ui/button → ui/icon
|
||||
ui/icon -/→ ui/modal
|
||||
```
|
||||
@@ -1,59 +0,0 @@
|
||||
# Модули слоя domains
|
||||
|
||||
> Пояснение размещения предметных ответственностей в обычных SLM-модулях.
|
||||
|
||||
## Связанные правила
|
||||
|
||||
- [`SLM-MODULE-A004`](../rules/registry.md#slm-module-a004)
|
||||
- [`SLM-MODULE-R006`](../rules/registry.md#slm-module-r006)
|
||||
- [`SLM-GROUP-R007`](../rules/registry.md#slm-group-r007)
|
||||
- [`SLM-LAYER-R001`](../rules/registry.md#slm-layer-r001)
|
||||
|
||||
## Обычные модули SLM
|
||||
|
||||
Модуль слоя `domains` является обычным SLM-модулем. Он отличается от модулей других слоёв только предметной ответственностью, а не отдельной архитектурной сущностью или обязательной файловой формой.
|
||||
|
||||
```text
|
||||
domains/auth/
|
||||
├── hooks/
|
||||
├── services/
|
||||
├── stores/
|
||||
├── types/
|
||||
├── ui/
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
Показанные каталоги являются возможными сегментами, а не обязательным каркасом. Модуль может содержать предметные типы, сценарии, состояние, framework-код, компоненты и вложенные модули.
|
||||
|
||||
## Публичный API
|
||||
|
||||
Внешний код использует модуль через его обычный публичный API:
|
||||
|
||||
```ts
|
||||
import { signOut, useSession } from '@/domains/auth'
|
||||
```
|
||||
|
||||
Глубокий импорт во внутренний сегмент нарушает модульную границу:
|
||||
|
||||
```ts
|
||||
import { useSession } from '@/domains/auth/hooks/use-session'
|
||||
```
|
||||
|
||||
## Groups
|
||||
|
||||
При большом количестве модулей слой `domains` может содержать обычные навигационные Groups:
|
||||
|
||||
```text
|
||||
domains/
|
||||
├── shop/ # Group
|
||||
│ ├── catalog/ # SLM-модуль
|
||||
│ └── orders/ # SLM-модуль
|
||||
└── cabinet/ # Group
|
||||
└── profile/ # SLM-модуль
|
||||
```
|
||||
|
||||
Group не имеет `index.ts`, реализации, состояния или публичного API. Она не создаёт dependency boundary и не реэкспортирует содержащиеся в ней модули.
|
||||
|
||||
## Среды выполнения
|
||||
|
||||
Если части публичного API модуля предназначены для разных сред выполнения, модуль сохраняет одну ответственность и разделяет доступ фасетами `client`, `browser` и `server`.
|
||||
@@ -1,25 +0,0 @@
|
||||
# Группы SLM
|
||||
|
||||
> Пояснение нормативной модели групп SLM.
|
||||
|
||||
Группа помогает ориентироваться в большом количестве модулей. Она классифицирует структуру, но ничего не реализует и не образует самостоятельную границу зависимостей.
|
||||
|
||||
## Связанное правило
|
||||
|
||||
- [`SLM-GROUP-R007`](../rules/registry.md#slm-group-r007)
|
||||
- [`SLM-MODULE-R011`](../rules/registry.md#slm-module-r011)
|
||||
|
||||
## Пример
|
||||
|
||||
```text
|
||||
compositions/
|
||||
├── pages/ # Группа
|
||||
│ ├── landing/ # Модуль
|
||||
│ └── contacts/ # Модуль
|
||||
└── layouts/ # Группа
|
||||
└── main/ # Модуль
|
||||
```
|
||||
|
||||
`pages` и `layouts` являются возможной группировкой проекта, а не обязательной структурой SLM.
|
||||
|
||||
Рекомендуется создавать группу только при реальной навигационной потребности. Если папка начинает владеть файлами реализации, состоянием, жизненным циклом или публичным API, она является модулем и должна получить модульную границу.
|
||||
@@ -1,99 +0,0 @@
|
||||
# Слои SLM
|
||||
|
||||
> Пояснение нормативной модели слоёв SLM.
|
||||
|
||||
## Базовая структура
|
||||
|
||||
```text
|
||||
src/
|
||||
├── app/
|
||||
├── compositions/
|
||||
├── domains/
|
||||
├── infra/
|
||||
├── ui/
|
||||
└── shared/
|
||||
```
|
||||
|
||||
`src/` здесь является примером SLM root. Фактическую границу определяет устройство приложения.
|
||||
|
||||
Отсутствующая в проекте роль не требует пустой папки. Слой является доступной архитектурной ролью, а не обязательным элементом каркаса.
|
||||
|
||||
## Роли слоёв
|
||||
|
||||
### App
|
||||
|
||||
`app` связывает приложение с фреймворком: запускает его, объявляет маршруты, преобразует входные данные и подключает публичные API модулей разрешённых слоёв или ресурсы `shared`. Файлы `app` являются точками входа фреймворка, а не модулями SLM.
|
||||
|
||||
Точка входа может напрямую использовать `compositions`, `domains`, `infra`, `ui` или `shared`, если зависимость разрешена матрицей слоёв. Такое использование не переносит ответственность импортируемого модуля в `app`.
|
||||
|
||||
### Compositions
|
||||
|
||||
`compositions` содержит продуктовый интерфейс: страницы, макеты, экраны, виджеты, точки входа и другие композиционные модули. Внутреннюю группировку слоя определяет проект.
|
||||
|
||||
### Domains
|
||||
|
||||
`domains` содержит обычные SLM-модули, владеющие предметными ответственностями приложения: моделями, правилами, сценариями и продуктовым состоянием.
|
||||
|
||||
Предметная логика не принадлежит устройству одной страницы или маршрута. Конкретный visual outcome и сборка нескольких предметных ответственностей остаются в `compositions`.
|
||||
|
||||
### Infra
|
||||
|
||||
`infra` содержит технические сервисы приложения: аналитику, локализацию, тему, телеметрию и другие возможности среды выполнения без самостоятельной предметной модели.
|
||||
|
||||
### UI
|
||||
|
||||
`ui` содержит универсальные модули интерфейса, которые не зависят от конкретной страницы или продуктовой композиции.
|
||||
|
||||
### Shared
|
||||
|
||||
`shared` содержит независимый детерминированный фундамент без знания о продукте, изменяемого состояния и ввода-вывода.
|
||||
|
||||
В `shared` допускаются обычные модули и специальные немодульные ресурсы: небольшие чистые утилиты, общие типы, стили, конфигурация и статические файлы. Ресурс не имеет самостоятельной ответственности, публичного API или жизненного цикла и может импортироваться напрямую по пути, установленному стайлгайдом.
|
||||
|
||||
Если ресурсу нужны самостоятельная ответственность, собственные архитектурные зависимости, несколько файлов реализации, изменяемое состояние, ввод-вывод или область жизни, он оформляется как модуль. Каталог ресурсов не реэкспортирует модули и не используется для обхода их публичных API.
|
||||
|
||||
## Матрица зависимостей
|
||||
|
||||
```text
|
||||
app
|
||||
|
|
||||
compositions
|
||||
|
|
||||
domains
|
||||
|
|
||||
infra
|
||||
|
|
||||
ui
|
||||
|
|
||||
shared
|
||||
```
|
||||
|
||||
Код слоя может импортировать модули своего слоя и слоёв, разрешённых строкой матрицы. Разрешённая зависимость может пропускать промежуточные роли.
|
||||
|
||||
| Слой | Может импортировать |
|
||||
|---|---|
|
||||
| `app` | `app`, `compositions`, `domains`, `infra`, `ui`, `shared` |
|
||||
| `compositions` | `compositions`, `domains`, `infra`, `ui`, `shared` |
|
||||
| `domains` | `domains`, `infra`, `ui`, `shared` |
|
||||
| `infra` | `infra`, `ui`, `shared` |
|
||||
| `ui` | `ui`, `shared` |
|
||||
| `shared` | `shared` |
|
||||
|
||||
`infra` может импортировать `ui`, когда технической возможности требуется визуальное представление, например CAPTCHA, платёжный виджет, карта, uploader, уведомление или инструмент разработчика. Такое представление остаётся частью технической ответственности и не переносит в `infra` страницы, продуктовые тексты или композицию нескольких модулей.
|
||||
|
||||
`ui` не импортирует `infra`. Универсальный UI получает локализованный текст, тему, callbacks аналитики и другие технические возможности через входной контракт. Если UI-модулю необходимо напрямую знать конкретный технический сервис приложения, интеграция размещается в `infra`, `domains` или `compositions`, а универсальная часть остаётся в `ui`.
|
||||
|
||||
Импорты внутри слоя, публичный API и циклы описаны отдельно в [Зависимостях](./dependencies.md).
|
||||
|
||||
## Связанные правила
|
||||
|
||||
- [`SLM-LAYER-R001`](../rules/registry.md#slm-layer-r001)
|
||||
- [`SLM-LAYER-A002`](../rules/registry.md#slm-layer-a002)
|
||||
- [`SLM-LAYER-R003`](../rules/registry.md#slm-layer-r003)
|
||||
- [`SLM-MODULE-R011`](../rules/registry.md#slm-module-r011)
|
||||
|
||||
## Граница слоя
|
||||
|
||||
Разрешённый импорт не переносит владение. Например, модуль слоя `domains` может использовать `infra` и `ui`, но технический сервис и универсальный интерфейс сохраняют собственных владельцев.
|
||||
|
||||
Если код определяет продуктовую модель, правило или сценарий, он принадлежит `domains`, а не `shared` или `infra`. Внутренняя форма домена определяется его ответственностью и реальными потребителями.
|
||||
@@ -1,31 +0,0 @@
|
||||
# Жизненный цикл
|
||||
|
||||
> Пояснение нормативной модели владения ресурсами SLM.
|
||||
|
||||
Жизненный цикл является частью ответственности модуля. Файл, компонент, провайдер или точка входа фреймворка могут технически создавать и останавливать ресурс, но архитектурным владельцем остаётся модуль.
|
||||
|
||||
## Связанные правила
|
||||
|
||||
- [`SLM-MODULE-R011`](../rules/registry.md#slm-module-r011)
|
||||
- [`SLM-LIFECYCLE-R013`](../rules/registry.md#slm-lifecycle-r013)
|
||||
|
||||
## Граница ресурса
|
||||
|
||||
Для ресурса определяются:
|
||||
|
||||
- модуль-владелец;
|
||||
- место создания;
|
||||
- момент начала работы;
|
||||
- область жизни;
|
||||
- допустимое число экземпляров;
|
||||
- способ остановки и очистки.
|
||||
|
||||
Ресурс начинает работу не раньше начала своей области жизни и не остаётся активным после её завершения. Подписки, слушатели, таймеры, наблюдатели, запросы и соединения рассматриваются одинаково, если требуют явного завершения или отмены.
|
||||
|
||||
## Реализация
|
||||
|
||||
Очистку может выполнять сам модуль, компонент, провайдер или фреймворк. Способ реализации не меняет владельца и не переносит ответственность в технический файл.
|
||||
|
||||
Одиночный экземпляр на всё приложение допустим только тогда, когда модуль действительно владеет областью жизни приложения или процесса. Размещение экземпляра на уровне файла само по себе этого не доказывает.
|
||||
|
||||
Точка входа `app` может запускать или подключать ресурс через публичный API импортируемого модуля, но не становится его владельцем.
|
||||
@@ -1,66 +0,0 @@
|
||||
# Модули SLM
|
||||
|
||||
> Пояснение нормативной модели модулей SLM.
|
||||
|
||||
Модуль является основной архитектурной единицей SLM. Он размещается в отдельной папке, но может состоять только из публичной точки входа и одного файла реализации.
|
||||
|
||||
## Связанные правила
|
||||
|
||||
- [`SLM-MODULE-A004`](../rules/registry.md#slm-module-a004)
|
||||
- [`SLM-MODULE-A014`](../rules/registry.md#slm-module-a014)
|
||||
- [`SLM-MODULE-R006`](../rules/registry.md#slm-module-r006)
|
||||
- [`SLM-MODULE-R011`](../rules/registry.md#slm-module-r011)
|
||||
- [`SLM-MODULE-R012`](../rules/registry.md#slm-module-r012)
|
||||
- [`SLM-ENVIRONMENT-R016`](../rules/registry.md#slm-environment-r016)
|
||||
- [`SLM-ENVIRONMENT-R017`](../rules/registry.md#slm-environment-r017)
|
||||
- [`SLM-ENVIRONMENT-R018`](../rules/registry.md#slm-environment-r018)
|
||||
- [`SLM-ENVIRONMENT-R019`](../rules/registry.md#slm-environment-r019)
|
||||
|
||||
## Владение
|
||||
|
||||
Каждая самостоятельная ответственность имеет одного модуля-владельца. Модуль определяет её публичный API, зависимости, состояние, область жизни и внутреннее устройство независимо от того, в каком файле выполняется конкретный код.
|
||||
|
||||
Точки входа `app` и нормативные ресурсы `shared` являются единственными немодульными исключениями. Остальной код внутри SLM root либо принадлежит существующему модулю, либо образует новый модуль.
|
||||
|
||||
## Публичный API
|
||||
|
||||
Модуль предоставляет один логический публичный API. По умолчанию он представлен корневым `index`, который служит основным barrel. Если реальные потребители требуют разделить несовместимые среды выполнения, модуль добавляет один или несколько фасетов `client`, `browser` и `server`.
|
||||
|
||||
Объявленные фасеты вместе образуют один публичный API и не считаются deep imports. Внешний код использует модуль только через них. Сам API открывает только контракт, необходимый реальным внешним потребителям; внутренние механизмы, изменяемое состояние и детали жизненного цикла остаются закрытыми.
|
||||
|
||||
```text
|
||||
auth/
|
||||
├── index.ts # Универсальный фасет
|
||||
├── client.ts # Необязательная клиентская framework-граница
|
||||
├── browser.ts # Необязательная browser-only граница
|
||||
├── server.ts # Необязательная server-only граница
|
||||
└── ... # Внутренняя реализация
|
||||
```
|
||||
|
||||
`index` экспортирует публичные типы и runtime-код, который одинаково допустимо выполнять при серверном рендеринге, включая RSC, и в клиентском runtime. Он не реэкспортирует специализированные фасеты.
|
||||
|
||||
`client` экспортирует Client Components, hooks, Providers и другой код, который не может выполняться как RSC. Такой код может быть отмечен директивой вроде `use client`.
|
||||
|
||||
`browser` экспортирует browser-only возможности и lazy-функциональность. Потребитель подключает этот фасет только динамически через поддерживаемую фреймворком границу с отключённым SSR.
|
||||
|
||||
`server` экспортирует только server-only возможности. `index`, `client` и `browser` не импортируют и не реэкспортируют его код.
|
||||
|
||||
Фасет не создаётся для симметрии или будущей потребности. Один публичный runtime-export размещается в минимально подходящем фасете и не дублируется между фасетами.
|
||||
|
||||
## Внутреннее устройство
|
||||
|
||||
Модуль может содержать корневые файлы, сегменты, компоненты и [вложенные модули](./nested-modules.md). Внутри своей границы он может использовать относительные импорты и не обязан обращаться к собственному публичному API; точную форму внутренних импортов определяет стайлгайд.
|
||||
|
||||
SLM не требует полного каркаса или обязательного каталога сегментов. Файлы фасетов являются публичными точками входа, а не сегментами или самостоятельными модулями.
|
||||
|
||||
## Визуальный модуль
|
||||
|
||||
Визуальный модуль обычно имеет корневой компонент, который экспортируется через публичный API.
|
||||
|
||||
```text
|
||||
button/
|
||||
├── button.tsx
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
Корневой компонент остаётся компонентом, а владельцем ответственности является модуль `button`.
|
||||
@@ -1,30 +0,0 @@
|
||||
# Вложенные модули
|
||||
|
||||
> Пояснение нормативной модели вложенных модулей SLM.
|
||||
|
||||
Вложенный модуль является обычным модулем, размещённым внутри границы родительского модуля. Он имеет собственные ответственность, публичный API и границу зависимостей и подчиняется всем общим правилам модулей.
|
||||
|
||||
## Связанные правила
|
||||
|
||||
- [`SLM-MODULE-A004`](../rules/registry.md#slm-module-a004)
|
||||
- [`SLM-MODULE-R006`](../rules/registry.md#slm-module-r006)
|
||||
- [`SLM-DEPENDENCY-A005`](../rules/registry.md#slm-dependency-a005)
|
||||
- [`SLM-NESTED_MODULE-A010`](../rules/registry.md#slm-nested_module-a010)
|
||||
|
||||
## Пример
|
||||
|
||||
```text
|
||||
landing/
|
||||
├── landing.page.tsx
|
||||
├── parts/
|
||||
│ └── hero/
|
||||
│ ├── hero.tsx
|
||||
│ └── index.ts
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
`parts/` здесь является примером сегмента, а не обязательным именем.
|
||||
|
||||
Код родительского модуля использует вложенный модуль через его собственный публичный API. Код за пределами родительского модуля получает доступ только через публичный API родителя.
|
||||
|
||||
Если вложенный модуль становится нужен за пределами родителя, рекомендуется перенести его в минимальную общую область без изменения внутренней формы. Доступ через API родителя при этом остаётся допустимым и сам по себе не требует переноса.
|
||||
@@ -1,29 +0,0 @@
|
||||
# Сегменты SLM
|
||||
|
||||
> Пояснение нормативной модели сегментов SLM.
|
||||
|
||||
Сегмент организует внутреннее содержимое модуля. SLM определяет роль сегмента, но не задаёт обязательный список имён.
|
||||
|
||||
## Связанное правило
|
||||
|
||||
- [`SLM-SEGMENT-R008`](../rules/registry.md#slm-segment-r008)
|
||||
- [`SLM-MODULE-A004`](../rules/registry.md#slm-module-a004)
|
||||
|
||||
## Файловая форма
|
||||
|
||||
Названия, набор и содержимое сегментов определяет стайлгайд проекта. Сегмент может группировать файлы, компоненты или вложенные модули.
|
||||
|
||||
Файлы и компоненты сегмента принадлежат родительскому модулю. Вложенный модуль внутри сегмента сохраняет собственные ответственность, публичный API и границу зависимостей.
|
||||
|
||||
## Пример
|
||||
|
||||
```text
|
||||
landing/ # Модуль
|
||||
└── ui/ # Сегмент модуля
|
||||
└── hero/ # Каталог компонента
|
||||
├── hero.tsx
|
||||
├── styles/ # Вспомогательный каталог компонента
|
||||
└── types/ # Вспомогательный каталог компонента
|
||||
```
|
||||
|
||||
`styles/` и `types/` внутри каталога компонента не обязаны считаться сегментами SLM. Их форму определяет стайлгайд компонентов.
|
||||
@@ -1,153 +0,0 @@
|
||||
# Терминология SLM
|
||||
|
||||
> Нормативные определения рабочего черновика. Этот раздел не объявляет правила.
|
||||
|
||||
Определения задают обязательный смысл архитектурных терминов и используются при толковании всех правил. Код получают только блокирующие требования, а не сами определения.
|
||||
|
||||
## Базовые понятия
|
||||
|
||||
### SLM root
|
||||
|
||||
Граница структурной архитектуры одного приложения. Внутри неё определяются слои, модули и зависимости SLM. Монорепозиторий, пакеты и отношения между несколькими SLM root находятся за пределами текущего черновика.
|
||||
|
||||
### Ответственность
|
||||
|
||||
Связная часть приложения с одной причиной изменяться. Ответственность является самостоятельной, когда ей нужны собственные публичный API, зависимости, состояние или область жизни.
|
||||
|
||||
### Владелец
|
||||
|
||||
Модуль, который определяет публичный API ответственности, её зависимости, состояние, область жизни и внутреннее устройство. Место выполнения кода не переносит владение.
|
||||
|
||||
### Публичный API
|
||||
|
||||
Единая логическая граница внешнего доступа к модулю. Публичный API скрывает внутреннее устройство и состоит из обязательного корневого фасета `index` и только реально необходимых environment-фасетов `client`, `browser` и `server`.
|
||||
|
||||
### Фасет
|
||||
|
||||
Объявленная публичная точка входа модуля, которая открывает часть его единого логического API для определённой среды или способа выполнения. Импорт объявленного фасета не является deep import. Любой другой путь внутрь модуля остаётся внутренним.
|
||||
|
||||
Корневой фасет `index` является основным barrel модуля. Он экспортирует публичные типы и runtime-код, совместимый как с серверным рендерингом, включая React Server Components, так и с клиентским выполнением.
|
||||
|
||||
Необязательные environment-фасеты имеют следующий нормативный смысл:
|
||||
|
||||
| Фасет | Среда и способ выполнения |
|
||||
|---|---|
|
||||
| `client` | Клиентская framework-граница, которая может участвовать в server prerender и затем выполняться при hydration и в браузере |
|
||||
| `browser` | Browser-only код, подключаемый только динамически с отключённым SSR |
|
||||
| `server` | Server-only код, недоступный через универсальный, клиентский и браузерный фасеты |
|
||||
|
||||
Client Component, импортированный Server Component, не становится универсальным кодом и не экспортируется через `index`. Совместимость фасета со средой определяется всеми его runtime-импортами и реэкспортами, включая транзитивные.
|
||||
|
||||
### Зависимость
|
||||
|
||||
Статическая связь внутри одного SLM root, которую импорт или реэкспорт создаёт между архитектурными границами. Обычный импорт, импорт типа и реэкспорт одинаково создают архитектурную зависимость.
|
||||
|
||||
Зависимость любого внутреннего файла, сегмента или компонента относится к ближайшему модулю-владельцу. Вложенный модуль начинает собственную границу зависимостей.
|
||||
|
||||
### Область жизни
|
||||
|
||||
Период, в течение которого принадлежащие модулю состояние или долгоживущий ресурс должны оставаться активными.
|
||||
|
||||
### Ресурс жизненного цикла
|
||||
|
||||
Ресурс, работа которого продолжается после первоначального вызова и требует остановки, отмены, отписки или освобождения. Например, подписка, слушатель событий, таймер, наблюдатель, запрос или соединение.
|
||||
|
||||
### Очистка
|
||||
|
||||
Гарантированное прекращение работы ресурса не позже завершения его области жизни. Автоматическая очистка фреймворка считается очисткой владельца, если модуль устанавливает и контролирует соответствующую границу.
|
||||
|
||||
## Структурные сущности
|
||||
|
||||
### Нормативная матрица слоёв
|
||||
|
||||
Отношение допустимой зависимости между слоями одного SLM root. Матрица определяет, код каких слоёв может импортировать исходный слой; она не обязана образовывать линейный порядок.
|
||||
|
||||
Для SLM нормативно отношение `app → compositions → domains → infra → ui → shared`. `infra` может импортировать `ui`, а `ui` не импортирует `infra`. Промежуточный слой не является обязательным посредником.
|
||||
|
||||
### Слой
|
||||
|
||||
Одна из шести верхнеуровневых ролей внутри SLM root:
|
||||
|
||||
| Слой | Роль |
|
||||
|---|---|
|
||||
| `app` | Связь приложения с фреймворком: запуск, маршруты и преобразование входных данных |
|
||||
| `compositions` | Сборка продуктового интерфейса: страницы, макеты, экраны, виджеты и другие композиции |
|
||||
| `domains` | Предметные области, их модели, правила, сценарии и продуктовое состояние |
|
||||
| `infra` | Технические сервисы и возможности приложения |
|
||||
| `ui` | Универсальные модули интерфейса без зависимости от конкретной продуктовой композиции |
|
||||
| `shared` | Независимый детерминированный фундамент без знания о продукте, изменяемого состояния и ввода-вывода |
|
||||
|
||||
Полная матрица допустимых зависимостей:
|
||||
|
||||
| Исходный слой | Допустимые целевые слои |
|
||||
|---|---|
|
||||
| `app` | `app`, `compositions`, `domains`, `infra`, `ui`, `shared` |
|
||||
| `compositions` | `compositions`, `domains`, `infra`, `ui`, `shared` |
|
||||
| `domains` | `domains`, `infra`, `ui`, `shared` |
|
||||
| `infra` | `infra`, `ui`, `shared` |
|
||||
| `ui` | `ui`, `shared` |
|
||||
| `shared` | `shared` |
|
||||
|
||||
### Модуль
|
||||
|
||||
Минимальная самостоятельная архитектурная единица SLM. Модуль владеет одной связной ответственностью, размещается в отдельной папке и предоставляет публичный API.
|
||||
|
||||
### Предметная ответственность
|
||||
|
||||
Связная предметная область приложения, которая может включать собственные модели, правила, сценарии и продуктовое состояние. Количество экранов, endpoint-ов, hooks или файлов само по себе не определяет её границу.
|
||||
|
||||
### Группа
|
||||
|
||||
Навигационная папка для модулей и других групп. Группа не является владельцем ответственности, состояния, области жизни, публичного API или границы зависимостей.
|
||||
|
||||
### Сегмент
|
||||
|
||||
Внутренняя часть одного модуля, которая группирует его содержимое по назначению. Сегмент не является самостоятельным владельцем, публичным API или границей зависимостей.
|
||||
|
||||
### Компонент
|
||||
|
||||
Сущность фреймворка, которая реализует часть интерфейса родительского модуля. Компонент не образует собственного владельца, публичного API или границы зависимостей.
|
||||
|
||||
Импорты, состояние, доступ к данным и код жизненного цикла компонента принадлежат родительскому модулю. Их наличие само по себе не создаёт новый модуль; решающим признаком является самостоятельная ответственность.
|
||||
|
||||
### Вложенный модуль
|
||||
|
||||
Обычный модуль, размещённый внутри границы родительского модуля. Он имеет собственные ответственность, публичный API и границу зависимостей и подчиняется всем общим правилам модулей.
|
||||
|
||||
Публичный API вложенного модуля доступен коду родительской границы. Для кода за пределами родительского модуля вложенный модуль остаётся внутренней реализацией родителя.
|
||||
|
||||
### Точка входа фреймворка
|
||||
|
||||
Специальная немодульная единица слоя `app`, которая непосредственно связывает приложение с фреймворком. Её импорты участвуют в проверке направления слоёв, но сама точка входа не является модулем.
|
||||
|
||||
### Ресурс shared
|
||||
|
||||
Специальная немодульная единица слоя `shared`: небольшая детерминированная утилита, общий тип, стиль, конфигурация или статический ресурс без продуктового знания, изменяемого состояния, ввода-вывода, области жизни и собственного публичного API.
|
||||
|
||||
Ресурс `shared` может импортироваться напрямую по пути, установленному стайлгайдом, и не является модулем. Доступный по этому пути файл является всей единицей и не скрывает отдельное внутреннее устройство.
|
||||
|
||||
Если ресурсу нужны самостоятельная ответственность, собственные архитектурные зависимости, несколько файлов реализации, изменяемое состояние, ввод-вывод или область жизни, он оформляется как модуль.
|
||||
|
||||
## Структурная модель
|
||||
|
||||
```text
|
||||
SLM root
|
||||
├── app
|
||||
│ └── точка входа фреймворка
|
||||
├── compositions | domains | infra | ui
|
||||
│ ├── группа
|
||||
│ │ └── модуль
|
||||
│ └── модуль
|
||||
│ ├── корневые файлы
|
||||
│ ├── сегмент
|
||||
│ │ ├── файлы
|
||||
│ │ ├── компоненты
|
||||
│ │ └── вложенные модули
|
||||
│ └── вложенный модуль
|
||||
└── shared
|
||||
├── группа
|
||||
├── модуль
|
||||
└── ресурс shared
|
||||
```
|
||||
|
||||
Путь и имя папки сами по себе не определяют сущность. Её определяют ответственность, владелец и публичная граница. Физическое сопоставление путей с сущностями задаётся стайлгайдом или конфигурацией проверки проекта.
|
||||
@@ -1,51 +0,0 @@
|
||||
# Проверка SLM
|
||||
|
||||
> Граница автоматической проверки и архитектурного ревью SLM.
|
||||
|
||||
## Автоматическая проверка
|
||||
|
||||
Проект, заявляющий соответствие SLM, сопоставляет физические пути с SLM root, слоями, модулями, Groups, вложенными модулями, публичными фасетами, точками входа `app` и ресурсами `shared`. Такое сопоставление задаётся стайлгайдом или конфигурацией проверки и не изменяет нормативный смысл сущностей.
|
||||
|
||||
Каждое правило класса `A` должно быть реализовано проверкой проекта и блокировать её при нарушении. SLM не навязывает конкретный инструмент.
|
||||
|
||||
Скрипт `draft-rules.js` проверяет только целостность документов: формат и уникальность кодов, ссылки и наличие тематических упоминаний. Он не проверяет архитектуру приложения.
|
||||
|
||||
Актуальный список правил скрипт получает из [канонического реестра](../rules/registry.md).
|
||||
|
||||
## Архитектурное ревью
|
||||
|
||||
Правила класса `R` проверяются вручную. Статический анализ может обнаружить подозрительный код, но не способен окончательно определить:
|
||||
|
||||
- ответственность и её владельца;
|
||||
- связность ответственности модуля;
|
||||
- соответствие кода роли слоя;
|
||||
- необходимость экспортов публичного API;
|
||||
- область жизни ресурса и достаточность очистки;
|
||||
- наличие самостоятельной границы у компонента, группы или сегмента.
|
||||
|
||||
## Проверка фасетов
|
||||
|
||||
Автоматическая проверка сопоставляет публичные пути модуля с фасетами `index`, `client`, `browser` и `server`, запрещает остальные внешние пути и проверяет их runtime-импорты и реэкспорты, включая транзитивные.
|
||||
|
||||
Для проверки сред инструмент различает runtime imports, type-only imports и dynamic imports. Окончательное решение о соответствии экспортируемого кода назначению фасета принимается на ревью.
|
||||
|
||||
На ревью проверяется:
|
||||
|
||||
- экспортирует ли `index` только универсальные типы и runtime-код;
|
||||
- остаётся ли `client` совместимым с server prerender и browser hydration;
|
||||
- достигается ли `browser` только через dynamic boundary с отключённым SSR;
|
||||
- остаётся ли `server` недоступным через `index`, `client` и `browser`;
|
||||
- существует ли каждый специализированный фасет ради реального потребителя;
|
||||
- не дублируется ли один runtime-export между фасетами.
|
||||
|
||||
Название файла, директива `use client`, tree shaking или локальная проверка `typeof window` сами по себе не доказывают совместимость кода со средой выполнения.
|
||||
|
||||
## Проверка слоя domains
|
||||
|
||||
На ревью определяется:
|
||||
|
||||
- соответствует ли ответственность модуля предметной роли слоя `domains`;
|
||||
- не разделена ли одна область на соседние модули без самостоятельных владельцев;
|
||||
- не объединены ли в одном модуле несвязанные предметные области;
|
||||
- остаются ли страницы, маршруты и UI нескольких предметных ответственностей в `compositions`;
|
||||
- остаются ли самостоятельные технические сервисы без предметной модели в `infra`.
|
||||
@@ -1,33 +0,0 @@
|
||||
---
|
||||
layout: home
|
||||
title: SLM Design
|
||||
|
||||
hero:
|
||||
name: SLM Design
|
||||
text: Последовательная архитектура фронтенд-приложений
|
||||
tagline: Слои, модули и явные публичные границы для приложений с несколькими средами выполнения.
|
||||
image:
|
||||
src: /logo.svg
|
||||
alt: SLM Design
|
||||
actions:
|
||||
- theme: brand
|
||||
text: Читать архитектуру
|
||||
link: /architecture/
|
||||
- theme: alt
|
||||
text: Реестр правил
|
||||
link: /rules/
|
||||
|
||||
features:
|
||||
- title: Архитектурная база
|
||||
details: Шесть слоёв, модули, публичные API, зависимости, вложенные границы и жизненный цикл ресурсов.
|
||||
- title: Фасеты сред выполнения
|
||||
details: Универсальный index и специализированные client, browser и server фасеты для явного разделения сред выполнения.
|
||||
- title: Канонические правила
|
||||
details: Точные блокирующие требования отделены от определений, рекомендаций и примеров и собраны в едином реестре.
|
||||
---
|
||||
|
||||
## Что опубликовано
|
||||
|
||||
Сайт содержит рабочий черновик архитектуры SLM и канонический реестр правил. Монорепозитории пока не входят в опубликованную документацию.
|
||||
|
||||
Определения терминологии нормативны внутри черновика. Точные формулировки блокирующих требований находятся только в [реестре правил](/rules/registry).
|
||||
@@ -1,117 +0,0 @@
|
||||
# Правила SLM
|
||||
|
||||
> Статус: системный черновик. Не является нормативной спецификацией.
|
||||
|
||||
Эта директория является единственным местом объявления правил SLM. Остальные черновики объясняют архитектуру и ссылаются на канонические коды, но не повторяют формулировки правил.
|
||||
|
||||
## Что считается правилом
|
||||
|
||||
Правило задаёт один блокирующий архитектурный инвариант.
|
||||
|
||||
Определения, рекомендации, разрешения, примеры и открытые вопросы не получают код правила.
|
||||
|
||||
Нормативные определения объявляются в [терминологии SLM](../architecture/terminology.md). Они обязательны для толкования правил, но нормативность определения сама по себе не превращает его в правило.
|
||||
|
||||
## Код правила
|
||||
|
||||
```text
|
||||
SLM-{group}-{class}{number}
|
||||
```
|
||||
|
||||
| Часть | Значение |
|
||||
|---|---|
|
||||
| `SLM` | Принадлежность архитектуре SLM |
|
||||
| `group` | Раздел правил |
|
||||
| `class` | Способ проверки: `A` или `R` |
|
||||
| `number` | Глобально уникальный трёхзначный номер правила |
|
||||
|
||||
## Способы проверки
|
||||
|
||||
### `A`: автоматическая проверка
|
||||
|
||||
Всё правило можно однозначно проверить программно без понимания предметного смысла кода. Нарушение такого правила должно блокировать автоматическую проверку.
|
||||
|
||||
### `R`: проверка на ревью
|
||||
|
||||
Для окончательного решения требуется понимание ответственности, владения или смысла зависимости. Линтер может проверять отдельные признаки, но не заменяет решение на ревью.
|
||||
|
||||
Одно правило не разделяется на автоматическую и ручную копии только из-за разных способов проверки. Если существенная часть инварианта требует смыслового решения, всё правило получает класс `R`.
|
||||
|
||||
## Разделы правил
|
||||
|
||||
| Код | Раздел |
|
||||
|---|---|
|
||||
| `LAYER` | Слои |
|
||||
| `DEPENDENCY` | Зависимости |
|
||||
| `MODULE` | Модули |
|
||||
| `GROUP` | Группы |
|
||||
| `SEGMENT` | Сегменты |
|
||||
| `COMPONENT` | Компоненты |
|
||||
| `NESTED_MODULE` | Вложенные модули |
|
||||
| `LIFECYCLE` | Жизненный цикл |
|
||||
| `ENVIRONMENT` | Границы сред выполнения |
|
||||
|
||||
Код раздела записывается полным английским именем в `UPPER_SNAKE_CASE`. Новый код добавляется в таблицу до первого использования.
|
||||
|
||||
## Формат записи
|
||||
|
||||
```md
|
||||
### SLM-MODULE-A004
|
||||
|
||||
> **Публичный API модуля**
|
||||
>
|
||||
> Каждый модуль предоставляет единый логический публичный API через обязательный корневой фасет `index` и, при необходимости, фасеты `client`, `browser` и `server`; код за пределами модуля импортирует его содержимое только через эти фасеты.
|
||||
```
|
||||
|
||||
Код является заголовком третьего уровня и автоматически получает адрес для ссылки `#slm-module-a004`.
|
||||
|
||||
Название и описание входят в одну цитату. Название выделяется жирным и служит кратким именем правила. Описание полностью формулирует требование и занимает одну физическую строку.
|
||||
|
||||
Ссылка из тематического черновика:
|
||||
|
||||
```md
|
||||
[`SLM-MODULE-A004`](./registry.md#slm-module-a004)
|
||||
```
|
||||
|
||||
## Как формулировать правила
|
||||
|
||||
1. Правило понятно без чтения тематической главы и опирается только на нормативные термины SLM.
|
||||
2. Правило защищает один архитектурный инвариант.
|
||||
3. Один инвариант получает один код независимо от числа участников и способов проверки.
|
||||
4. Название является кратким и устойчивым именем правила.
|
||||
5. Название обозначает предмет правила, а описание полностью формулирует требование.
|
||||
6. Описание объясняет допустимую границу и то, что считается нарушением.
|
||||
7. Описание раскрывает названный инвариант и не вводит второе независимое требование.
|
||||
8. Описание использует нормативные определения и не пересказывает их без необходимости.
|
||||
9. Название и описание используют человеческий язык и только необходимые архитектурные термины.
|
||||
10. Обоснование, подробности, примеры и исключения размещаются в тематическом черновике, а не в описании.
|
||||
11. Правило не создаётся отдельно с позиции владельца и потребителя, если обе формулировки защищают одну границу.
|
||||
12. Перед добавлением правила реестр проверяется на дубли и противоречия.
|
||||
13. Код присваивается после проверки правила на примерах и контрпримерах.
|
||||
|
||||
## Нумерация
|
||||
|
||||
1. Номер глобально уникален независимо от раздела и способа проверки.
|
||||
2. Номер не обозначает важность или порядок выполнения.
|
||||
3. Удалённый номер не переиспользуется для другого правила.
|
||||
4. При изменении способа проверки номер сохраняется, но меняется полный код.
|
||||
|
||||
## Проверка качества
|
||||
|
||||
Перед принятием правила нужно ответить «да»:
|
||||
|
||||
- Понятно, о чём правило?
|
||||
- Название кратко и однозначно называет правило?
|
||||
- Понятно, что оно требует?
|
||||
- Понятно, что является нарушением?
|
||||
- Нельзя ли объединить его с существующим правилом?
|
||||
- Не содержит ли оно рекомендацию или разрешение?
|
||||
- Соответствует ли класс способу окончательной проверки?
|
||||
|
||||
## Проверка документов
|
||||
|
||||
Корневой скрипт `draft-rules.js` читает объявления только из этой директории, проверяет формат и уникальность кодов, валидирует ссылки из остальных черновиков и выводит правила разделами «Автоматические» и «Для ревью». Скрипт проверяет документы, а не архитектуру приложения.
|
||||
|
||||
## Реестр
|
||||
|
||||
- [Единый реестр правил SLM](./registry.md)
|
||||
@@ -1,129 +0,0 @@
|
||||
# Реестр правил SLM
|
||||
|
||||
Здесь собраны правила SLM. Это единственное место, где они формулируются; тематические черновики объясняют их и ссылаются на коды.
|
||||
|
||||
## Размещение кода по слоям
|
||||
|
||||
### SLM-LAYER-R001
|
||||
|
||||
> **Назначение слоёв**
|
||||
>
|
||||
> Код внутри SLM root размещается в слое, нормативная роль которого соответствует ответственности этого кода.
|
||||
|
||||
### SLM-LAYER-A002
|
||||
|
||||
> **Направление зависимостей**
|
||||
>
|
||||
> Внутри одного SLM root код каждого слоя может зависеть только от кода целевых слоёв, разрешённых для него нормативной матрицей слоёв.
|
||||
|
||||
### SLM-LAYER-R003
|
||||
|
||||
> **Граница слоя `app`**
|
||||
>
|
||||
> В `app` размещаются только точки входа фреймворка для запуска, маршрутов, преобразования входных данных и подключения публичных API модулей разрешённых слоёв или ресурсов `shared`; ответственности этих модулей остаются за пределами `app`.
|
||||
|
||||
## Границы модулей
|
||||
|
||||
### SLM-MODULE-A004
|
||||
|
||||
> **Публичный API модуля**
|
||||
>
|
||||
> Каждый модуль предоставляет единый логический публичный API через обязательный корневой фасет `index` и, при необходимости, фасеты `client`, `browser` и `server`; код за пределами модуля импортирует его содержимое только через эти фасеты.
|
||||
|
||||
### SLM-MODULE-A014
|
||||
|
||||
> **Папка модуля**
|
||||
>
|
||||
> Каждый модуль размещается в отдельной папке; его публичный API и внутренняя реализация находятся внутри этой границы, а вложенные модули образуют собственные папки.
|
||||
|
||||
### SLM-MODULE-R006
|
||||
|
||||
> **Ответственность модуля**
|
||||
>
|
||||
> Одна модульная граница содержит код одной связной ответственности; части, которые изменяются по несвязанным причинам, размещаются в разных модулях.
|
||||
|
||||
### SLM-MODULE-R011
|
||||
|
||||
> **Владелец ответственности**
|
||||
>
|
||||
> Каждая самостоятельная ответственность и относящийся к ней код принадлежат ровно одному модулю; вне модульной границы допускаются только точки входа `app` и нормативные ресурсы `shared`.
|
||||
|
||||
### SLM-MODULE-R012
|
||||
|
||||
> **Состав публичного API**
|
||||
>
|
||||
> Публичный API модуля открывает только контракт, необходимый реальным внешним потребителям; детали реализации и изменяемые внутренние механизмы остаются закрытыми.
|
||||
|
||||
## Зависимости между модулями
|
||||
|
||||
### SLM-DEPENDENCY-A005
|
||||
|
||||
> **Циклические зависимости**
|
||||
>
|
||||
> Зависимости между модулями внутри одного SLM root, включая вложенные модули, не образуют циклов.
|
||||
|
||||
## Назначение групп
|
||||
|
||||
### SLM-GROUP-R007
|
||||
|
||||
> **Назначение группы**
|
||||
>
|
||||
> Группа содержит только модули и другие группы, не владеет файлами реализации, состоянием, жизненным циклом или публичным API и не импортируется внешним кодом.
|
||||
|
||||
## Назначение сегментов
|
||||
|
||||
### SLM-SEGMENT-R008
|
||||
|
||||
> **Граница сегмента**
|
||||
>
|
||||
> Сегмент организует код только внутри одного модуля и не имеет собственной ответственности, публичного API или границы зависимостей.
|
||||
|
||||
## Ответственность компонентов
|
||||
|
||||
### SLM-COMPONENT-R009
|
||||
|
||||
> **Ответственность компонента**
|
||||
>
|
||||
> Компонент реализует часть ответственности одного родительского модуля; все его зависимости, состояние и жизненный цикл принадлежат этому модулю и не образуют самостоятельную архитектурную границу.
|
||||
|
||||
## Границы вложенных модулей
|
||||
|
||||
### SLM-NESTED_MODULE-A010
|
||||
|
||||
> **Доступ к вложенному модулю**
|
||||
>
|
||||
> Код за пределами родительского модуля не импортирует вложенный модуль напрямую и получает его экспорты только через публичный API родителя.
|
||||
|
||||
## Жизненный цикл
|
||||
|
||||
### SLM-LIFECYCLE-R013
|
||||
|
||||
> **Жизненный цикл ресурсов**
|
||||
>
|
||||
> Для каждого ресурса жизненного цикла модуль-владелец определяет создание, область жизни, число экземпляров и очистку; ресурс активен только внутри своей области жизни.
|
||||
|
||||
## Границы сред выполнения
|
||||
|
||||
### SLM-ENVIRONMENT-R016
|
||||
|
||||
> **Универсальный фасет**
|
||||
>
|
||||
> Корневой фасет `index` экспортирует только публичный код, совместимый как с серверным рендерингом, включая RSC, так и с клиентским выполнением, и не импортирует или реэкспортирует код фасетов `client`, `browser` или `server` прямо либо транзитивно.
|
||||
|
||||
### SLM-ENVIRONMENT-R017
|
||||
|
||||
> **Клиентский фасет**
|
||||
>
|
||||
> Фасет `client` экспортирует только клиентский код, который не может выполняться как RSC, и не импортирует или реэкспортирует код фасетов `browser` или `server` прямо либо транзитивно.
|
||||
|
||||
### SLM-ENVIRONMENT-R018
|
||||
|
||||
> **Браузерный фасет**
|
||||
>
|
||||
> Фасет `browser` экспортирует только browser-only код, а потребители импортируют его только динамически с отключённым SSR.
|
||||
|
||||
### SLM-ENVIRONMENT-R019
|
||||
|
||||
> **Серверный фасет**
|
||||
>
|
||||
> Фасет `server` экспортирует только server-only код и не импортируется или реэкспортируется фасетами `index`, `client` или `browser` прямо либо транзитивно.
|
||||
@@ -1 +0,0 @@
|
||||
NEXT_PUBLIC_SIMPLE_API_URL=http://localhost:3001
|
||||
42
examples/demo-frontend/.gitignore
vendored
42
examples/demo-frontend/.gitignore
vendored
@@ -1,42 +0,0 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -1,5 +0,0 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -1 +0,0 @@
|
||||
@AGENTS.md
|
||||
@@ -1,102 +0,0 @@
|
||||
# Demo frontend
|
||||
|
||||
Полноценное Next.js 16 приложение поверх меньшего из двух fixture API: `../demo-backend/openapi/simple.json`.
|
||||
|
||||
Пример показывает SLM Level 1 на каталоге: public data, JWT login/refresh/logout, persisted cart, checkout, protected orders, RBAC, admin CRUD, optimistic locking и детерминированные network outcomes.
|
||||
|
||||
## Запуск
|
||||
|
||||
Требуются Node.js 20+, npm 10+ и современный browser с Web Locks API. Auth и cart transitions работают fail-closed без cross-tab lock; `localhost` считается secure context для локальной разработки.
|
||||
|
||||
Сначала запустите Simple API:
|
||||
|
||||
```bash
|
||||
cd ../demo-backend
|
||||
npm install
|
||||
npm run dev:simple
|
||||
```
|
||||
|
||||
Затем запустите frontend:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Откройте `http://localhost:3000`. Backend по умолчанию доступен на `http://localhost:3001`.
|
||||
|
||||
Чтобы изменить URL backend, создайте `.env.local` рядом с `package.json`:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_SIMPLE_API_URL=http://localhost:3001
|
||||
```
|
||||
|
||||
## Demo accounts
|
||||
|
||||
| Email | Password | Role | Scenarios |
|
||||
|---|---|---|---|
|
||||
| `admin@demo.local` | `demo1234` | admin | Product CRUD, all orders |
|
||||
| `customer@demo.local` | `demo1234` | customer | Cart, checkout, own orders |
|
||||
|
||||
## Routes
|
||||
|
||||
| Route | Composition responsibility |
|
||||
|---|---|
|
||||
| `/` | Product search, category filter, sorting and pagination |
|
||||
| `/products/[productId]` | Product detail and add-to-cart |
|
||||
| `/cart` | Multi-domain checkout coordination |
|
||||
| `/orders` | Protected order history and cancellation |
|
||||
| `/sign-in` | JWT session lifecycle and account selection |
|
||||
| `/admin/products` | RBAC-gated CRUD and optimistic locking |
|
||||
|
||||
Плавающий `Demo controls` widget переключает `X-Demo-Scenario`: slow, timeout, 500, 429, empty, expired auth, forbidden и conflict. Отдельное действие `Seed 250` загружает настоящий большой seed с интерактивными product IDs; synthetic `large-dataset` header намеренно не используется для mutation UI.
|
||||
|
||||
## SLM boundary
|
||||
|
||||
`src` является SLM root:
|
||||
|
||||
```text
|
||||
src/
|
||||
├── app/ Next.js bootstrap and route entries
|
||||
├── compositions/ Layout, screens and demo widget
|
||||
├── domains/ Auth, catalog, cart, orders and demo-control
|
||||
├── infra/ Generated REST client, JWT storage and browser storage
|
||||
├── ui/ Product-agnostic button, form field and feedback panel
|
||||
└── shared/ Deterministic predicates, formatting and Result type
|
||||
```
|
||||
|
||||
Пример использует Level 1 осознанно: у каждого домена одна browser runtime integration. Дополнительные factories, adapters и assemblies Level 2 не окупили бы стоимость. Checkout остаётся в composition, поэтому `orders` не импортирует `cart`, а module graph остаётся ацикличным.
|
||||
|
||||
State и lifecycle распределены по владельцам:
|
||||
|
||||
- `auth` владеет пользовательской сессией; `infra/simple-auth-session` хранит technical JWT pair, стабильный `sessionId` и CAS revision.
|
||||
- `cart` владеет строками, totals и persisted snapshot validation; monotonic revision и conditional clear сериализованы между вкладками.
|
||||
- SWR cache принадлежит REST infra module, создаётся отдельным provider и remount-ится при смене logical auth session.
|
||||
- React providers создаются один раз в application scope и очищают свои subscriptions при unmount.
|
||||
- Route entries только адаптируют Next.js params и подключают public APIs compositions.
|
||||
- Reset, seed, role и request-scenario transitions синхронизируют cache, auth, cart и route-local pagination между вкладками.
|
||||
|
||||
## REST client
|
||||
|
||||
Split SDK генерируется из committed OpenAPI:
|
||||
|
||||
```bash
|
||||
npm run codegen:simple-rest-api
|
||||
```
|
||||
|
||||
Generated-код живёт только в `src/infra/simple-rest-api/generated` и не редактируется вручную. GET hooks вызывают точечные operations через `simpleHttpClient`; submit-сценарии используют полный `simpleRestApi`. Внешний код импортирует REST capability только через `@/infra/simple-rest-api`.
|
||||
|
||||
OpenAPI fixture описывает числовые `page` и `limit` как `object`. Исправление generated type изолировано в `types/to-generated-query.ts`; runtime query остаётся числовым.
|
||||
|
||||
JWT refresh выполняется в transport `onError`, ограничен одним retry и дедуплицирует конкурентные refresh requests одной revision. CAS не позволяет позднему refresh воскресить logout или перезаписать новый login. Хранение refresh token в `localStorage` допустимо только для этой архитектурной fixture; production-приложение должно выбрать threat model и более безопасную session strategy.
|
||||
|
||||
Checkout привязан к captured auth session и persisted cart revision. Backend атомарно проверяет product version, unit price, USD currency, aggregate stock и уникальность product lines. Timeout-сценарий откладывает mutation handler и отменяет его при disconnect, поэтому frontend timeout не скрывает завершившийся POST.
|
||||
|
||||
## Проверка
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
npm run build
|
||||
```
|
||||
|
||||
`npm run check` запускает architecture constraints, ESLint, TypeScript и Vitest. Architecture script строит import graph через TypeScript AST и явный module manifest, затем проверяет направление слоёв, public module imports, side-effect/dynamic imports, запрет утечки generated SDK и циклы.
|
||||
@@ -1,37 +0,0 @@
|
||||
import js from '@eslint/js'
|
||||
import nextPlugin from '@next/eslint-plugin-next'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import globals from 'globals'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores([
|
||||
'.next/**',
|
||||
'out/**',
|
||||
'build/**',
|
||||
'coverage/**',
|
||||
'next-env.d.ts',
|
||||
'src/infra/simple-rest-api/generated/**'
|
||||
]),
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ['**/*.{js,mjs,mts,ts,tsx}'],
|
||||
plugins: {
|
||||
'@next/next': nextPlugin,
|
||||
'react-hooks': reactHooks
|
||||
},
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
...nextPlugin.configs.recommended.rules,
|
||||
...nextPlugin.configs['core-web-vitals'].rules,
|
||||
...reactHooks.configs.flat.recommended.rules
|
||||
}
|
||||
}
|
||||
])
|
||||
@@ -1,24 +0,0 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
turbopack: {
|
||||
root: fileURLToPath(new URL('.', import.meta.url))
|
||||
},
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'picsum.photos',
|
||||
pathname: '/**'
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'i.pravatar.cc',
|
||||
pathname: '/**'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
4280
examples/demo-frontend/package-lock.json
generated
4280
examples/demo-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"name": "demo-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"check:architecture": "node scripts/check-architecture.mjs",
|
||||
"check": "npm run check:architecture && npm run lint && npm run typecheck && npm run test",
|
||||
"codegen:simple-rest-api": "npx @gromlab/api-codegen@5.1.1 -i ../demo-backend/openapi/simple.json -o src/infra/simple-rest-api/generated"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "2.1.1",
|
||||
"next": "16.2.12",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"swr": "2.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "10.0.1",
|
||||
"@next/eslint-plugin-next": "16.2.12",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "10.8.0",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"globals": "17.8.0",
|
||||
"typescript": "^5",
|
||||
"typescript-eslint": "8.65.0",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"overrides": {
|
||||
"postcss": "8.5.25",
|
||||
"sharp": "0.35.3"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480" role="img" aria-labelledby="title description">
|
||||
<title id="title">Product image unavailable</title>
|
||||
<desc id="description">Neutral geometric placeholder for a missing product image.</desc>
|
||||
<rect width="640" height="480" fill="#ebe9e2"/>
|
||||
<path d="M0 390 180 210l105 105 78-78 277 243H0Z" fill="#b7d72c"/>
|
||||
<circle cx="470" cy="130" r="58" fill="#3057ff"/>
|
||||
<path d="M72 74h214v24H72zm0 48h142v16H72z" fill="#171813"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 496 B |
@@ -1,360 +0,0 @@
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
||||
import { dirname, extname, join, relative, resolve, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import ts from 'typescript'
|
||||
|
||||
const SCRIPT_DIRECTORY = dirname(fileURLToPath(import.meta.url))
|
||||
const PROJECT_ROOT = resolve(SCRIPT_DIRECTORY, '..')
|
||||
const SOURCE_ROOT = join(PROJECT_ROOT, 'src')
|
||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx'])
|
||||
const MODULE_ROOTS = [
|
||||
'compositions/layouts/store-shell',
|
||||
'compositions/screens/cart',
|
||||
'compositions/screens/catalog',
|
||||
'compositions/screens/orders',
|
||||
'compositions/screens/product-admin',
|
||||
'compositions/screens/product-detail',
|
||||
'compositions/screens/sign-in',
|
||||
'compositions/widgets/demo-toolbar',
|
||||
'domains/auth',
|
||||
'domains/cart',
|
||||
'domains/catalog',
|
||||
'domains/demo-control',
|
||||
'domains/orders',
|
||||
'infra/browser-storage',
|
||||
'infra/simple-auth-session',
|
||||
'infra/simple-rest-api',
|
||||
'shared/lib/value-predicates',
|
||||
'ui/button',
|
||||
'ui/feedback-panel',
|
||||
'ui/form-field'
|
||||
].sort((left, right) => right.length - left.length)
|
||||
const MODULAR_LAYERS = new Set(['compositions', 'domains', 'infra', 'ui'])
|
||||
const ALLOWED_TARGETS = {
|
||||
app: new Set(['app', 'compositions', 'domains', 'infra', 'ui', 'shared']),
|
||||
compositions: new Set(['compositions', 'domains', 'infra', 'ui', 'shared']),
|
||||
domains: new Set(['domains', 'infra', 'ui', 'shared']),
|
||||
infra: new Set(['infra', 'shared']),
|
||||
ui: new Set(['ui', 'shared']),
|
||||
shared: new Set(['shared'])
|
||||
}
|
||||
|
||||
/**
|
||||
* Рекурсивно собирает TypeScript-файлы SLM root.
|
||||
*/
|
||||
const collectSourceFiles = (directory) => {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const entryPath = join(directory, entry.name)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
return collectSourceFiles(entryPath)
|
||||
}
|
||||
|
||||
return SOURCE_EXTENSIONS.has(extname(entry.name)) ? [entryPath] : []
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Нормализует filesystem path к формату architecture manifest.
|
||||
*/
|
||||
const normalizePath = (filePath) => {
|
||||
return filePath.split(sep).join('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает путь файла относительно SLM root.
|
||||
*/
|
||||
const getSourcePath = (filePath) => {
|
||||
return normalizePath(relative(SOURCE_ROOT, filePath))
|
||||
}
|
||||
|
||||
/**
|
||||
* Определяет слой относительного source path.
|
||||
*/
|
||||
const getLayer = (sourcePath) => {
|
||||
return sourcePath.split('/')[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Находит явно объявленного module-owner по longest-prefix rule.
|
||||
*/
|
||||
const getModuleId = (sourcePath) => {
|
||||
return MODULE_ROOTS.find(
|
||||
(moduleRoot) => sourcePath === moduleRoot || sourcePath.startsWith(`${moduleRoot}/`)
|
||||
) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет deep import относительно явно объявленного public entry.
|
||||
*/
|
||||
const isDeepModuleImport = (targetPath, sourceModuleId) => {
|
||||
const targetModuleId = getModuleId(targetPath)
|
||||
|
||||
return targetModuleId !== null &&
|
||||
targetModuleId !== sourceModuleId &&
|
||||
targetPath !== targetModuleId
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет направление слоя независимо от синтаксиса import path.
|
||||
*/
|
||||
const isLayerImportAllowed = (sourcePath, targetPath) => {
|
||||
const sourceLayer = getLayer(sourcePath)
|
||||
const targetLayer = getLayer(targetPath)
|
||||
|
||||
return ALLOWED_TARGETS[sourceLayer]?.has(targetLayer) === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает static, side-effect, reexport и dynamic module specifiers через TypeScript AST.
|
||||
*/
|
||||
const getModuleSpecifiers = (filePath, sourceCode) => {
|
||||
const scriptKind = extname(filePath) === '.tsx' ? ts.ScriptKind.TSX : ts.ScriptKind.TS
|
||||
const sourceFile = ts.createSourceFile(
|
||||
filePath,
|
||||
sourceCode,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
scriptKind
|
||||
)
|
||||
const specifiers = []
|
||||
|
||||
/**
|
||||
* Обходит один AST node и собирает строковые module references.
|
||||
*/
|
||||
const visit = (node) => {
|
||||
if (
|
||||
(ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
|
||||
node.moduleSpecifier &&
|
||||
ts.isStringLiteral(node.moduleSpecifier)
|
||||
) {
|
||||
specifiers.push(node.moduleSpecifier.text)
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isImportEqualsDeclaration(node) &&
|
||||
ts.isExternalModuleReference(node.moduleReference) &&
|
||||
node.moduleReference.expression &&
|
||||
ts.isStringLiteral(node.moduleReference.expression)
|
||||
) {
|
||||
specifiers.push(node.moduleReference.expression.text)
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isCallExpression(node) &&
|
||||
node.arguments.length === 1 &&
|
||||
ts.isStringLiteral(node.arguments[0]) &&
|
||||
(node.expression.kind === ts.SyntaxKind.ImportKeyword ||
|
||||
(ts.isIdentifier(node.expression) && node.expression.text === 'require'))
|
||||
) {
|
||||
specifiers.push(node.arguments[0].text)
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isImportTypeNode(node) &&
|
||||
ts.isLiteralTypeNode(node.argument) &&
|
||||
ts.isStringLiteral(node.argument.literal)
|
||||
) {
|
||||
specifiers.push(node.argument.literal.text)
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
visit(sourceFile)
|
||||
|
||||
return specifiers
|
||||
}
|
||||
|
||||
/**
|
||||
* Разрешает относительный TypeScript import.
|
||||
*/
|
||||
const resolveRelativeImport = (sourceFile, specifier) => {
|
||||
const basePath = resolve(dirname(sourceFile), specifier)
|
||||
const candidates = [
|
||||
basePath,
|
||||
`${basePath}.ts`,
|
||||
`${basePath}.tsx`,
|
||||
join(basePath, 'index.ts'),
|
||||
join(basePath, 'index.tsx')
|
||||
]
|
||||
|
||||
return candidates.find((candidate) => existsSync(candidate)) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Находит первый cycle в module graph.
|
||||
*/
|
||||
const findCycle = (graph) => {
|
||||
const visited = new Set()
|
||||
const active = new Set()
|
||||
const path = []
|
||||
|
||||
/**
|
||||
* Обходит один module node depth-first.
|
||||
*/
|
||||
const visit = (moduleId) => {
|
||||
if (active.has(moduleId)) {
|
||||
const cycleStart = path.indexOf(moduleId)
|
||||
|
||||
return [...path.slice(cycleStart), moduleId]
|
||||
}
|
||||
|
||||
if (visited.has(moduleId)) {
|
||||
return null
|
||||
}
|
||||
|
||||
visited.add(moduleId)
|
||||
active.add(moduleId)
|
||||
path.push(moduleId)
|
||||
|
||||
for (const dependency of graph.get(moduleId) ?? []) {
|
||||
const cycle = visit(dependency)
|
||||
|
||||
if (cycle !== null) {
|
||||
return cycle
|
||||
}
|
||||
}
|
||||
|
||||
path.pop()
|
||||
active.delete(moduleId)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
for (const moduleId of graph.keys()) {
|
||||
const cycle = visit(moduleId)
|
||||
|
||||
if (cycle !== null) {
|
||||
return cycle
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const violations = []
|
||||
const graph = new Map()
|
||||
const sourceFiles = collectSourceFiles(SOURCE_ROOT)
|
||||
const parserProbe = getModuleSpecifiers(
|
||||
'architecture-probe.ts',
|
||||
`
|
||||
import '@/infra/simple-rest-api'
|
||||
export { useAuth } from '@/domains/auth'
|
||||
type Auth = import('@/domains/auth').AuthContextValue
|
||||
const lazy = import('@/compositions/screens/catalog')
|
||||
`
|
||||
)
|
||||
const expectedProbeSpecifiers = [
|
||||
'@/infra/simple-rest-api',
|
||||
'@/domains/auth',
|
||||
'@/domains/auth',
|
||||
'@/compositions/screens/catalog'
|
||||
]
|
||||
|
||||
expectedProbeSpecifiers.forEach((specifier) => {
|
||||
if (!parserProbe.includes(specifier)) {
|
||||
violations.push(`architecture parser fixture missed ${specifier}`)
|
||||
}
|
||||
})
|
||||
|
||||
if (!isDeepModuleImport('shared/lib/value-predicates/value-predicates', 'domains/cart')) {
|
||||
violations.push('architecture boundary fixture did not detect shared module deep import')
|
||||
}
|
||||
|
||||
if (isLayerImportAllowed('shared/bridge.ts', 'domains/auth/index.ts')) {
|
||||
violations.push('architecture boundary fixture allowed shared to import domains relatively')
|
||||
}
|
||||
|
||||
MODULE_ROOTS.forEach((moduleRoot) => {
|
||||
const moduleDirectory = join(SOURCE_ROOT, moduleRoot)
|
||||
const publicEntry = join(moduleDirectory, 'index.ts')
|
||||
|
||||
if (!existsSync(moduleDirectory) || !existsSync(publicEntry)) {
|
||||
violations.push(`${moduleRoot}: declared module must be a folder with index.ts public API`)
|
||||
}
|
||||
})
|
||||
|
||||
sourceFiles.forEach((sourceFile) => {
|
||||
const sourcePath = getSourcePath(sourceFile)
|
||||
const sourceLayer = getLayer(sourcePath)
|
||||
const sourceModuleId = getModuleId(sourcePath)
|
||||
const relativeSource = relative(PROJECT_ROOT, sourceFile)
|
||||
const sourceCode = readFileSync(sourceFile, 'utf8')
|
||||
|
||||
if (MODULAR_LAYERS.has(sourceLayer) && sourceModuleId === null) {
|
||||
violations.push(`${relativeSource}: implementation file has no declared module-owner`)
|
||||
}
|
||||
|
||||
getModuleSpecifiers(sourceFile, sourceCode).forEach((specifier) => {
|
||||
if (specifier.startsWith('@/')) {
|
||||
const targetPath = specifier.slice(2)
|
||||
const targetLayer = getLayer(targetPath)
|
||||
const targetModuleId = getModuleId(targetPath)
|
||||
|
||||
if (!isLayerImportAllowed(sourcePath, targetPath)) {
|
||||
violations.push(`${relativeSource}: ${sourceLayer} cannot import ${targetLayer} via ${specifier}`)
|
||||
}
|
||||
|
||||
if (isDeepModuleImport(targetPath, sourceModuleId)) {
|
||||
violations.push(`${relativeSource}: deep import into ${targetModuleId} via ${specifier}`)
|
||||
}
|
||||
|
||||
if (sourceModuleId !== null && targetModuleId !== null && sourceModuleId !== targetModuleId) {
|
||||
const dependencies = graph.get(sourceModuleId) ?? new Set()
|
||||
dependencies.add(targetModuleId)
|
||||
graph.set(sourceModuleId, dependencies)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (specifier.startsWith('.')) {
|
||||
const targetFile = resolveRelativeImport(sourceFile, specifier)
|
||||
|
||||
if (targetFile === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetModuleId = getModuleId(getSourcePath(targetFile))
|
||||
const targetPath = getSourcePath(targetFile)
|
||||
const targetLayer = getLayer(targetPath)
|
||||
|
||||
if (!isLayerImportAllowed(sourcePath, targetPath)) {
|
||||
violations.push(
|
||||
`${relativeSource}: ${sourceLayer} cannot import ${targetLayer} via ${specifier}`
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
sourceModuleId !== null &&
|
||||
targetModuleId !== null &&
|
||||
sourceModuleId !== targetModuleId
|
||||
) {
|
||||
violations.push(`${relativeSource}: relative import crosses into ${targetModuleId}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const isRestModuleFile = sourceModuleId === 'infra/simple-rest-api'
|
||||
|
||||
if (sourceCode.includes('/generated') && !isRestModuleFile) {
|
||||
violations.push(`${relativeSource}: generated SDK import escaped infra/simple-rest-api`)
|
||||
}
|
||||
})
|
||||
|
||||
const cycle = findCycle(graph)
|
||||
|
||||
if (cycle !== null) {
|
||||
violations.push(`module dependency cycle: ${cycle.join(' -> ')}`)
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error('SLM architecture check failed:')
|
||||
violations.forEach((violation) => console.error(`- ${violation}`))
|
||||
process.exitCode = 1
|
||||
} else {
|
||||
console.log(
|
||||
`SLM architecture check passed for ${sourceFiles.length} files and ${MODULE_ROOTS.length} declared modules.`
|
||||
)
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import { ProductAdminScreen } from '@/compositions/screens/product-admin'
|
||||
|
||||
/**
|
||||
* Metadata catalog administration route.
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
title: 'Product admin'
|
||||
}
|
||||
|
||||
/**
|
||||
* Подключает admin composition к framework route.
|
||||
*/
|
||||
export default function ProductAdminPage() {
|
||||
return <ProductAdminScreen />
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import { CartScreen } from '@/compositions/screens/cart'
|
||||
|
||||
/**
|
||||
* Metadata cart route.
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
title: 'Cart'
|
||||
}
|
||||
|
||||
/**
|
||||
* Подключает multi-domain checkout composition к cart route.
|
||||
*/
|
||||
export default function CartPage() {
|
||||
return <CartScreen />
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { StoreShellLayout } from '@/compositions/layouts/store-shell'
|
||||
|
||||
import type { AppLayoutProps } from '../types/app-layout-props.type'
|
||||
|
||||
/**
|
||||
* Подключает общий storefront layout ко всем leaf routes группы.
|
||||
*/
|
||||
export default function StoreLayout(props: AppLayoutProps) {
|
||||
const { children } = props
|
||||
|
||||
return <StoreShellLayout>{children}</StoreShellLayout>
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import { OrdersScreen } from '@/compositions/screens/orders'
|
||||
|
||||
/**
|
||||
* Metadata protected orders route.
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
title: 'Orders'
|
||||
}
|
||||
|
||||
/**
|
||||
* Подключает protected orders composition к framework route.
|
||||
*/
|
||||
export default function OrdersPage() {
|
||||
return <OrdersScreen />
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { CatalogScreen } from '@/compositions/screens/catalog'
|
||||
|
||||
/**
|
||||
* Подключает catalog composition к корневому route.
|
||||
*/
|
||||
export default function CatalogPage() {
|
||||
return <CatalogScreen />
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import { ProductDetailScreen } from '@/compositions/screens/product-detail'
|
||||
|
||||
import type { ProductPageProps } from '../../../types/product-page-props.type'
|
||||
|
||||
/**
|
||||
* Metadata dynamic product route без запроса к runtime fixture во время build.
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
title: 'Product detail'
|
||||
}
|
||||
|
||||
/**
|
||||
* Адаптирует async dynamic segment к product detail composition.
|
||||
*/
|
||||
export default async function ProductPage(props: ProductPageProps) {
|
||||
const { productId } = await props.params
|
||||
|
||||
return <ProductDetailScreen productId={productId} />
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import { SignInScreen } from '@/compositions/screens/sign-in'
|
||||
|
||||
/**
|
||||
* Metadata sign-in route.
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
title: 'Sign in'
|
||||
}
|
||||
|
||||
/**
|
||||
* Подключает auth composition к framework route.
|
||||
*/
|
||||
export default function SignInPage() {
|
||||
return <SignInScreen />
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
@@ -1,85 +0,0 @@
|
||||
:root {
|
||||
--color-paper: #f3efe4;
|
||||
--color-surface: #faf7ef;
|
||||
--color-surface-strong: #e6e0d4;
|
||||
--color-ink: #191918;
|
||||
--color-ink-muted: #65615a;
|
||||
--color-ink-faint: #67635c;
|
||||
--color-line: #d7d0c3;
|
||||
--color-line-strong: #aaa297;
|
||||
--color-blue: #2847f4;
|
||||
--color-acid: #c9ff50;
|
||||
--color-acid-dark: #6f9c00;
|
||||
--color-orange: #ff8a3d;
|
||||
--color-error: #b83228;
|
||||
--color-focus: #8dacff;
|
||||
--font-body: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--font-display: 'Arial Black', 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
|
||||
--shadow-card: 0 10px 40px rgb(37 31 22 / 7%);
|
||||
--shadow-card-hover: 0 20px 55px rgb(37 31 22 / 14%);
|
||||
}
|
||||
|
||||
html {
|
||||
color: var(--color-ink);
|
||||
background: var(--color-paper);
|
||||
scroll-behavior: smooth;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--color-ink);
|
||||
background:
|
||||
radial-gradient(circle at 15% 0%, rgb(255 255 255 / 58%), transparent 25%),
|
||||
var(--color-paper);
|
||||
font-family: var(--font-body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
::selection {
|
||||
color: var(--color-ink);
|
||||
background: var(--color-acid);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import { AppProviders } from './providers'
|
||||
import type { AppLayoutProps } from './types/app-layout-props.type'
|
||||
import './globals.css'
|
||||
|
||||
/**
|
||||
* Статические metadata архитектурного demo-приложения.
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'Layer Supply',
|
||||
template: '%s / Layer Supply'
|
||||
},
|
||||
description: 'A complete Next.js storefront demonstrating Scoped Layered Module Design.'
|
||||
}
|
||||
|
||||
/**
|
||||
* Корневая Next.js entry point с application-scoped providers.
|
||||
*/
|
||||
export default function RootLayout(props: AppLayoutProps) {
|
||||
const { children } = props
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<AppProviders>{children}</AppProviders>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { AuthProvider, useAuth } from '@/domains/auth'
|
||||
import { CartProvider } from '@/domains/cart'
|
||||
import { SimpleRestApiProvider } from '@/infra/simple-rest-api'
|
||||
|
||||
import type { AppLayoutProps } from './types/app-layout-props.type'
|
||||
|
||||
/**
|
||||
* Создаёт новый REST cache при каждой смене logical auth session.
|
||||
*
|
||||
* Используется для:
|
||||
* - исключения DTO предыдущего пользователя из нового session scope
|
||||
*/
|
||||
const AuthScopedRestApiProvider = (props: AppLayoutProps) => {
|
||||
const { children } = props
|
||||
const auth = useAuth()
|
||||
const cacheScopeKey = auth.status === 'checking'
|
||||
? `checking:${auth.sessionKey ?? 'anonymous'}`
|
||||
: auth.sessionKey ?? auth.status
|
||||
|
||||
return (
|
||||
<SimpleRestApiProvider
|
||||
key={cacheScopeKey}
|
||||
isPaused={auth.status === 'checking'}
|
||||
>
|
||||
{children}
|
||||
</SimpleRestApiProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Подключает application-scoped domain state и auth-scoped REST cache.
|
||||
*
|
||||
* Используется для:
|
||||
* - сохранения auth/cart state между route transitions
|
||||
* - пересоздания technical cache на границе пользовательской сессии
|
||||
*/
|
||||
export const AppProviders = (props: AppLayoutProps) => {
|
||||
const { children } = props
|
||||
|
||||
return (
|
||||
<AuthProvider>
|
||||
<CartProvider>
|
||||
<AuthScopedRestApiProvider>{children}</AuthScopedRestApiProvider>
|
||||
</CartProvider>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Props framework layouts, передающих готовое route subtree.
|
||||
*/
|
||||
export type AppLayoutProps = {
|
||||
/** Вложенное route subtree. */
|
||||
children: ReactNode
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
/**
|
||||
* Props dynamic product route в Next.js 16.
|
||||
*/
|
||||
export type ProductPageProps = {
|
||||
/** Асинхронные параметры dynamic segment. */
|
||||
params: Promise<{
|
||||
/** Идентификатор продукта из URL. */
|
||||
productId: string
|
||||
}>
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { StoreShellLayout } from './store-shell.layout'
|
||||
export type { StoreShellLayoutProps } from './types/store-shell-layout-props.type'
|
||||
@@ -1,134 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import cl from 'clsx'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
import { useAuth } from '@/domains/auth'
|
||||
import { useCart } from '@/domains/cart'
|
||||
import { DemoToolbarWidget } from '@/compositions/widgets/demo-toolbar'
|
||||
import { Button } from '@/ui/button'
|
||||
|
||||
import type { StoreShellLayoutProps } from './types/store-shell-layout-props.type'
|
||||
import styles from './styles/store-shell.module.css'
|
||||
|
||||
/**
|
||||
* Общий каркас навигации и application-scoped controls магазина.
|
||||
*
|
||||
* Используется для:
|
||||
* - сохранения auth/cart контекста между App Router страницами
|
||||
* - доступа к deterministic demo controls
|
||||
*/
|
||||
export const StoreShellLayout = (props: StoreShellLayoutProps) => {
|
||||
const { children, className, ...rootAttrs } = props
|
||||
const pathname = usePathname()
|
||||
const auth = useAuth()
|
||||
const cart = useCart()
|
||||
const isAdmin = auth.status === 'authenticated' && auth.user?.role === 'admin'
|
||||
const hasSession = auth.sessionKey !== null
|
||||
const accountLabel = auth.user?.name ?? 'Sign in'
|
||||
|
||||
/**
|
||||
* Определяет активный верхнеуровневый navigation item.
|
||||
*/
|
||||
const isActivePath = (path: string): boolean => {
|
||||
return path === '/' ? pathname === '/' : pathname.startsWith(path)
|
||||
}
|
||||
|
||||
const isCatalogActive = isActivePath('/')
|
||||
const isCartActive = isActivePath('/cart')
|
||||
const isOrdersActive = isActivePath('/orders')
|
||||
const isAdminActive = isActivePath('/admin')
|
||||
|
||||
/**
|
||||
* Завершает текущую сессию из общего header.
|
||||
*/
|
||||
const handleSignOut = (): void => {
|
||||
void auth.signOut()
|
||||
}
|
||||
|
||||
/**
|
||||
* Пытается восстановить unreadable cart явным empty snapshot.
|
||||
*/
|
||||
const handleCartReset = (): void => {
|
||||
void cart.clearCart()
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...rootAttrs} className={cl(styles.root, className)}>
|
||||
<header className={styles.header}>
|
||||
<Link className={styles.brand} href="/" aria-label="Layer Supply home">
|
||||
<span className={styles.brandMark}>LS</span>
|
||||
<span className={styles.brandText}>
|
||||
Layer
|
||||
<br />
|
||||
Supply
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className={styles.nav} aria-label="Primary navigation">
|
||||
<Link
|
||||
className={cl(styles.navLink, isCatalogActive && styles.active)}
|
||||
href="/"
|
||||
aria-current={isCatalogActive ? 'page' : undefined}
|
||||
>
|
||||
Catalog
|
||||
</Link>
|
||||
<Link
|
||||
className={cl(styles.navLink, isCartActive && styles.active)}
|
||||
href="/cart"
|
||||
aria-current={isCartActive ? 'page' : undefined}
|
||||
>
|
||||
Cart <span className={styles.count}>{cart.itemCount}</span>
|
||||
</Link>
|
||||
<Link
|
||||
className={cl(styles.navLink, isOrdersActive && styles.active)}
|
||||
href="/orders"
|
||||
aria-current={isOrdersActive ? 'page' : undefined}
|
||||
>
|
||||
Orders
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<Link
|
||||
className={cl(styles.navLink, isAdminActive && styles.active)}
|
||||
href="/admin/products"
|
||||
aria-current={isAdminActive ? 'page' : undefined}
|
||||
>
|
||||
Admin
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className={styles.account}>
|
||||
<span className={styles.accountMeta}>{auth.status}</span>
|
||||
<Link className={styles.accountLink} href="/sign-in">
|
||||
{accountLabel}
|
||||
</Link>
|
||||
{hasSession && (
|
||||
<Button variant="ghost" size="small" onClick={handleSignOut}>
|
||||
Exit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{cart.error && (
|
||||
<div className={styles.cartError} role="alert">
|
||||
<span>{cart.error.message}</span>
|
||||
<Button variant="ghost" size="small" onClick={handleCartReset}>
|
||||
Reset cart
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className={styles.page}>{children}</div>
|
||||
|
||||
<footer className={styles.footer}>
|
||||
<span>Next.js 16 + SLM Level 1</span>
|
||||
<span>Simple API / localhost:3001</span>
|
||||
</footer>
|
||||
|
||||
<DemoToolbarWidget />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
.root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: clamp(18px, 4vw, 56px);
|
||||
min-height: 82px;
|
||||
padding: 12px clamp(18px, 5vw, 72px);
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
background: color-mix(in srgb, var(--color-paper) 88%, transparent);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.brandMark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
color: var(--color-paper);
|
||||
border-radius: 50%;
|
||||
background: var(--color-blue);
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.brandText {
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 820;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 0.82;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: clamp(14px, 3vw, 34px);
|
||||
}
|
||||
|
||||
.navLink {
|
||||
position: relative;
|
||||
padding: 10px 0;
|
||||
color: var(--color-ink-muted);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.navLink::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 3px;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
border-radius: 3px;
|
||||
background: var(--color-orange);
|
||||
transform: scaleX(0);
|
||||
transition: transform 160ms ease;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.navLink:hover,
|
||||
.active {
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.active::after {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.count {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
margin-left: 3px;
|
||||
padding: 0 5px;
|
||||
color: var(--color-ink);
|
||||
border-radius: 999px;
|
||||
background: var(--color-acid);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.account {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
align-items: center;
|
||||
justify-items: end;
|
||||
gap: 2px 10px;
|
||||
}
|
||||
|
||||
.accountMeta {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--color-ink-faint);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.accountLink {
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 720;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cartError {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
color: var(--color-error);
|
||||
border-left: 3px solid var(--color-error);
|
||||
background: color-mix(in srgb, var(--color-error) 8%, transparent);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.page {
|
||||
min-height: calc(100vh - 160px);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 28px clamp(18px, 5vw, 72px) 90px;
|
||||
color: var(--color-ink-faint);
|
||||
border-top: 1px solid var(--color-line);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@media (max-width: 850px) {
|
||||
.header {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.nav {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 540px) {
|
||||
.header {
|
||||
padding-inline: 14px;
|
||||
}
|
||||
|
||||
.brandText,
|
||||
.accountMeta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
flex-direction: column;
|
||||
padding-inline: 18px;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
/**
|
||||
* Параметры общего storefront layout.
|
||||
*/
|
||||
export type StoreShellLayoutParams = object
|
||||
|
||||
/**
|
||||
* Атрибуты корневого контейнера.
|
||||
*/
|
||||
type RootAttrs = ComponentPropsWithoutRef<'div'>
|
||||
|
||||
/**
|
||||
* Props общего storefront layout.
|
||||
*/
|
||||
export type StoreShellLayoutProps = RootAttrs & StoreShellLayoutParams
|
||||
@@ -1,374 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ChangeEvent, FocusEvent } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import cl from 'clsx'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import { useAuth } from '@/domains/auth'
|
||||
import { getCartProductQuantityLimit, useCart } from '@/domains/cart'
|
||||
import { PRODUCT_IMAGE_PLACEHOLDER, useCatalogCommands } from '@/domains/catalog'
|
||||
import { useOrderCommands, validateOrderDraft } from '@/domains/orders'
|
||||
import type { OrderDraftLine } from '@/domains/orders'
|
||||
import { formatMoney } from '@/shared/lib/format-money'
|
||||
import { isEmptyArray, isNonEmptyArray } from '@/shared/lib/value-predicates'
|
||||
import { Button } from '@/ui/button'
|
||||
import { FeedbackPanel } from '@/ui/feedback-panel'
|
||||
|
||||
import type { CartScreenProps } from './types/cart-screen-props.type'
|
||||
import styles from './styles/cart.module.css'
|
||||
|
||||
/**
|
||||
* Multi-domain cart and checkout composition.
|
||||
*
|
||||
* Используется для:
|
||||
* - координации cart, auth и orders без междоменного цикла
|
||||
* - отображения stock и checkout errors
|
||||
*/
|
||||
export const CartScreen = (props: CartScreenProps) => {
|
||||
const { className, ...rootAttrs } = props
|
||||
const cart = useCart()
|
||||
const auth = useAuth()
|
||||
const catalogCommands = useCatalogCommands()
|
||||
const orderCommands = useOrderCommands()
|
||||
const router = useRouter()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [checkoutError, setCheckoutError] = useState<string | null>(null)
|
||||
const [quantityDraftsById, setQuantityDraftsById] = useState<Record<string, string>>({})
|
||||
const isMountedRef = useRef(false)
|
||||
const checkoutAttemptRef = useRef(0)
|
||||
const hasLines = isNonEmptyArray(cart.lines)
|
||||
const orderDraft: OrderDraftLine[] = cart.lines.map((line) => ({
|
||||
productId: line.product.id,
|
||||
quantity: line.quantity,
|
||||
availableStock: line.product.stock,
|
||||
currency: line.product.currency,
|
||||
expectedVersion: line.product.version,
|
||||
expectedUnitPriceCents: line.product.priceCents
|
||||
}))
|
||||
const draftError = validateOrderDraft(orderDraft)
|
||||
const canCheckout = draftError === null
|
||||
const totalLabel = cart.currency === null
|
||||
? 'Mixed currencies'
|
||||
: formatMoney(cart.subtotalCents, cart.currency)
|
||||
const checkoutLabel = auth.status === 'authenticated'
|
||||
? 'Place order'
|
||||
: auth.status === 'unavailable'
|
||||
? 'Retry session first'
|
||||
: 'Sign in to checkout'
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
checkoutAttemptRef.current += 1
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Сохраняет локальный quantity draft до commit на blur.
|
||||
*/
|
||||
const handleQuantityChange = (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
productId: string
|
||||
): void => {
|
||||
const value = event.currentTarget.value
|
||||
|
||||
setQuantityDraftsById((currentDrafts) => ({
|
||||
...currentDrafts,
|
||||
[productId]: value
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Применяет завершённое редактирование quantity без удаления на пустом draft.
|
||||
*/
|
||||
const handleQuantityCommit = (
|
||||
event: FocusEvent<HTMLInputElement>,
|
||||
productId: string
|
||||
): void => {
|
||||
const value = event.currentTarget.value.trim()
|
||||
|
||||
if (value === '') {
|
||||
setQuantityDraftsById((currentDrafts) => {
|
||||
const nextDrafts = { ...currentDrafts }
|
||||
|
||||
delete nextDrafts[productId]
|
||||
|
||||
return nextDrafts
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const quantity = Number(value)
|
||||
|
||||
if (Number.isFinite(quantity)) {
|
||||
void cart.setQuantity(productId, quantity)
|
||||
}
|
||||
|
||||
setQuantityDraftsById((currentDrafts) => {
|
||||
const nextDrafts = { ...currentDrafts }
|
||||
|
||||
delete nextDrafts[productId]
|
||||
|
||||
return nextDrafts
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет lifecycle текущей checkout-попытки после async boundary.
|
||||
*/
|
||||
const isCheckoutAttemptActive = (attemptId: number): boolean => {
|
||||
return isMountedRef.current && checkoutAttemptRef.current === attemptId
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт order из cart snapshot или направляет гостя к auth route.
|
||||
*/
|
||||
const handleCheckout = async (): Promise<void> => {
|
||||
if (auth.status === 'unavailable') {
|
||||
const result = await auth.refreshCurrentUser()
|
||||
|
||||
if (!result.isSuccess) {
|
||||
setCheckoutError(result.error.message)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (auth.status !== 'authenticated') {
|
||||
router.push('/sign-in')
|
||||
return
|
||||
}
|
||||
|
||||
const sessionKey = auth.sessionKey
|
||||
|
||||
if (sessionKey === null || isSubmitting) {
|
||||
return
|
||||
}
|
||||
|
||||
const cartRevision = cart.revision
|
||||
const attemptId = checkoutAttemptRef.current + 1
|
||||
|
||||
checkoutAttemptRef.current = attemptId
|
||||
|
||||
setIsSubmitting(true)
|
||||
setCheckoutError(null)
|
||||
|
||||
const productsResult = await catalogCommands.loadProducts(
|
||||
orderDraft.map((line) => line.productId)
|
||||
)
|
||||
|
||||
if (!isCheckoutAttemptActive(attemptId)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!productsResult.isSuccess) {
|
||||
setIsSubmitting(false)
|
||||
setCheckoutError(productsResult.error.message)
|
||||
return
|
||||
}
|
||||
|
||||
if (!auth.isCurrentSession(sessionKey)) {
|
||||
setIsSubmitting(false)
|
||||
setCheckoutError('The active session changed. Start checkout again.')
|
||||
return
|
||||
}
|
||||
|
||||
const reconciliationStatus = await cart.reconcileProducts(
|
||||
productsResult.data,
|
||||
cartRevision
|
||||
)
|
||||
|
||||
if (!isCheckoutAttemptActive(attemptId)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (reconciliationStatus === 'stale') {
|
||||
setIsSubmitting(false)
|
||||
setCheckoutError('The cart changed during checkout. Review it and try again.')
|
||||
return
|
||||
}
|
||||
|
||||
if (reconciliationStatus === 'unavailable') {
|
||||
setIsSubmitting(false)
|
||||
setCheckoutError('Cart storage is unavailable. Checkout was not started.')
|
||||
return
|
||||
}
|
||||
|
||||
if (reconciliationStatus === 'updated') {
|
||||
setIsSubmitting(false)
|
||||
setCheckoutError('Inventory changed. Review the refreshed totals before checkout.')
|
||||
return
|
||||
}
|
||||
|
||||
const cartRevisionStatus = await cart.isCurrentRevision(cartRevision)
|
||||
|
||||
if (!isCheckoutAttemptActive(attemptId)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (cartRevisionStatus === 'unavailable') {
|
||||
setIsSubmitting(false)
|
||||
setCheckoutError('Cart storage is unavailable. Checkout was not started.')
|
||||
return
|
||||
}
|
||||
|
||||
if (cartRevisionStatus === 'stale' || !auth.isCurrentSession(sessionKey)) {
|
||||
setIsSubmitting(false)
|
||||
setCheckoutError('The checkout scope changed. Review the cart and try again.')
|
||||
return
|
||||
}
|
||||
|
||||
const result = await orderCommands.createOrder(orderDraft, sessionKey)
|
||||
|
||||
if (result.isSuccess) {
|
||||
const clearStatus = await cart.clearCart(cartRevision)
|
||||
|
||||
if (!isCheckoutAttemptActive(attemptId)) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(false)
|
||||
|
||||
if (clearStatus === 'unavailable') {
|
||||
setCheckoutError('The order was created, but cart storage could not be updated.')
|
||||
return
|
||||
}
|
||||
|
||||
if (clearStatus === 'stale') {
|
||||
setCheckoutError('The order was created, but the cart changed in another tab.')
|
||||
return
|
||||
}
|
||||
|
||||
if (auth.isCurrentSession(sessionKey)) {
|
||||
router.push('/orders')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!isCheckoutAttemptActive(attemptId)) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(false)
|
||||
setCheckoutError(result.error.message)
|
||||
}
|
||||
|
||||
return (
|
||||
<main {...rootAttrs} className={cl(styles.root, className)}>
|
||||
<header className={styles.header}>
|
||||
<div>
|
||||
<p className={styles.kicker}>Cart / composition-owned checkout</p>
|
||||
<h1>Selected objects</h1>
|
||||
</div>
|
||||
<span className={styles.count}>{cart.itemCount} units</span>
|
||||
</header>
|
||||
|
||||
{isEmptyArray(cart.lines) && cart.isHydrated && (
|
||||
<FeedbackPanel
|
||||
variant="empty"
|
||||
title="Nothing crosses this boundary yet"
|
||||
description="Add an object from the catalog. The cart snapshot stays owned by its domain and persists locally."
|
||||
>
|
||||
<Link className={styles.catalogLink} href="/">
|
||||
Open catalog
|
||||
</Link>
|
||||
</FeedbackPanel>
|
||||
)}
|
||||
|
||||
{hasLines && (
|
||||
<div className={styles.layout}>
|
||||
<section className={styles.lines} aria-label="Cart products">
|
||||
{cart.lines.map((line) => (
|
||||
<article key={line.product.id} className={styles.line}>
|
||||
<Link
|
||||
className={styles.imageWrap}
|
||||
href={`/products/${line.product.id}`}
|
||||
aria-label={`Open ${line.product.name}`}
|
||||
>
|
||||
<Image
|
||||
className={styles.image}
|
||||
src={line.product.imageUrl}
|
||||
alt=""
|
||||
fill
|
||||
sizes="150px"
|
||||
onError={(event) => {
|
||||
event.currentTarget.srcset = ''
|
||||
event.currentTarget.src = PRODUCT_IMAGE_PLACEHOLDER
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
<div className={styles.lineInfo}>
|
||||
<span>{line.product.categoryId.replace('category-', '')}</span>
|
||||
<Link href={`/products/${line.product.id}`}>
|
||||
<h2>{line.product.name}</h2>
|
||||
</Link>
|
||||
<strong>{formatMoney(line.product.priceCents, line.product.currency)}</strong>
|
||||
</div>
|
||||
<label className={styles.quantity}>
|
||||
<span>Quantity</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={getCartProductQuantityLimit(line.product)}
|
||||
step={1}
|
||||
value={quantityDraftsById[line.product.id] ?? String(line.quantity)}
|
||||
disabled={isSubmitting}
|
||||
onChange={(event) => handleQuantityChange(event, line.product.id)}
|
||||
onBlur={(event) => handleQuantityCommit(event, line.product.id)}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="small"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => void cart.removeProduct(line.product.id)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<aside className={styles.summary}>
|
||||
<p className={styles.sequence}>03 / CHECKOUT</p>
|
||||
<h2>Order summary</h2>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Units</dt>
|
||||
<dd>{cart.itemCount}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Subtotal</dt>
|
||||
<dd>{totalLabel}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Session</dt>
|
||||
<dd>{auth.status}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{draftError && (
|
||||
<p className={styles.warning} role="status">
|
||||
{draftError.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{checkoutError && <p className={styles.error} role="alert">{checkoutError}</p>}
|
||||
|
||||
<Button
|
||||
isLoading={isSubmitting}
|
||||
disabled={!canCheckout || isSubmitting}
|
||||
onClick={handleCheckout}
|
||||
>
|
||||
{checkoutLabel}
|
||||
</Button>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { CartScreen } from './cart.screen'
|
||||
export type { CartScreenProps } from './types/cart-screen-props.type'
|
||||
@@ -1,200 +0,0 @@
|
||||
.root {
|
||||
display: grid;
|
||||
gap: 34px;
|
||||
padding: clamp(42px, 7vw, 100px) clamp(18px, 6vw, 90px) 100px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding-bottom: 28px;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.kicker,
|
||||
.sequence,
|
||||
.lineInfo > span,
|
||||
.quantity span {
|
||||
color: var(--color-blue);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin-top: 13px;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(3rem, 8vw, 7.5rem);
|
||||
letter-spacing: -0.055em;
|
||||
line-height: 0.82;
|
||||
}
|
||||
|
||||
.count {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--color-line-strong);
|
||||
border-radius: 999px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(300px, 0.38fr);
|
||||
gap: clamp(30px, 6vw, 90px);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.lines {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.line {
|
||||
display: grid;
|
||||
grid-template-columns: 132px minmax(0, 1fr) 100px auto;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
padding: 18px 0;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.imageWrap {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 15px;
|
||||
background: var(--color-surface-strong);
|
||||
}
|
||||
|
||||
.image {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.lineInfo {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.lineInfo h2 {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.4rem, 3vw, 2.25rem);
|
||||
line-height: 0.95;
|
||||
}
|
||||
|
||||
.lineInfo strong {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.quantity {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.quantity input {
|
||||
width: 80px;
|
||||
min-height: 42px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--color-line-strong);
|
||||
border-radius: 10px;
|
||||
background: var(--color-surface);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.summary {
|
||||
position: sticky;
|
||||
top: 110px;
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
padding: clamp(24px, 4vw, 42px);
|
||||
border-radius: 24px;
|
||||
background: var(--color-acid);
|
||||
}
|
||||
|
||||
.sequence {
|
||||
color: color-mix(in srgb, var(--color-ink) 58%, transparent);
|
||||
}
|
||||
|
||||
.summary h2 {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(2rem, 4vw, 3.6rem);
|
||||
line-height: 0.9;
|
||||
}
|
||||
|
||||
.summary dl {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.summary dl div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 13px 0;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-ink) 18%, transparent);
|
||||
}
|
||||
|
||||
.summary dt {
|
||||
color: color-mix(in srgb, var(--color-ink) 60%, transparent);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.summary dd {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.warning,
|
||||
.error {
|
||||
padding: 11px 13px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: rgb(255 255 255 / 38%);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--color-error);
|
||||
background: rgb(255 255 255 / 55%);
|
||||
}
|
||||
|
||||
.catalogLink {
|
||||
padding: 14px 20px;
|
||||
color: var(--color-paper);
|
||||
border-radius: 999px;
|
||||
background: var(--color-ink);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.summary {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.line {
|
||||
grid-template-columns: 92px 1fr;
|
||||
}
|
||||
|
||||
.quantity,
|
||||
.line > button {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.quantity input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
/**
|
||||
* Параметры cart and checkout screen.
|
||||
*/
|
||||
export type CartScreenParams = object
|
||||
|
||||
/**
|
||||
* Атрибуты корневого main без внешнего содержимого.
|
||||
*/
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'main'>, 'children'>
|
||||
|
||||
/**
|
||||
* Props cart and checkout screen.
|
||||
*/
|
||||
export type CartScreenProps = RootAttrs & CartScreenParams
|
||||
@@ -1,217 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { useDeferredValue, useState } from 'react'
|
||||
import cl from 'clsx'
|
||||
|
||||
import { useCategories, useProductCatalog } from '@/domains/catalog'
|
||||
import type { CatalogFilters } from '@/domains/catalog'
|
||||
import { useOnDemoFixtureChange } from '@/domains/demo-control'
|
||||
import { isEmptyArray, isNonEmptyArray, isOneOf } from '@/shared/lib/value-predicates'
|
||||
import { Button } from '@/ui/button'
|
||||
import { FeedbackPanel } from '@/ui/feedback-panel'
|
||||
|
||||
import { ProductCard } from './ui/product-card'
|
||||
import type { CatalogScreenProps } from './types/catalog-screen-props.type'
|
||||
import styles from './styles/catalog.module.css'
|
||||
|
||||
const CATALOG_SORTS = ['newest', 'price-asc', 'price-desc', 'name'] as const
|
||||
const SKELETON_IDS = ['one', 'two', 'three', 'four', 'five', 'six'] as const
|
||||
|
||||
/**
|
||||
* Публичная витрина с фильтрами и offset pagination.
|
||||
*
|
||||
* Используется для:
|
||||
* - поиска и сравнения fixture-продуктов
|
||||
* - демонстрации loading, empty и transport error outcomes
|
||||
*/
|
||||
export const CatalogScreen = (props: CatalogScreenProps) => {
|
||||
const { className, ...rootAttrs } = props
|
||||
const [filters, setFilters] = useState<CatalogFilters>({
|
||||
page: 1,
|
||||
limit: 12,
|
||||
search: '',
|
||||
categoryId: '',
|
||||
sort: 'newest'
|
||||
})
|
||||
const deferredSearch = useDeferredValue(filters.search)
|
||||
const catalog = useProductCatalog({ ...filters, search: deferredSearch })
|
||||
const categoriesState = useCategories()
|
||||
const hasProducts = isNonEmptyArray(catalog.products)
|
||||
const shouldShowEmpty =
|
||||
!catalog.isLoading && catalog.error === null && isEmptyArray(catalog.products)
|
||||
const resultSummary = catalog.pagination
|
||||
? `${catalog.pagination.total} objects / page ${catalog.pagination.page}`
|
||||
: 'Connecting to inventory'
|
||||
const canGoBack = (catalog.pagination?.page ?? 1) > 1
|
||||
const canGoForward =
|
||||
catalog.pagination !== null && catalog.pagination.page < catalog.pagination.totalPages
|
||||
|
||||
useOnDemoFixtureChange((kind) => {
|
||||
if (kind === 'data') {
|
||||
setFilters((current) => ({ ...current, page: 1 }))
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Обновляет search-фильтр, сохраняя ввод отзывчивым через deferred value.
|
||||
*/
|
||||
const handleSearchChange = (event: ChangeEvent<HTMLInputElement>): void => {
|
||||
setFilters((current) => ({ ...current, search: event.target.value, page: 1 }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Применяет category-фильтр и возвращает выдачу на первую страницу.
|
||||
*/
|
||||
const handleCategoryChange = (event: ChangeEvent<HTMLSelectElement>): void => {
|
||||
setFilters((current) => ({ ...current, categoryId: event.target.value, page: 1 }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Применяет только поддерживаемую доменом сортировку.
|
||||
*/
|
||||
const handleSortChange = (event: ChangeEvent<HTMLSelectElement>): void => {
|
||||
const value = event.target.value
|
||||
|
||||
if (isOneOf(value, CATALOG_SORTS)) {
|
||||
setFilters((current) => ({ ...current, sort: value, page: 1 }))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает предыдущую страницу выдачи.
|
||||
*/
|
||||
const handlePreviousPage = (): void => {
|
||||
setFilters((current) => ({ ...current, page: Math.max(1, current.page - 1) }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает следующую страницу выдачи.
|
||||
*/
|
||||
const handleNextPage = (): void => {
|
||||
setFilters((current) => ({ ...current, page: current.page + 1 }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Повторяет неуспешный catalog query.
|
||||
*/
|
||||
const handleReload = (): void => {
|
||||
void catalog.reload()
|
||||
}
|
||||
|
||||
return (
|
||||
<main {...rootAttrs} className={cl(styles.root, className)}>
|
||||
<section className={styles.hero} aria-labelledby="catalog-title">
|
||||
<p className={styles.kicker}>Deterministic objects for frontend systems</p>
|
||||
<h1 className={styles.title} id="catalog-title">
|
||||
Useful things,
|
||||
<br />
|
||||
visible boundaries.
|
||||
</h1>
|
||||
<p className={styles.lede}>
|
||||
A storefront where every loading state, cache edge, auth transition and conflict can be
|
||||
reproduced on demand.
|
||||
</p>
|
||||
<span className={styles.sequence}>01 / CATALOG</span>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className={styles.catalog}
|
||||
aria-label="Product catalog"
|
||||
aria-busy={catalog.isLoading || catalog.isRefreshing}
|
||||
>
|
||||
<div className={styles.filters}>
|
||||
<label className={styles.searchField}>
|
||||
<span>Search inventory</span>
|
||||
<input
|
||||
type="search"
|
||||
value={filters.search}
|
||||
placeholder="Keyboard, desk, systems..."
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className={styles.selectField}>
|
||||
<span>Category</span>
|
||||
<select value={filters.categoryId} onChange={handleCategoryChange}>
|
||||
<option value="">All categories</option>
|
||||
{categoriesState.categories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name} ({category.productCount})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className={styles.selectField}>
|
||||
<span>Order</span>
|
||||
<select value={filters.sort} onChange={handleSortChange}>
|
||||
<option value="newest">Newest first</option>
|
||||
<option value="price-asc">Price, low to high</option>
|
||||
<option value="price-desc">Price, high to low</option>
|
||||
<option value="name">Name</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className={styles.resultBar}>
|
||||
<span>{resultSummary}</span>
|
||||
{catalog.isRefreshing && <span className={styles.refreshing}>Refreshing</span>}
|
||||
</div>
|
||||
|
||||
{catalog.isLoading && (
|
||||
<div
|
||||
className={styles.productGrid}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label="Loading products"
|
||||
>
|
||||
{SKELETON_IDS.map((id) => (
|
||||
<div key={id} className={styles.skeleton} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{catalog.error && (
|
||||
<FeedbackPanel
|
||||
variant="error"
|
||||
title="The catalog boundary held"
|
||||
description={catalog.error.message}
|
||||
>
|
||||
<Button onClick={handleReload}>Retry request</Button>
|
||||
</FeedbackPanel>
|
||||
)}
|
||||
|
||||
{shouldShowEmpty && (
|
||||
<FeedbackPanel
|
||||
variant="empty"
|
||||
title="A valid empty state"
|
||||
description="No objects match this request. Try another filter or select the normal demo scenario."
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasProducts && (
|
||||
<div className={styles.productGrid}>
|
||||
{catalog.products.map((product, index) => (
|
||||
<ProductCard key={product.id} product={product} index={index} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{catalog.pagination && (catalog.pagination.totalPages > 1 || filters.page > 1) && (
|
||||
<div className={styles.pagination}>
|
||||
<Button variant="ghost" disabled={!canGoBack} onClick={handlePreviousPage}>
|
||||
Previous
|
||||
</Button>
|
||||
<span>
|
||||
{catalog.pagination.page} / {catalog.pagination.totalPages}
|
||||
</span>
|
||||
<Button variant="ghost" disabled={!canGoForward} onClick={handleNextPage}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { CatalogScreen } from './catalog.screen'
|
||||
export type { CatalogScreenProps } from './types/catalog-screen-props.type'
|
||||
@@ -1,212 +0,0 @@
|
||||
.root {
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(250px, 0.34fr);
|
||||
gap: 20px 60px;
|
||||
min-height: 520px;
|
||||
padding: clamp(72px, 11vw, 150px) clamp(20px, 7vw, 110px) 80px;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.hero::before {
|
||||
position: absolute;
|
||||
top: 18%;
|
||||
right: -8vw;
|
||||
width: clamp(230px, 34vw, 520px);
|
||||
aspect-ratio: 1;
|
||||
border: 1px solid var(--color-line-strong);
|
||||
border-radius: 50%;
|
||||
box-shadow:
|
||||
inset 0 0 0 38px var(--color-paper),
|
||||
inset 0 0 0 39px var(--color-line);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.hero::after {
|
||||
position: absolute;
|
||||
right: 15%;
|
||||
bottom: -140px;
|
||||
width: 240px;
|
||||
height: 300px;
|
||||
background: var(--color-acid);
|
||||
transform: rotate(24deg);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.kicker,
|
||||
.sequence {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--color-blue);
|
||||
}
|
||||
|
||||
.title {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 10ch;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(3.3rem, 9vw, 8.8rem);
|
||||
letter-spacing: -0.065em;
|
||||
line-height: 0.78;
|
||||
}
|
||||
|
||||
.lede {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
align-self: end;
|
||||
max-width: 33ch;
|
||||
color: var(--color-ink-muted);
|
||||
font-size: clamp(1rem, 1.6vw, 1.3rem);
|
||||
line-height: 1.58;
|
||||
}
|
||||
|
||||
.sequence {
|
||||
position: absolute;
|
||||
bottom: 30px;
|
||||
left: clamp(20px, 7vw, 110px);
|
||||
color: var(--color-ink-faint);
|
||||
}
|
||||
|
||||
.catalog {
|
||||
display: grid;
|
||||
gap: 26px;
|
||||
padding: 54px clamp(18px, 5vw, 72px) 0;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 1fr) repeat(2, minmax(180px, 0.35fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.searchField,
|
||||
.selectField {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filters label > span {
|
||||
color: var(--color-ink-faint);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.filters :is(input, select) {
|
||||
width: 100%;
|
||||
min-height: 50px;
|
||||
padding: 0 15px;
|
||||
color: var(--color-ink);
|
||||
border: 1px solid var(--color-line-strong);
|
||||
border-radius: 13px;
|
||||
outline: none;
|
||||
background: var(--color-surface);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.filters :is(input, select):focus {
|
||||
border-color: var(--color-blue);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-blue) 14%, transparent);
|
||||
}
|
||||
|
||||
.resultBar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
min-height: 24px;
|
||||
color: var(--color-ink-faint);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.refreshing {
|
||||
color: var(--color-blue);
|
||||
}
|
||||
|
||||
.productGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: clamp(14px, 2vw, 26px);
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
min-height: 410px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 22px;
|
||||
background:
|
||||
linear-gradient(105deg, transparent 30%, rgb(255 255 255 / 48%) 45%, transparent 60%)
|
||||
0 0 / 220% 100%,
|
||||
var(--color-surface-strong);
|
||||
animation: sweep 1.4s linear infinite;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
padding-top: 18px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
@keyframes sweep {
|
||||
to {
|
||||
background-position: -220% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.lede {
|
||||
max-width: 50ch;
|
||||
}
|
||||
|
||||
.filters,
|
||||
.productGrid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.searchField {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hero {
|
||||
min-height: 440px;
|
||||
padding-top: 70px;
|
||||
}
|
||||
|
||||
.hero::after {
|
||||
right: -40px;
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
.filters,
|
||||
.productGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.searchField {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
/**
|
||||
* Параметры storefront catalog screen.
|
||||
*/
|
||||
export type CatalogScreenParams = object
|
||||
|
||||
/**
|
||||
* Атрибуты корневого main без внешнего содержимого.
|
||||
*/
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'main'>, 'children'>
|
||||
|
||||
/**
|
||||
* Props storefront catalog screen.
|
||||
*/
|
||||
export type CatalogScreenProps = RootAttrs & CatalogScreenParams
|
||||
@@ -1,2 +0,0 @@
|
||||
export { ProductCard } from './product-card'
|
||||
export type { ProductCardProps } from './types/product-card-props.type'
|
||||
@@ -1,74 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import cl from 'clsx'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { useCart } from '@/domains/cart'
|
||||
import { PRODUCT_IMAGE_PLACEHOLDER } from '@/domains/catalog'
|
||||
import { formatMoney } from '@/shared/lib/format-money'
|
||||
import { Button } from '@/ui/button'
|
||||
|
||||
import type { ProductCardProps } from './types/product-card-props.type'
|
||||
import styles from './styles/product-card.module.css'
|
||||
|
||||
/**
|
||||
* Карточка продукта для grid-выдачи catalog screen.
|
||||
*
|
||||
* Используется для:
|
||||
* - перехода к detail route
|
||||
* - добавления доступного продукта в application cart
|
||||
*/
|
||||
export const ProductCard = (props: ProductCardProps) => {
|
||||
const { product, index, className, ...rootAttrs } = props
|
||||
const cart = useCart()
|
||||
const isSoldOut = product.stock === 0
|
||||
const stockLabel = isSoldOut ? 'Out of stock' : `${product.stock} ready`
|
||||
|
||||
/**
|
||||
* Добавляет продукт в cart domain без transport-зависимости компонента.
|
||||
*/
|
||||
const handleAdd = (): void => {
|
||||
void cart.addProduct(product)
|
||||
}
|
||||
|
||||
return (
|
||||
<article {...rootAttrs} className={cl(styles.root, className)}>
|
||||
<Link
|
||||
className={styles.imageLink}
|
||||
href={`/products/${product.id}`}
|
||||
aria-label={`Open ${product.name}`}
|
||||
>
|
||||
<Image
|
||||
className={styles.image}
|
||||
src={product.imageUrl}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 720px) 100vw, (max-width: 1100px) 50vw, 33vw"
|
||||
onError={(event) => {
|
||||
event.currentTarget.srcset = ''
|
||||
event.currentTarget.src = PRODUCT_IMAGE_PLACEHOLDER
|
||||
}}
|
||||
/>
|
||||
<span className={styles.index}>{String(index + 1).padStart(2, '0')}</span>
|
||||
</Link>
|
||||
|
||||
<div className={styles.body}>
|
||||
<div className={styles.meta}>
|
||||
<span>{stockLabel}</span>
|
||||
<span>{product.rating.toFixed(1)} / 5</span>
|
||||
</div>
|
||||
<Link href={`/products/${product.id}`}>
|
||||
<h2 className={styles.name}>{product.name}</h2>
|
||||
</Link>
|
||||
<p className={styles.description}>{product.description}</p>
|
||||
<div className={styles.footer}>
|
||||
<strong className={styles.price}>{formatMoney(product.priceCents, product.currency)}</strong>
|
||||
<Button size="small" disabled={isSoldOut} onClick={handleAdd}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
.root {
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 22px;
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-card);
|
||||
transition:
|
||||
transform 200ms ease,
|
||||
box-shadow 200ms ease;
|
||||
}
|
||||
|
||||
.root:hover {
|
||||
box-shadow: var(--shadow-card-hover);
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.imageLink {
|
||||
position: relative;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 4 / 3;
|
||||
background: var(--color-surface-strong);
|
||||
}
|
||||
|
||||
.image {
|
||||
object-fit: cover;
|
||||
filter: saturate(0.78) contrast(1.04);
|
||||
transition: transform 420ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
.root:hover .image {
|
||||
transform: scale(1.035);
|
||||
}
|
||||
|
||||
.index {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
color: var(--color-paper);
|
||||
border-radius: 50%;
|
||||
background: var(--color-blue);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr auto;
|
||||
gap: 13px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.meta,
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--color-ink-faint);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.64rem;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.35rem, 2.5vw, 2rem);
|
||||
line-height: 0.98;
|
||||
}
|
||||
|
||||
.description {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
color: var(--color-ink-muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.price {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
import type { Product } from '@/domains/catalog'
|
||||
|
||||
/**
|
||||
* Собственные параметры product card внутри catalog screen.
|
||||
*/
|
||||
export type ProductCardParams = {
|
||||
/** Catalog-модель продукта. */
|
||||
product: Product
|
||||
/** Позиция карточки для editorial numbering. */
|
||||
index: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Атрибуты корневой article-карточки без внешнего содержимого.
|
||||
*/
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'article'>, 'children'>
|
||||
|
||||
/**
|
||||
* Props product card внутри catalog screen.
|
||||
*/
|
||||
export type ProductCardProps = RootAttrs & ProductCardParams
|
||||
@@ -1,2 +0,0 @@
|
||||
export { OrdersScreen } from './orders.screen'
|
||||
export type { OrdersScreenProps } from './types/orders-screen-props.type'
|
||||
@@ -1,212 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import cl from 'clsx'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { useAuth } from '@/domains/auth'
|
||||
import { canCancelOrder, useOrderCommands, useOrders } from '@/domains/orders'
|
||||
import type { OrderError } from '@/domains/orders'
|
||||
import { formatDate } from '@/shared/lib/format-date'
|
||||
import { formatMoney } from '@/shared/lib/format-money'
|
||||
import { isEmptyArray, isNonEmptyArray } from '@/shared/lib/value-predicates'
|
||||
import { Button } from '@/ui/button'
|
||||
import { FeedbackPanel } from '@/ui/feedback-panel'
|
||||
|
||||
import type { OrdersScreenProps } from './types/orders-screen-props.type'
|
||||
import styles from './styles/orders.module.css'
|
||||
|
||||
/**
|
||||
* Protected история заказов с допустимыми state transitions.
|
||||
*
|
||||
* Используется для:
|
||||
* - проверки auth-aware deferred query
|
||||
* - демонстрации успешной и конфликтной отмены заказа
|
||||
*/
|
||||
export const OrdersScreen = (props: OrdersScreenProps) => {
|
||||
const { className, ...rootAttrs } = props
|
||||
const auth = useAuth()
|
||||
const isAuthenticated = auth.status === 'authenticated'
|
||||
const ordersState = useOrders(isAuthenticated)
|
||||
const orderCommands = useOrderCommands()
|
||||
const [cancellingOrderId, setCancellingOrderId] = useState<string | null>(null)
|
||||
const [actionError, setActionError] = useState<OrderError | null>(null)
|
||||
const hasOrders = isNonEmptyArray(ordersState.orders)
|
||||
|
||||
/**
|
||||
* Повторяет неуспешный protected orders query.
|
||||
*/
|
||||
const handleReload = (): void => {
|
||||
void ordersState.reload()
|
||||
}
|
||||
|
||||
/**
|
||||
* Повторяет recoverable auth check перед protected query.
|
||||
*/
|
||||
const handleSessionRetry = (): void => {
|
||||
void auth.refreshCurrentUser()
|
||||
}
|
||||
|
||||
/**
|
||||
* Запрашивает допустимый переход заказа в cancelled.
|
||||
*/
|
||||
const handleCancel = async (orderId: string): Promise<void> => {
|
||||
const sessionKey = auth.sessionKey
|
||||
|
||||
if (sessionKey === null) {
|
||||
return
|
||||
}
|
||||
|
||||
setCancellingOrderId(orderId)
|
||||
setActionError(null)
|
||||
|
||||
const result = await orderCommands.cancelOrder(orderId, sessionKey)
|
||||
|
||||
setCancellingOrderId(null)
|
||||
|
||||
if (!result.isSuccess) {
|
||||
setActionError(result.error)
|
||||
}
|
||||
}
|
||||
|
||||
if (auth.status === 'checking') {
|
||||
return (
|
||||
<main {...rootAttrs} className={cl(styles.root, styles.outcome, className)}>
|
||||
<FeedbackPanel
|
||||
title="Restoring protected scope"
|
||||
description="The orders query stays disabled until auth resolves its application-scoped session."
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
if (auth.status === 'unavailable') {
|
||||
return (
|
||||
<main {...rootAttrs} className={cl(styles.root, styles.outcome, className)}>
|
||||
<FeedbackPanel
|
||||
variant="error"
|
||||
title="Session authority is unavailable"
|
||||
description={auth.sessionError?.message ?? 'The session was preserved for retry.'}
|
||||
>
|
||||
<Button onClick={handleSessionRetry}>Retry session</Button>
|
||||
</FeedbackPanel>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main {...rootAttrs} className={cl(styles.root, styles.outcome, className)}>
|
||||
<FeedbackPanel
|
||||
variant="empty"
|
||||
title="This route needs a session"
|
||||
description="Sign in as customer to see own orders, or as admin to see every fixture order."
|
||||
>
|
||||
<Link className={styles.primaryLink} href="/sign-in">
|
||||
Choose account
|
||||
</Link>
|
||||
</FeedbackPanel>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main
|
||||
{...rootAttrs}
|
||||
className={cl(styles.root, className)}
|
||||
aria-busy={ordersState.isLoading}
|
||||
>
|
||||
<header className={styles.header}>
|
||||
<div>
|
||||
<p className={styles.kicker}>Orders / {auth.user?.role}</p>
|
||||
<h1>State in motion</h1>
|
||||
</div>
|
||||
<p className={styles.lede}>
|
||||
Backend transitions remain authoritative. The UI only offers cancellation where the
|
||||
current domain model allows it.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{ordersState.isLoading && (
|
||||
<div
|
||||
className={styles.loading}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label="Loading orders"
|
||||
/>
|
||||
)}
|
||||
|
||||
{ordersState.error && (
|
||||
<FeedbackPanel
|
||||
variant="error"
|
||||
title="Orders stayed inside their boundary"
|
||||
description={ordersState.error.message}
|
||||
>
|
||||
<Button onClick={handleReload}>Retry orders</Button>
|
||||
</FeedbackPanel>
|
||||
)}
|
||||
|
||||
{actionError && <p className={styles.actionError} role="alert">{actionError.message}</p>}
|
||||
|
||||
{isEmptyArray(ordersState.orders) && !ordersState.isLoading && ordersState.error === null && (
|
||||
<FeedbackPanel
|
||||
variant="empty"
|
||||
title="No visible orders"
|
||||
description="Create one from the cart or switch the demo scenario back to normal."
|
||||
>
|
||||
<Link className={styles.primaryLink} href="/">
|
||||
Browse catalog
|
||||
</Link>
|
||||
</FeedbackPanel>
|
||||
)}
|
||||
|
||||
{hasOrders && (
|
||||
<section className={styles.orders} aria-label="Order history">
|
||||
{ordersState.orders.map((order, index) => {
|
||||
const canCancel = canCancelOrder(order)
|
||||
const isCancelling = cancellingOrderId === order.id
|
||||
|
||||
return (
|
||||
<article key={order.id} className={styles.order}>
|
||||
<div className={styles.orderIndex}>{String(index + 1).padStart(2, '0')}</div>
|
||||
<div className={styles.orderHeader}>
|
||||
<div>
|
||||
<span>{formatDate(order.createdAt)}</span>
|
||||
<h2>{order.id}</h2>
|
||||
</div>
|
||||
<span className={styles.status} data-status={order.status}>
|
||||
{order.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.lines}>
|
||||
{order.lines.map((line) => (
|
||||
<div key={line.productId} className={styles.line}>
|
||||
<span>{line.quantity} x</span>
|
||||
<strong>{line.productName}</strong>
|
||||
<span>{formatMoney(line.unitPriceCents, order.currency)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.orderFooter}>
|
||||
<strong>{formatMoney(order.totalCents, order.currency)}</strong>
|
||||
{canCancel && (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="small"
|
||||
isLoading={isCancelling}
|
||||
onClick={() => handleCancel(order.id)}
|
||||
>
|
||||
Cancel order
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
.root {
|
||||
display: grid;
|
||||
gap: 38px;
|
||||
padding: clamp(48px, 8vw, 112px) clamp(18px, 7vw, 110px) 110px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(260px, 0.42fr);
|
||||
gap: 40px;
|
||||
align-items: end;
|
||||
padding-bottom: 34px;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.kicker {
|
||||
color: var(--color-blue);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
max-width: 9ch;
|
||||
margin-top: 14px;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(3rem, 8vw, 7.4rem);
|
||||
letter-spacing: -0.055em;
|
||||
line-height: 0.8;
|
||||
}
|
||||
|
||||
.lede {
|
||||
color: var(--color-ink-muted);
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.orders {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.order {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 26px;
|
||||
overflow: hidden;
|
||||
padding: clamp(24px, 4vw, 38px);
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 24px;
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.orderIndex {
|
||||
position: absolute;
|
||||
right: -8px;
|
||||
bottom: -22px;
|
||||
color: color-mix(in srgb, var(--color-ink) 5%, transparent);
|
||||
font-family: var(--font-display);
|
||||
font-size: 9rem;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.orderHeader,
|
||||
.orderFooter,
|
||||
.line {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.orderHeader span,
|
||||
.line span {
|
||||
color: var(--color-ink-faint);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.orderHeader h2 {
|
||||
margin-top: 6px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.7rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 8px 11px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface-strong);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status[data-status='pending'] {
|
||||
color: #845300;
|
||||
background: #ffe5ad;
|
||||
}
|
||||
|
||||
.status[data-status='paid'] {
|
||||
color: #215a00;
|
||||
background: #dfffa0;
|
||||
}
|
||||
|
||||
.status[data-status='shipped'] {
|
||||
color: #123ec0;
|
||||
background: #dae4ff;
|
||||
}
|
||||
|
||||
.status[data-status='cancelled'] {
|
||||
color: #8b2920;
|
||||
background: #ffd9d5;
|
||||
}
|
||||
|
||||
.lines {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.line {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
padding: 11px 0;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.line strong {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.orderFooter {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.orderFooter > strong {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.actionError {
|
||||
padding: 14px 18px;
|
||||
color: var(--color-error);
|
||||
border-left: 5px solid var(--color-error);
|
||||
background: color-mix(in srgb, var(--color-error) 8%, transparent);
|
||||
}
|
||||
|
||||
.loading {
|
||||
min-height: 380px;
|
||||
border-radius: 24px;
|
||||
background: var(--color-surface-strong);
|
||||
animation: pulse 1s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.outcome {
|
||||
min-height: calc(100vh - 82px);
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.primaryLink {
|
||||
padding: 14px 20px;
|
||||
color: var(--color-paper);
|
||||
border-radius: 999px;
|
||||
background: var(--color-ink);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
to {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 850px) {
|
||||
.header,
|
||||
.orders {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
/**
|
||||
* Параметры protected orders screen.
|
||||
*/
|
||||
export type OrdersScreenParams = object
|
||||
|
||||
/**
|
||||
* Атрибуты корневого main без внешнего содержимого.
|
||||
*/
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'main'>, 'children'>
|
||||
|
||||
/**
|
||||
* Props protected orders screen.
|
||||
*/
|
||||
export type OrdersScreenProps = RootAttrs & OrdersScreenParams
|
||||
@@ -1,2 +0,0 @@
|
||||
export { ProductAdminScreen } from './product-admin.screen'
|
||||
export type { ProductAdminScreenProps } from './types/product-admin-screen-props.type'
|
||||
@@ -1,351 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import cl from 'clsx'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { useAuth } from '@/domains/auth'
|
||||
import {
|
||||
useCategories,
|
||||
useCatalogCommands,
|
||||
useProductCatalog
|
||||
} from '@/domains/catalog'
|
||||
import type {
|
||||
CatalogError,
|
||||
CreateProductInput,
|
||||
Product,
|
||||
UpdateProductInput
|
||||
} from '@/domains/catalog'
|
||||
import { useOnDemoFixtureChange } from '@/domains/demo-control'
|
||||
import { formatMoney } from '@/shared/lib/format-money'
|
||||
import { isNonEmptyArray } from '@/shared/lib/value-predicates'
|
||||
import { Button } from '@/ui/button'
|
||||
import { FeedbackPanel } from '@/ui/feedback-panel'
|
||||
|
||||
import { ProductForm } from './ui/product-form'
|
||||
import type { ProductAdminScreenProps } from './types/product-admin-screen-props.type'
|
||||
import styles from './styles/product-admin.module.css'
|
||||
|
||||
/**
|
||||
* Admin-only catalog mutation workspace.
|
||||
*
|
||||
* Используется для:
|
||||
* - create/update/delete RBAC-сценариев
|
||||
* - проверки optimistic-lock conflict outcome
|
||||
*/
|
||||
export const ProductAdminScreen = (props: ProductAdminScreenProps) => {
|
||||
const { className, ...rootAttrs } = props
|
||||
const auth = useAuth()
|
||||
const catalogCommands = useCatalogCommands()
|
||||
const [adminPage, setAdminPage] = useState(1)
|
||||
const catalog = useProductCatalog({
|
||||
page: adminPage,
|
||||
limit: 100,
|
||||
search: '',
|
||||
categoryId: '',
|
||||
sort: 'newest'
|
||||
})
|
||||
const categoriesState = useCategories()
|
||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null)
|
||||
const [formGeneration, setFormGeneration] = useState(0)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [deletingProductId, setDeletingProductId] = useState<string | null>(null)
|
||||
const [mutationError, setMutationError] = useState<CatalogError | null>(null)
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null)
|
||||
const isAdmin = auth.status === 'authenticated' && auth.user?.role === 'admin'
|
||||
const hasProducts = isNonEmptyArray(catalog.products)
|
||||
const canGoBack = (catalog.pagination?.page ?? 1) > 1
|
||||
const canGoForward =
|
||||
catalog.pagination !== null && catalog.pagination.page < catalog.pagination.totalPages
|
||||
const isSelectedProductAvailable =
|
||||
selectedProduct !== null &&
|
||||
(
|
||||
catalog.isLoading ||
|
||||
catalog.error !== null ||
|
||||
catalog.products.some((product) => product.id === selectedProduct.id)
|
||||
)
|
||||
const formProduct = isSelectedProductAvailable ? selectedProduct : null
|
||||
const formKey = `${formProduct?.id ?? 'new'}-${formProduct?.version ?? 0}-${categoriesState.categories.length}-${formGeneration}`
|
||||
|
||||
useOnDemoFixtureChange((kind) => {
|
||||
if (kind === 'data') {
|
||||
setAdminPage(1)
|
||||
setSelectedProduct(null)
|
||||
setMutationError(null)
|
||||
setFormGeneration((value) => value + 1)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Переключает product form в edit mode с последним catalog snapshot.
|
||||
*/
|
||||
const handleEdit = (product: Product): void => {
|
||||
setSelectedProduct(product)
|
||||
setMutationError(null)
|
||||
setSuccessMessage(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает product form в create mode.
|
||||
*/
|
||||
const handleCancelEdit = (): void => {
|
||||
setSelectedProduct(null)
|
||||
setMutationError(null)
|
||||
setFormGeneration((value) => value + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Выбирает create или update domain scenario по форме input.
|
||||
*/
|
||||
const handleSubmit = async (
|
||||
input: CreateProductInput | UpdateProductInput
|
||||
): Promise<void> => {
|
||||
const sessionKey = auth.sessionKey
|
||||
|
||||
if (sessionKey === null) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setMutationError(null)
|
||||
setSuccessMessage(null)
|
||||
|
||||
const result = 'id' in input
|
||||
? await catalogCommands.updateProduct(input, sessionKey)
|
||||
: await catalogCommands.createProduct(input, sessionKey)
|
||||
|
||||
setIsSubmitting(false)
|
||||
|
||||
if (!result.isSuccess) {
|
||||
setMutationError(result.error)
|
||||
return
|
||||
}
|
||||
|
||||
setSuccessMessage(`${result.data.name} saved at version ${result.data.version}.`)
|
||||
setSelectedProduct(null)
|
||||
setFormGeneration((value) => value + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет продукт после явного browser confirmation.
|
||||
*/
|
||||
const handleRemove = async (product: Product): Promise<void> => {
|
||||
const sessionKey = auth.sessionKey
|
||||
|
||||
if (sessionKey === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const shouldRemove = window.confirm(`Remove ${product.name}?`)
|
||||
|
||||
if (!shouldRemove) {
|
||||
return
|
||||
}
|
||||
|
||||
setDeletingProductId(product.id)
|
||||
setMutationError(null)
|
||||
setSuccessMessage(null)
|
||||
|
||||
const result = await catalogCommands.removeProduct(product.id, sessionKey)
|
||||
|
||||
setDeletingProductId(null)
|
||||
|
||||
if (!result.isSuccess) {
|
||||
setMutationError(result.error)
|
||||
return
|
||||
}
|
||||
|
||||
setSuccessMessage(`${product.name} removed.`)
|
||||
|
||||
if (selectedProduct?.id === product.id) {
|
||||
setSelectedProduct(null)
|
||||
setFormGeneration((value) => value + 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Повторяет catalog query после transport failure.
|
||||
*/
|
||||
const handleReload = (): void => {
|
||||
void catalog.reload()
|
||||
}
|
||||
|
||||
/**
|
||||
* Открывает предыдущую admin inventory page.
|
||||
*/
|
||||
const handlePreviousPage = (): void => {
|
||||
setAdminPage((page) => Math.max(1, page - 1))
|
||||
}
|
||||
|
||||
/**
|
||||
* Открывает следующую admin inventory page.
|
||||
*/
|
||||
const handleNextPage = (): void => {
|
||||
setAdminPage((page) => page + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Повторяет recoverable auth check перед admin workspace.
|
||||
*/
|
||||
const handleSessionRetry = (): void => {
|
||||
void auth.refreshCurrentUser()
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает authority snapshot после optimistic-lock conflict.
|
||||
*/
|
||||
const handleLoadLatest = async (): Promise<void> => {
|
||||
if (formProduct === null) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
const result = await catalogCommands.loadProducts([formProduct.id])
|
||||
setIsSubmitting(false)
|
||||
|
||||
if (!result.isSuccess) {
|
||||
setMutationError(result.error)
|
||||
return
|
||||
}
|
||||
|
||||
const latestProduct = result.data[0]
|
||||
|
||||
if (latestProduct !== undefined) {
|
||||
setSelectedProduct(latestProduct)
|
||||
setMutationError(null)
|
||||
setSuccessMessage('Loaded the latest product version. Review changes before saving.')
|
||||
}
|
||||
}
|
||||
|
||||
if (auth.status === 'checking') {
|
||||
return (
|
||||
<main {...rootAttrs} className={cl(styles.root, styles.outcome, className)}>
|
||||
<FeedbackPanel
|
||||
title="Resolving admin scope"
|
||||
description="Catalog mutation UI waits for the auth owner before rendering protected controls."
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
if (auth.status === 'unavailable') {
|
||||
return (
|
||||
<main {...rootAttrs} className={cl(styles.root, styles.outcome, className)}>
|
||||
<FeedbackPanel
|
||||
variant="error"
|
||||
title="Admin session is temporarily unavailable"
|
||||
description={auth.sessionError?.message ?? 'The session was preserved for retry.'}
|
||||
>
|
||||
<Button onClick={handleSessionRetry}>Retry session</Button>
|
||||
</FeedbackPanel>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main {...rootAttrs} className={cl(styles.root, styles.outcome, className)}>
|
||||
<FeedbackPanel
|
||||
variant="error"
|
||||
title="Admin boundary"
|
||||
description="This composition does not infer permissions from the route. It reads the role owned by auth."
|
||||
>
|
||||
<Link className={styles.primaryLink} href="/sign-in">
|
||||
Sign in as admin
|
||||
</Link>
|
||||
</FeedbackPanel>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main {...rootAttrs} className={cl(styles.root, className)}>
|
||||
<header className={styles.header}>
|
||||
<div>
|
||||
<p className={styles.kicker}>Catalog / protected mutations</p>
|
||||
<h1>Versioned inventory</h1>
|
||||
</div>
|
||||
<p>
|
||||
Every update carries the last-read version. Use the conflict demo scenario to inspect
|
||||
the domain error without exposing transport payloads.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{catalog.error && (
|
||||
<FeedbackPanel variant="error" title="Catalog query failed" description={catalog.error.message}>
|
||||
<Button onClick={handleReload}>Retry inventory</Button>
|
||||
</FeedbackPanel>
|
||||
)}
|
||||
|
||||
<div className={styles.workspace}>
|
||||
<div className={styles.formColumn}>
|
||||
<ProductForm
|
||||
key={formKey}
|
||||
categories={categoriesState.categories}
|
||||
product={formProduct}
|
||||
isSubmitting={isSubmitting}
|
||||
error={mutationError?.message ?? null}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={handleCancelEdit}
|
||||
/>
|
||||
{mutationError?.code === 'conflict' && formProduct !== null && (
|
||||
<Button variant="secondary" onClick={handleLoadLatest}>
|
||||
Load latest version
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className={styles.inventory} aria-labelledby="inventory-title">
|
||||
<div className={styles.inventoryHeader}>
|
||||
<div>
|
||||
<span>Live snapshot</span>
|
||||
<h2 id="inventory-title">Inventory</h2>
|
||||
</div>
|
||||
<strong>{catalog.pagination?.total ?? 0} products</strong>
|
||||
</div>
|
||||
|
||||
{successMessage && <p className={styles.success} role="status">{successMessage}</p>}
|
||||
|
||||
{hasProducts && (
|
||||
<div className={styles.productList}>
|
||||
{catalog.products.map((product) => (
|
||||
<article key={product.id} className={styles.productRow}>
|
||||
<div>
|
||||
<span>v{product.version} / {product.stock} stock</span>
|
||||
<h3>{product.name}</h3>
|
||||
<small>{formatMoney(product.priceCents, product.currency)}</small>
|
||||
</div>
|
||||
<div className={styles.rowActions}>
|
||||
<Button variant="ghost" size="small" onClick={() => handleEdit(product)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="small"
|
||||
isLoading={deletingProductId === product.id}
|
||||
onClick={() => handleRemove(product)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{catalog.pagination && (catalog.pagination.totalPages > 1 || adminPage > 1) && (
|
||||
<div className={styles.pagination}>
|
||||
<Button variant="ghost" size="small" disabled={!canGoBack} onClick={handlePreviousPage}>
|
||||
Previous 100
|
||||
</Button>
|
||||
<span>{catalog.pagination.page} / {catalog.pagination.totalPages}</span>
|
||||
<Button variant="ghost" size="small" disabled={!canGoForward} onClick={handleNextPage}>
|
||||
Next 100
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
.root {
|
||||
display: grid;
|
||||
gap: 42px;
|
||||
padding: clamp(46px, 7vw, 100px) clamp(18px, 6vw, 90px) 110px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 0.42fr);
|
||||
gap: 42px;
|
||||
align-items: end;
|
||||
padding-bottom: 34px;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.kicker,
|
||||
.inventoryHeader span,
|
||||
.productRow span {
|
||||
color: var(--color-blue);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
max-width: 10ch;
|
||||
margin-top: 14px;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(3rem, 7.5vw, 7.2rem);
|
||||
letter-spacing: -0.055em;
|
||||
line-height: 0.8;
|
||||
}
|
||||
|
||||
.header > p {
|
||||
color: var(--color-ink-muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 0.78fr) minmax(0, 1.22fr);
|
||||
gap: clamp(24px, 5vw, 70px);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.formColumn {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.inventory {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.inventoryHeader {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.inventoryHeader h2 {
|
||||
margin-top: 7px;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(2rem, 4vw, 3.6rem);
|
||||
line-height: 0.9;
|
||||
}
|
||||
|
||||
.inventoryHeader strong {
|
||||
color: var(--color-ink-faint);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.productList {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.productRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 17px 0;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.productRow h3 {
|
||||
margin: 6px 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.45rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.productRow small {
|
||||
color: var(--color-ink-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.success {
|
||||
padding: 12px 14px;
|
||||
color: #315c00;
|
||||
border-left: 4px solid var(--color-acid-dark);
|
||||
background: color-mix(in srgb, var(--color-acid) 28%, transparent);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
padding-top: 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.outcome {
|
||||
min-height: calc(100vh - 82px);
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.primaryLink {
|
||||
padding: 14px 20px;
|
||||
color: var(--color-paper);
|
||||
border-radius: 999px;
|
||||
background: var(--color-ink);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.header,
|
||||
.workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.productRow {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
justify-content: start;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
/**
|
||||
* Параметры product administration screen.
|
||||
*/
|
||||
export type ProductAdminScreenParams = object
|
||||
|
||||
/**
|
||||
* Атрибуты корневого main без внешнего содержимого.
|
||||
*/
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'main'>, 'children'>
|
||||
|
||||
/**
|
||||
* Props product administration screen.
|
||||
*/
|
||||
export type ProductAdminScreenProps = RootAttrs & ProductAdminScreenParams
|
||||
@@ -1,2 +0,0 @@
|
||||
export { ProductForm } from './product-form'
|
||||
export type { ProductFormProps } from './types/product-form-props.type'
|
||||
@@ -1,272 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ChangeEvent, FormEvent } from 'react'
|
||||
import { useState } from 'react'
|
||||
import cl from 'clsx'
|
||||
|
||||
import type { CatalogCurrency, CreateProductInput, UpdateProductInput } from '@/domains/catalog'
|
||||
import { isOneOf } from '@/shared/lib/value-predicates'
|
||||
import { Button } from '@/ui/button'
|
||||
import { FormField } from '@/ui/form-field'
|
||||
|
||||
import type { ProductFormProps } from './types/product-form-props.type'
|
||||
import styles from './styles/product-form.module.css'
|
||||
|
||||
const CURRENCIES = ['USD', 'EUR'] as const
|
||||
|
||||
/**
|
||||
* Локальное редактируемое состояние admin-формы.
|
||||
*/
|
||||
type ProductFormValues = {
|
||||
/** Название продукта. */
|
||||
name: string
|
||||
/** Полное описание. */
|
||||
description: string
|
||||
/** Цена в cents как form string. */
|
||||
priceCents: string
|
||||
/** Валюта. */
|
||||
currency: CatalogCurrency
|
||||
/** Выбранная категория. */
|
||||
categoryId: string
|
||||
/** Stock как form string. */
|
||||
stock: string
|
||||
/** URL удалённого изображения. */
|
||||
imageUrl: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт initial form state из create mode или catalog snapshot.
|
||||
*/
|
||||
const createInitialValues = (props: ProductFormProps): ProductFormValues => {
|
||||
const { product, categories } = props
|
||||
|
||||
if (product !== null) {
|
||||
return {
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
priceCents: String(product.priceCents),
|
||||
currency: product.currency,
|
||||
categoryId: product.categoryId,
|
||||
stock: String(product.stock),
|
||||
imageUrl: product.imageUrl
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: '',
|
||||
description: '',
|
||||
priceCents: '9900',
|
||||
currency: 'USD',
|
||||
categoryId: categories[0]?.id ?? '',
|
||||
stock: '10',
|
||||
imageUrl: 'https://picsum.photos/seed/new-object/640/480'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Форма create/update с сохранением optimistic-lock версии продукта.
|
||||
*
|
||||
* Используется для:
|
||||
* - создания fixture-продукта
|
||||
* - редактирования последнего catalog snapshot
|
||||
*/
|
||||
export const ProductForm = (props: ProductFormProps) => {
|
||||
const {
|
||||
categories,
|
||||
product,
|
||||
isSubmitting,
|
||||
error,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
className,
|
||||
...rootAttrs
|
||||
} = props
|
||||
const [values, setValues] = useState<ProductFormValues>(() => createInitialValues(props))
|
||||
const isEditMode = product !== null
|
||||
const title = isEditMode ? `Edit ${product.name}` : 'Create object'
|
||||
const submitLabel = isEditMode ? 'Save version' : 'Create product'
|
||||
|
||||
/**
|
||||
* Обновляет текстовое поле формы по имени control.
|
||||
*/
|
||||
const handleTextChange = (
|
||||
event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
): void => {
|
||||
const field = event.target.name
|
||||
const value = event.target.value
|
||||
|
||||
if (field === 'name' || field === 'description' || field === 'imageUrl') {
|
||||
setValues((current) => ({ ...current, [field]: value }))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет числовое поле, сохраняя browser form representation.
|
||||
*/
|
||||
const handleNumberChange = (event: ChangeEvent<HTMLInputElement>): void => {
|
||||
const field = event.target.name
|
||||
const value = event.target.value
|
||||
|
||||
if (field === 'priceCents' || field === 'stock') {
|
||||
setValues((current) => ({ ...current, [field]: value }))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет выбранную category.
|
||||
*/
|
||||
const handleCategoryChange = (event: ChangeEvent<HTMLSelectElement>): void => {
|
||||
setValues((current) => ({ ...current, categoryId: event.target.value }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет только поддерживаемую backend currency.
|
||||
*/
|
||||
const handleCurrencyChange = (event: ChangeEvent<HTMLSelectElement>): void => {
|
||||
const value = event.target.value
|
||||
|
||||
if (isOneOf(value, CURRENCIES)) {
|
||||
setValues((current) => ({ ...current, currency: value }))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Передаёт нормализованный domain input screen-владельцу.
|
||||
*/
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault()
|
||||
|
||||
const baseInput: CreateProductInput = {
|
||||
name: values.name.trim(),
|
||||
description: values.description.trim(),
|
||||
priceCents: Number(values.priceCents),
|
||||
currency: values.currency,
|
||||
categoryId: values.categoryId,
|
||||
stock: Number(values.stock),
|
||||
imageUrl: values.imageUrl.trim()
|
||||
}
|
||||
|
||||
if (product !== null) {
|
||||
const updateInput: UpdateProductInput = {
|
||||
...baseInput,
|
||||
id: product.id,
|
||||
version: product.version
|
||||
}
|
||||
|
||||
await onSubmit(updateInput)
|
||||
return
|
||||
}
|
||||
|
||||
await onSubmit(baseInput)
|
||||
}
|
||||
|
||||
return (
|
||||
<form {...rootAttrs} className={cl(styles.root, className)} onSubmit={handleSubmit}>
|
||||
<div className={styles.heading}>
|
||||
<div>
|
||||
<span>{isEditMode ? `Version ${product.version}` : 'New catalog entry'}</span>
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
{isEditMode && (
|
||||
<Button variant="ghost" size="small" onClick={onCancel}>
|
||||
Cancel edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormField label="Name" htmlFor="product-name">
|
||||
<input
|
||||
id="product-name"
|
||||
name="name"
|
||||
value={values.name}
|
||||
minLength={2}
|
||||
maxLength={120}
|
||||
required
|
||||
onChange={handleTextChange}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Description" htmlFor="product-description">
|
||||
<textarea
|
||||
id="product-description"
|
||||
name="description"
|
||||
value={values.description}
|
||||
minLength={10}
|
||||
maxLength={1000}
|
||||
required
|
||||
onChange={handleTextChange}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className={styles.columns}>
|
||||
<FormField label="Price, cents" htmlFor="product-price">
|
||||
<input
|
||||
id="product-price"
|
||||
name="priceCents"
|
||||
type="number"
|
||||
min={0}
|
||||
value={values.priceCents}
|
||||
required
|
||||
onChange={handleNumberChange}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Currency" htmlFor="product-currency">
|
||||
<select id="product-currency" value={values.currency} onChange={handleCurrencyChange}>
|
||||
<option value="USD">USD</option>
|
||||
<option value="EUR">EUR</option>
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className={styles.columns}>
|
||||
<FormField label="Category" htmlFor="product-category">
|
||||
<select
|
||||
id="product-category"
|
||||
value={values.categoryId}
|
||||
required
|
||||
onChange={handleCategoryChange}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select category
|
||||
</option>
|
||||
{categories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Stock" htmlFor="product-stock">
|
||||
<input
|
||||
id="product-stock"
|
||||
name="stock"
|
||||
type="number"
|
||||
min={0}
|
||||
value={values.stock}
|
||||
required
|
||||
onChange={handleNumberChange}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField label="Image URL" htmlFor="product-image" hint="Allowed demo host: picsum.photos">
|
||||
<input
|
||||
id="product-image"
|
||||
name="imageUrl"
|
||||
type="url"
|
||||
value={values.imageUrl}
|
||||
required
|
||||
onChange={handleTextChange}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{error && <p className={styles.error} role="alert">{error}</p>}
|
||||
|
||||
<Button type="submit" isLoading={isSubmitting}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
.root {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: clamp(22px, 4vw, 38px);
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 24px;
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.heading span {
|
||||
color: var(--color-blue);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.64rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.heading h2 {
|
||||
margin-top: 8px;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.8rem, 4vw, 3rem);
|
||||
line-height: 0.92;
|
||||
}
|
||||
|
||||
.columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 12px 14px;
|
||||
color: var(--color-error);
|
||||
border-left: 4px solid var(--color-error);
|
||||
background: color-mix(in srgb, var(--color-error) 8%, transparent);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
import type {
|
||||
Category,
|
||||
CreateProductInput,
|
||||
Product,
|
||||
UpdateProductInput
|
||||
} from '@/domains/catalog'
|
||||
|
||||
/**
|
||||
* Собственные параметры admin product form.
|
||||
*/
|
||||
export type ProductFormParams = {
|
||||
/** Справочник допустимых категорий. */
|
||||
categories: Category[]
|
||||
/** Редактируемый продукт или null для create mode. */
|
||||
product: Product | null
|
||||
/** Выполняется ли catalog mutation. */
|
||||
isSubmitting: boolean
|
||||
/** Ошибка последней catalog mutation. */
|
||||
error: string | null
|
||||
/** Передаёт валидный create/update input screen-владельцу. */
|
||||
onSubmit: (input: CreateProductInput | UpdateProductInput) => Promise<void>
|
||||
/** Возвращает форму в create mode. */
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Атрибуты корневой form без собственного submit callback.
|
||||
*/
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'form'>, 'children' | 'onSubmit'>
|
||||
|
||||
/**
|
||||
* Props admin product form.
|
||||
*/
|
||||
export type ProductFormProps = RootAttrs & ProductFormParams
|
||||
@@ -1,2 +0,0 @@
|
||||
export { ProductDetailScreen } from './product-detail.screen'
|
||||
export type { ProductDetailScreenProps } from './types/product-detail-screen-props.type'
|
||||
@@ -1,127 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import cl from 'clsx'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { PRODUCT_IMAGE_PLACEHOLDER, useProduct } from '@/domains/catalog'
|
||||
import { useCart } from '@/domains/cart'
|
||||
import { formatMoney } from '@/shared/lib/format-money'
|
||||
import { Button } from '@/ui/button'
|
||||
import { FeedbackPanel } from '@/ui/feedback-panel'
|
||||
|
||||
import type { ProductDetailScreenProps } from './types/product-detail-screen-props.type'
|
||||
import styles from './styles/product-detail.module.css'
|
||||
|
||||
/**
|
||||
* Детальная карточка выбранного catalog-продукта.
|
||||
*
|
||||
* Используется для:
|
||||
* - проверки dynamic route и detail query
|
||||
* - добавления продукта в application cart
|
||||
*/
|
||||
export const ProductDetailScreen = (props: ProductDetailScreenProps) => {
|
||||
const { productId, className, ...rootAttrs } = props
|
||||
const productState = useProduct(productId)
|
||||
const cart = useCart()
|
||||
|
||||
/**
|
||||
* Повторяет неуспешный product detail query.
|
||||
*/
|
||||
const handleReload = (): void => {
|
||||
void productState.reload()
|
||||
}
|
||||
|
||||
if (productState.isLoading) {
|
||||
return (
|
||||
<main {...rootAttrs} className={cl(styles.root, className)} aria-busy>
|
||||
<div
|
||||
className={styles.loading}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label="Loading product"
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (productState.error !== null || productState.product === null) {
|
||||
const message = productState.error?.message ?? 'The product returned no usable data.'
|
||||
|
||||
return (
|
||||
<main {...rootAttrs} className={cl(styles.root, styles.outcome, className)}>
|
||||
<FeedbackPanel variant="error" title="Product boundary stopped here" description={message}>
|
||||
<Button onClick={handleReload}>Retry detail</Button>
|
||||
<Link className={styles.textLink} href="/">
|
||||
Back to catalog
|
||||
</Link>
|
||||
</FeedbackPanel>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const product = productState.product
|
||||
const isSoldOut = product.stock === 0
|
||||
const stockLabel = isSoldOut ? 'Unavailable' : `${product.stock} units in deterministic stock`
|
||||
|
||||
/**
|
||||
* Добавляет текущий detail-продукт в cart domain.
|
||||
*/
|
||||
const handleAdd = (): void => {
|
||||
void cart.addProduct(product)
|
||||
}
|
||||
|
||||
return (
|
||||
<main {...rootAttrs} className={cl(styles.root, className)}>
|
||||
<Link className={styles.back} href="/">
|
||||
Back / catalog
|
||||
</Link>
|
||||
|
||||
<article className={styles.product}>
|
||||
<div className={styles.visual}>
|
||||
<Image
|
||||
className={styles.image}
|
||||
src={product.imageUrl}
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
sizes="(max-width: 820px) 100vw, 55vw"
|
||||
onError={(event) => {
|
||||
event.currentTarget.srcset = ''
|
||||
event.currentTarget.src = PRODUCT_IMAGE_PLACEHOLDER
|
||||
}}
|
||||
/>
|
||||
<span className={styles.version}>v{product.version}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
<p className={styles.eyebrow}>Object / {product.categoryId.replace('category-', '')}</p>
|
||||
<h1 className={styles.title}>{product.name}</h1>
|
||||
<p className={styles.description}>{product.description}</p>
|
||||
|
||||
<dl className={styles.facts}>
|
||||
<div>
|
||||
<dt>Rating</dt>
|
||||
<dd>{product.rating.toFixed(1)} / 5</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Stock</dt>
|
||||
<dd>{stockLabel}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Lock</dt>
|
||||
<dd>Version {product.version}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className={styles.purchase}>
|
||||
<strong>{formatMoney(product.priceCents, product.currency)}</strong>
|
||||
<Button disabled={isSoldOut} onClick={handleAdd}>
|
||||
Add to cart
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
.root {
|
||||
min-height: calc(100vh - 82px);
|
||||
padding: clamp(28px, 5vw, 72px);
|
||||
}
|
||||
|
||||
.back,
|
||||
.textLink {
|
||||
display: inline-flex;
|
||||
color: var(--color-blue);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.product {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(340px, 0.85fr);
|
||||
gap: clamp(32px, 7vw, 100px);
|
||||
margin-top: 34px;
|
||||
}
|
||||
|
||||
.visual {
|
||||
position: relative;
|
||||
min-height: min(66vw, 720px);
|
||||
overflow: hidden;
|
||||
border-radius: 28px;
|
||||
background: var(--color-surface-strong);
|
||||
}
|
||||
|
||||
.image {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.version {
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 54px;
|
||||
height: 54px;
|
||||
color: var(--color-paper);
|
||||
border-radius: 50%;
|
||||
background: var(--color-blue);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
align-self: center;
|
||||
display: grid;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--color-blue);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.title {
|
||||
max-width: 9ch;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(3.2rem, 7vw, 7rem);
|
||||
letter-spacing: -0.055em;
|
||||
line-height: 0.82;
|
||||
}
|
||||
|
||||
.description {
|
||||
max-width: 48ch;
|
||||
color: var(--color-ink-muted);
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.facts {
|
||||
display: grid;
|
||||
border-block: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.facts div {
|
||||
display: grid;
|
||||
grid-template-columns: 90px 1fr;
|
||||
gap: 16px;
|
||||
padding: 13px 0;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.facts div:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.facts dt {
|
||||
color: var(--color-ink-faint);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.facts dd {
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.purchase {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.purchase strong {
|
||||
font-family: var(--font-mono);
|
||||
font-size: clamp(1.35rem, 3vw, 2rem);
|
||||
}
|
||||
|
||||
.outcome {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.loading {
|
||||
min-height: 70vh;
|
||||
border-radius: 28px;
|
||||
background:
|
||||
linear-gradient(105deg, transparent 30%, rgb(255 255 255 / 55%) 45%, transparent 60%)
|
||||
0 0 / 220% 100%,
|
||||
var(--color-surface-strong);
|
||||
animation: sweep 1.4s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes sweep {
|
||||
to {
|
||||
background-position: -220% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.product {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.visual {
|
||||
min-height: 70vw;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
/**
|
||||
* Параметры product detail screen.
|
||||
*/
|
||||
export type ProductDetailScreenParams = {
|
||||
/** Product id, адаптированный route entry. */
|
||||
productId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Атрибуты корневого main без внешнего содержимого.
|
||||
*/
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'main'>, 'children'>
|
||||
|
||||
/**
|
||||
* Props product detail screen.
|
||||
*/
|
||||
export type ProductDetailScreenProps = RootAttrs & ProductDetailScreenParams
|
||||
@@ -1,2 +0,0 @@
|
||||
export { SignInScreen } from './sign-in.screen'
|
||||
export type { SignInScreenProps } from './types/sign-in-screen-props.type'
|
||||
@@ -1,205 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ChangeEvent, FormEvent } from 'react'
|
||||
import { useState } from 'react'
|
||||
import cl from 'clsx'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import { AUTH_DEMO_ACCOUNTS, useAuth } from '@/domains/auth'
|
||||
import type { AuthError, DemoAccount, SignInCredentials } from '@/domains/auth'
|
||||
import { Button } from '@/ui/button'
|
||||
import { FeedbackPanel } from '@/ui/feedback-panel'
|
||||
import { FormField } from '@/ui/form-field'
|
||||
|
||||
import type { SignInScreenProps } from './types/sign-in-screen-props.type'
|
||||
import styles from './styles/sign-in.module.css'
|
||||
|
||||
/**
|
||||
* Auth route с двумя воспроизводимыми fixture-ролями.
|
||||
*
|
||||
* Используется для:
|
||||
* - запуска JWT login/refresh lifecycle
|
||||
* - переключения customer и admin RBAC outcomes
|
||||
*/
|
||||
export const SignInScreen = (props: SignInScreenProps) => {
|
||||
const { className, ...rootAttrs } = props
|
||||
const auth = useAuth()
|
||||
const router = useRouter()
|
||||
const [credentials, setCredentials] = useState<SignInCredentials>({
|
||||
email: AUTH_DEMO_ACCOUNTS[0].email,
|
||||
password: AUTH_DEMO_ACCOUNTS[0].password
|
||||
})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<AuthError | null>(null)
|
||||
|
||||
/**
|
||||
* Подставляет выбранную deterministic account в форму.
|
||||
*/
|
||||
const handleAccountSelect = (account: DemoAccount): void => {
|
||||
setCredentials({ email: account.email, password: account.password })
|
||||
setError(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет email login-формы.
|
||||
*/
|
||||
const handleEmailChange = (event: ChangeEvent<HTMLInputElement>): void => {
|
||||
setCredentials((current) => ({ ...current, email: event.target.value }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет password login-формы.
|
||||
*/
|
||||
const handlePasswordChange = (event: ChangeEvent<HTMLInputElement>): void => {
|
||||
setCredentials((current) => ({ ...current, password: event.target.value }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняет auth-domain login и переводит пользователя в каталог.
|
||||
*/
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
const result = await auth.signIn(credentials)
|
||||
|
||||
setIsSubmitting(false)
|
||||
|
||||
if (result.isSuccess) {
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
|
||||
setError(result.error)
|
||||
}
|
||||
|
||||
/**
|
||||
* Повторяет recoverable проверку сохранённой session.
|
||||
*/
|
||||
const handleSessionRetry = (): void => {
|
||||
void auth.refreshCurrentUser()
|
||||
}
|
||||
|
||||
/**
|
||||
* Явно завершает сохранённую session после recoverable failure.
|
||||
*/
|
||||
const handleSignOut = (): void => {
|
||||
void auth.signOut()
|
||||
}
|
||||
|
||||
if (auth.status === 'checking') {
|
||||
return (
|
||||
<main {...rootAttrs} className={cl(styles.root, styles.outcome, className)}>
|
||||
<FeedbackPanel
|
||||
title="Restoring session"
|
||||
description="The auth owner is validating persisted JWT data before exposing a route outcome."
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (auth.status === 'authenticated' && auth.user !== null) {
|
||||
return (
|
||||
<main {...rootAttrs} className={cl(styles.root, styles.outcome, className)}>
|
||||
<FeedbackPanel
|
||||
variant="success"
|
||||
title={`Signed in as ${auth.user.name}`}
|
||||
description={`The active ${auth.user.role} session is available to every composition through the auth public API.`}
|
||||
>
|
||||
<div className={styles.outcomeActions}>
|
||||
<Link className={styles.primaryLink} href="/">
|
||||
Browse catalog
|
||||
</Link>
|
||||
<Link className={styles.textLink} href="/orders">
|
||||
Open orders
|
||||
</Link>
|
||||
</div>
|
||||
</FeedbackPanel>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (auth.status === 'unavailable') {
|
||||
return (
|
||||
<main {...rootAttrs} className={cl(styles.root, styles.outcome, className)}>
|
||||
<FeedbackPanel
|
||||
variant="error"
|
||||
title="Session check is temporarily unavailable"
|
||||
description={auth.sessionError?.message ?? 'The saved session was kept for a safe retry.'}
|
||||
>
|
||||
<div className={styles.outcomeActions}>
|
||||
<Button onClick={handleSessionRetry}>Retry session</Button>
|
||||
<Button variant="ghost" onClick={handleSignOut}>End session</Button>
|
||||
</div>
|
||||
</FeedbackPanel>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main {...rootAttrs} className={cl(styles.root, className)}>
|
||||
<section className={styles.intro}>
|
||||
<p className={styles.kicker}>Auth / JWT transport</p>
|
||||
<h1 className={styles.title}>Choose a boundary to cross.</h1>
|
||||
<p className={styles.description}>
|
||||
Both accounts use the same password, but their domain permissions produce different
|
||||
navigation, mutations and order visibility.
|
||||
</p>
|
||||
|
||||
<div className={styles.accounts}>
|
||||
{AUTH_DEMO_ACCOUNTS.map((account) => (
|
||||
<button
|
||||
key={account.email}
|
||||
type="button"
|
||||
className={styles.accountCard}
|
||||
onClick={() => handleAccountSelect(account)}
|
||||
>
|
||||
<span>{account.role}</span>
|
||||
<strong>{account.email}</strong>
|
||||
<small>{account.description}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.formPanel} aria-labelledby="sign-in-title">
|
||||
<span className={styles.sequence}>02 / SESSION</span>
|
||||
<h2 id="sign-in-title">Enter the fixture</h2>
|
||||
<form className={styles.form} onSubmit={handleSubmit}>
|
||||
<FormField label="Email" htmlFor="email" hint="Try either demo account">
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
value={credentials.email}
|
||||
required
|
||||
onChange={handleEmailChange}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Password" htmlFor="password" hint="Fixture default: demo1234">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
minLength={8}
|
||||
value={credentials.password}
|
||||
required
|
||||
onChange={handlePasswordChange}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{error && <p className={styles.error} role="alert">{error.message}</p>}
|
||||
|
||||
<Button type="submit" isLoading={isSubmitting}>
|
||||
Start session
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
.root {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(360px, 0.72fr);
|
||||
min-height: calc(100vh - 82px);
|
||||
}
|
||||
|
||||
.intro,
|
||||
.formPanel {
|
||||
padding: clamp(44px, 8vw, 110px);
|
||||
}
|
||||
|
||||
.intro {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 30px;
|
||||
color: var(--color-paper);
|
||||
background: var(--color-ink);
|
||||
}
|
||||
|
||||
.kicker,
|
||||
.sequence {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
color: var(--color-acid);
|
||||
}
|
||||
|
||||
.title {
|
||||
max-width: 9ch;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(3.2rem, 8vw, 7.5rem);
|
||||
letter-spacing: -0.055em;
|
||||
line-height: 0.82;
|
||||
}
|
||||
|
||||
.description {
|
||||
max-width: 48ch;
|
||||
color: rgb(247 242 232 / 68%);
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.accounts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.accountCard {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 18px;
|
||||
color: inherit;
|
||||
border: 1px solid rgb(255 255 255 / 18%);
|
||||
border-radius: 16px;
|
||||
background: rgb(255 255 255 / 5%);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
background 160ms ease;
|
||||
}
|
||||
|
||||
.accountCard:hover,
|
||||
.accountCard:focus-visible {
|
||||
border-color: var(--color-acid);
|
||||
outline: none;
|
||||
background: rgb(200 255 77 / 8%);
|
||||
}
|
||||
|
||||
.accountCard span,
|
||||
.accountCard small {
|
||||
color: rgb(247 242 232 / 54%);
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.accountCard strong {
|
||||
overflow: hidden;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.formPanel {
|
||||
position: relative;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 26px;
|
||||
background:
|
||||
radial-gradient(circle at 90% 10%, color-mix(in srgb, var(--color-orange) 22%, transparent), transparent 32%),
|
||||
var(--color-paper);
|
||||
}
|
||||
|
||||
.sequence {
|
||||
color: var(--color-blue);
|
||||
}
|
||||
|
||||
.formPanel h2 {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(2rem, 5vw, 4rem);
|
||||
line-height: 0.9;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
max-width: 440px;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 12px 14px;
|
||||
color: var(--color-error);
|
||||
border-left: 4px solid var(--color-error);
|
||||
background: color-mix(in srgb, var(--color-error) 8%, transparent);
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.outcome {
|
||||
place-items: center;
|
||||
padding: clamp(30px, 8vw, 120px);
|
||||
}
|
||||
|
||||
.outcomeActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.primaryLink {
|
||||
padding: 14px 20px;
|
||||
color: var(--color-paper);
|
||||
border-radius: 999px;
|
||||
background: var(--color-ink);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.textLink {
|
||||
color: var(--color-blue);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.root {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.accounts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
/**
|
||||
* Параметры sign-in screen.
|
||||
*/
|
||||
export type SignInScreenParams = object
|
||||
|
||||
/**
|
||||
* Атрибуты корневого main без внешнего содержимого.
|
||||
*/
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'main'>, 'children'>
|
||||
|
||||
/**
|
||||
* Props sign-in screen.
|
||||
*/
|
||||
export type SignInScreenProps = RootAttrs & SignInScreenParams
|
||||
@@ -1,140 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { useEffect, useEffectEvent, useRef } from 'react'
|
||||
import cl from 'clsx'
|
||||
|
||||
import { useAuth } from '@/domains/auth'
|
||||
import { useCart } from '@/domains/cart'
|
||||
import { useDemoControl } from '@/domains/demo-control'
|
||||
import { Button } from '@/ui/button'
|
||||
|
||||
import type { DemoToolbarWidgetProps } from './types/demo-toolbar-widget-props.type'
|
||||
import styles from './styles/demo-toolbar.module.css'
|
||||
|
||||
/**
|
||||
* Управляет детерминированными сбоями и seed fixture-бэкенда.
|
||||
*
|
||||
* Используется для:
|
||||
* - проверки loading, empty, error и auth outcomes
|
||||
* - сброса данных между архитектурными демонстрациями
|
||||
*/
|
||||
export const DemoToolbarWidget = (props: DemoToolbarWidgetProps) => {
|
||||
const { className, ...rootAttrs } = props
|
||||
const demo = useDemoControl()
|
||||
const auth = useAuth()
|
||||
const cart = useCart()
|
||||
const selectedOption = demo.scenarios.find((option) => option.value === demo.scenario)
|
||||
const healthLabel = demo.health === 'online' ? `API ${demo.apiVersion ?? ''}`.trim() : demo.health
|
||||
const canChangeRole = auth.user !== null
|
||||
const nextRole = auth.user?.role === 'admin' ? 'customer' : 'admin'
|
||||
const roleActionLabel = `Become ${nextRole}`
|
||||
const previousFixtureChangeRef = useRef(demo.fixtureChange)
|
||||
const synchronizeFixtureOwners = useEffectEvent((change: string) => {
|
||||
if (change.startsWith('data:')) {
|
||||
void cart.clearCart()
|
||||
}
|
||||
|
||||
if (auth.sessionKey !== null) {
|
||||
void auth.refreshCurrentUser()
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (previousFixtureChangeRef.current !== demo.fixtureChange) {
|
||||
previousFixtureChangeRef.current = demo.fixtureChange
|
||||
synchronizeFixtureOwners(demo.fixtureChange)
|
||||
}
|
||||
}, [demo.fixtureChange])
|
||||
|
||||
/**
|
||||
* Применяет выбранный request-local сценарий.
|
||||
*/
|
||||
const handleScenarioChange = (event: ChangeEvent<HTMLSelectElement>): void => {
|
||||
const selectedScenario = demo.scenarios.find((option) => option.value === event.target.value)
|
||||
|
||||
if (selectedScenario !== undefined) {
|
||||
void demo.selectScenario(selectedScenario.value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Восстанавливает исходный малый seed.
|
||||
*/
|
||||
const handleReset = async (): Promise<void> => {
|
||||
await demo.resetData()
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает большой deterministic dataset.
|
||||
*/
|
||||
const handleLargeSeed = async (): Promise<void> => {
|
||||
await demo.seedData('large')
|
||||
}
|
||||
|
||||
/**
|
||||
* Переключает роль текущего fixture-пользователя и обновляет профиль.
|
||||
*/
|
||||
const handleRoleChange = async (): Promise<void> => {
|
||||
if (auth.user === null) {
|
||||
return
|
||||
}
|
||||
|
||||
await demo.changeRole(auth.user.id, nextRole)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside {...rootAttrs} className={cl(styles.root, className)}>
|
||||
<details className={styles.panel}>
|
||||
<summary className={styles.summary}>
|
||||
<span className={styles.pulse} data-health={demo.health} />
|
||||
<span>Demo controls</span>
|
||||
<span className={styles.current}>{demo.scenario}</span>
|
||||
</summary>
|
||||
|
||||
<div className={styles.body}>
|
||||
<div className={styles.intro}>
|
||||
<p className={styles.eyebrow}>Fixture runtime</p>
|
||||
<p className={styles.description}>
|
||||
{selectedOption?.description ?? 'Choose a deterministic API behavior.'}
|
||||
</p>
|
||||
<span className={styles.health}>{healthLabel}</span>
|
||||
</div>
|
||||
|
||||
<label className={styles.control}>
|
||||
<span>Request scenario</span>
|
||||
<select value={demo.scenario} onChange={handleScenarioChange}>
|
||||
{demo.scenarios.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Button variant="ghost" size="small" isLoading={demo.isMutating} onClick={handleReset}>
|
||||
Reset data
|
||||
</Button>
|
||||
<Button variant="ghost" size="small" isLoading={demo.isMutating} onClick={handleLargeSeed}>
|
||||
Seed 250
|
||||
</Button>
|
||||
{canChangeRole && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
isLoading={demo.isMutating}
|
||||
onClick={handleRoleChange}
|
||||
>
|
||||
{roleActionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{demo.message && <p className={styles.message} role="status">{demo.message}</p>}
|
||||
{demo.error && <p className={styles.error} role="alert">{demo.error.message}</p>}
|
||||
</div>
|
||||
</details>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { DemoToolbarWidget } from './demo-toolbar.widget'
|
||||
export type { DemoToolbarWidgetProps } from './types/demo-toolbar-widget-props.type'
|
||||
@@ -1,159 +0,0 @@
|
||||
.root {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
z-index: 40;
|
||||
width: min(390px, calc(100vw - 36px));
|
||||
}
|
||||
|
||||
.panel {
|
||||
overflow: hidden;
|
||||
color: #f7f2e8;
|
||||
border: 1px solid rgb(255 255 255 / 18%);
|
||||
border-radius: 18px;
|
||||
background: rgb(20 20 19 / 94%);
|
||||
box-shadow: 0 24px 70px rgb(0 0 0 / 28%);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 48px;
|
||||
padding: 0 16px;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pulse {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #ffac33;
|
||||
box-shadow: 0 0 0 5px rgb(255 172 51 / 12%);
|
||||
}
|
||||
|
||||
.pulse[data-health='online'] {
|
||||
background: #c8ff4d;
|
||||
box-shadow: 0 0 0 5px rgb(200 255 77 / 12%);
|
||||
}
|
||||
|
||||
.pulse[data-health='offline'] {
|
||||
background: #ff6b5f;
|
||||
box-shadow: 0 0 0 5px rgb(255 107 95 / 14%);
|
||||
}
|
||||
|
||||
.current {
|
||||
max-width: 130px;
|
||||
overflow: hidden;
|
||||
color: #c8ff4d;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 4px 16px 16px;
|
||||
border-top: 1px solid rgb(255 255 255 / 12%);
|
||||
}
|
||||
|
||||
.intro {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 5px 12px;
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.control span {
|
||||
color: rgb(247 242 232 / 56%);
|
||||
font-size: 0.64rem;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.11em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.description {
|
||||
grid-column: 1 / -1;
|
||||
color: rgb(247 242 232 / 78%);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.health {
|
||||
color: #c8ff4d;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.control {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.control select {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
padding: 0 12px;
|
||||
color: #f7f2e8;
|
||||
border: 1px solid rgb(255 255 255 / 20%);
|
||||
border-radius: 10px;
|
||||
background: #292927;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
color: #f7f2e8;
|
||||
border-color: rgb(255 255 255 / 20%);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.message,
|
||||
.error {
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.message {
|
||||
color: #dfffa0;
|
||||
background: rgb(200 255 77 / 10%);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ffd1cc;
|
||||
background: rgb(255 107 95 / 13%);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.root {
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
width: calc(100vw - 20px);
|
||||
}
|
||||
|
||||
.body {
|
||||
max-height: calc(100dvh - 78px);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
/**
|
||||
* Параметры виджета архитектурных demo-сценариев.
|
||||
*/
|
||||
export type DemoToolbarWidgetParams = object
|
||||
|
||||
/**
|
||||
* Атрибуты корневого aside.
|
||||
*/
|
||||
type RootAttrs = ComponentPropsWithoutRef<'aside'>
|
||||
|
||||
/**
|
||||
* Props demo toolbar widget.
|
||||
*/
|
||||
export type DemoToolbarWidgetProps = RootAttrs & DemoToolbarWidgetParams
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { DemoAccount } from './types/demo-account.type'
|
||||
|
||||
/**
|
||||
* Учётные записи Simple API для воспроизводимых auth/RBAC-сценариев.
|
||||
*/
|
||||
export const AUTH_DEMO_ACCOUNTS: readonly DemoAccount[] = [
|
||||
{
|
||||
email: 'admin@demo.local',
|
||||
password: 'demo1234',
|
||||
role: 'admin',
|
||||
description: 'Catalog CRUD and every order'
|
||||
},
|
||||
{
|
||||
email: 'customer@demo.local',
|
||||
password: 'demo1234',
|
||||
role: 'customer',
|
||||
description: 'Checkout and own orders'
|
||||
}
|
||||
]
|
||||
@@ -1,8 +0,0 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
import type { AuthContextValue } from './types/auth-context.type'
|
||||
|
||||
/**
|
||||
* Внутренний React context application-scoped auth API.
|
||||
*/
|
||||
export const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
@@ -1,393 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { startTransition, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import {
|
||||
clearSimpleAuthSessionScope,
|
||||
discardSimpleAuthSession,
|
||||
readSimpleAuthSession,
|
||||
replaceSimpleAuthSession,
|
||||
subscribeSimpleAuthSession
|
||||
} from '@/infra/simple-auth-session'
|
||||
import type { SimpleAuthSession } from '@/infra/simple-auth-session'
|
||||
import { simpleRestApi } from '@/infra/simple-rest-api'
|
||||
import type { Result } from '@/shared/types/result.type'
|
||||
|
||||
import { AuthContext } from './auth.context'
|
||||
import { mapAuthError } from './errors/auth-error.mapper'
|
||||
import { mapAuthUser } from './mappers/auth-user.mapper'
|
||||
import type { AuthError } from './types/auth-error.type'
|
||||
import type { AuthContextValue, SignInCredentials } from './types/auth-context.type'
|
||||
import type { AuthProviderProps } from './types/auth-provider-props.type'
|
||||
import type { AuthUser } from './types/auth-user.type'
|
||||
|
||||
const SESSION_STORAGE_ERROR: AuthError = {
|
||||
code: 'service-unavailable',
|
||||
message: 'Browser storage is unavailable. The session was not changed.'
|
||||
}
|
||||
const SESSION_CHANGED_ERROR: AuthError = {
|
||||
code: 'session-expired',
|
||||
message: 'The active session changed before the request completed.'
|
||||
}
|
||||
|
||||
/**
|
||||
* Владеет пользовательской сессией на всём времени жизни приложения.
|
||||
*
|
||||
* Используется для:
|
||||
* - восстановления профиля из CAS-protected JWT session
|
||||
* - предоставления login/logout API всем composition-модулям
|
||||
*/
|
||||
export const AuthProvider = (props: AuthProviderProps) => {
|
||||
const { children } = props
|
||||
const [status, setStatus] = useState<AuthContextValue['status']>('checking')
|
||||
const [user, setUser] = useState<AuthUser | null>(null)
|
||||
const [sessionKey, setSessionKey] = useState<string | null>(null)
|
||||
const [sessionError, setSessionError] = useState<AuthError | null>(null)
|
||||
const sessionKeyRef = useRef<string | null>(null)
|
||||
const profileRequestIdRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true
|
||||
|
||||
/**
|
||||
* Загружает authority-профиль только для всё ещё актуальной session scope.
|
||||
*/
|
||||
const loadSessionUser = async (session: SimpleAuthSession): Promise<void> => {
|
||||
const requestId = ++profileRequestIdRef.current
|
||||
|
||||
try {
|
||||
const response = await simpleRestApi.users.simpleUsersMe()
|
||||
const currentResult = readSimpleAuthSession()
|
||||
|
||||
if (
|
||||
!isActive ||
|
||||
requestId !== profileRequestIdRef.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (currentResult.status === 'unavailable') {
|
||||
setSessionError(SESSION_STORAGE_ERROR)
|
||||
setStatus('unavailable')
|
||||
return
|
||||
}
|
||||
|
||||
if (currentResult.session?.sessionId !== session.sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
sessionKeyRef.current = session.sessionId
|
||||
setSessionKey(session.sessionId)
|
||||
setUser(mapAuthUser(response.data))
|
||||
setSessionError(null)
|
||||
setStatus('authenticated')
|
||||
})
|
||||
} catch (error) {
|
||||
const currentResult = readSimpleAuthSession()
|
||||
|
||||
if (!isActive || requestId !== profileRequestIdRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (currentResult.status === 'unavailable') {
|
||||
setSessionError(SESSION_STORAGE_ERROR)
|
||||
setStatus('unavailable')
|
||||
return
|
||||
}
|
||||
|
||||
const currentSession = currentResult.session
|
||||
|
||||
if (currentSession === null) {
|
||||
sessionKeyRef.current = null
|
||||
setSessionKey(null)
|
||||
setUser(null)
|
||||
setSessionError(null)
|
||||
setStatus('guest')
|
||||
return
|
||||
}
|
||||
|
||||
if (currentSession.sessionId === session.sessionId) {
|
||||
startTransition(() => {
|
||||
sessionKeyRef.current = session.sessionId
|
||||
setSessionKey(session.sessionId)
|
||||
setSessionError(mapAuthError(error))
|
||||
setStatus('unavailable')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribe = subscribeSimpleAuthSession((result) => {
|
||||
if (!isActive) {
|
||||
return
|
||||
}
|
||||
|
||||
if (result.status === 'unavailable') {
|
||||
profileRequestIdRef.current += 1
|
||||
setSessionError(SESSION_STORAGE_ERROR)
|
||||
setStatus('unavailable')
|
||||
return
|
||||
}
|
||||
|
||||
const session = result.session
|
||||
|
||||
if (session === null) {
|
||||
profileRequestIdRef.current += 1
|
||||
sessionKeyRef.current = null
|
||||
setSessionKey(null)
|
||||
setUser(null)
|
||||
setSessionError(null)
|
||||
setStatus('guest')
|
||||
return
|
||||
}
|
||||
|
||||
if (session.sessionId !== sessionKeyRef.current) {
|
||||
profileRequestIdRef.current += 1
|
||||
sessionKeyRef.current = session.sessionId
|
||||
setSessionKey(session.sessionId)
|
||||
setUser(null)
|
||||
setSessionError(null)
|
||||
setStatus('checking')
|
||||
void loadSessionUser(session)
|
||||
}
|
||||
})
|
||||
const initialResult = readSimpleAuthSession()
|
||||
|
||||
if (initialResult.status === 'unavailable') {
|
||||
queueMicrotask(() => {
|
||||
if (isActive) {
|
||||
setSessionError(SESSION_STORAGE_ERROR)
|
||||
setStatus('unavailable')
|
||||
}
|
||||
})
|
||||
} else if (initialResult.session === null) {
|
||||
queueMicrotask(() => {
|
||||
if (isActive) {
|
||||
setStatus('guest')
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const session = initialResult.session
|
||||
|
||||
sessionKeyRef.current = session.sessionId
|
||||
void loadSessionUser(session)
|
||||
}
|
||||
|
||||
return () => {
|
||||
isActive = false
|
||||
unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Выполняет login и создаёт новую logical session scope.
|
||||
*/
|
||||
const signIn = async (
|
||||
credentials: SignInCredentials
|
||||
): Promise<Result<AuthUser, AuthError>> => {
|
||||
try {
|
||||
const response = await simpleRestApi.auth.simpleAuthLogin(credentials)
|
||||
const nextUser = mapAuthUser(response.data.user)
|
||||
const session = await replaceSimpleAuthSession(response.data.tokens)
|
||||
|
||||
if (session === null) {
|
||||
return { isSuccess: false, error: SESSION_STORAGE_ERROR }
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
profileRequestIdRef.current += 1
|
||||
sessionKeyRef.current = session.sessionId
|
||||
setSessionKey(session.sessionId)
|
||||
setUser(nextUser)
|
||||
setSessionError(null)
|
||||
setStatus('authenticated')
|
||||
})
|
||||
|
||||
return { isSuccess: true, data: nextUser }
|
||||
} catch (error) {
|
||||
return { isSuccess: false, error: mapAuthError(error) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Локально завершает session до best-effort backend logout.
|
||||
*/
|
||||
const signOut = async (): Promise<void> => {
|
||||
const currentResult = readSimpleAuthSession()
|
||||
|
||||
if (currentResult.status === 'unavailable') {
|
||||
if (currentResult.observedValue === null) {
|
||||
setSessionError(SESSION_STORAGE_ERROR)
|
||||
setStatus('unavailable')
|
||||
return
|
||||
}
|
||||
|
||||
const isDiscarded = await discardSimpleAuthSession(currentResult.observedValue)
|
||||
|
||||
if (!isDiscarded) {
|
||||
const latestResult = readSimpleAuthSession()
|
||||
|
||||
if (latestResult.status === 'unavailable') {
|
||||
setSessionError(SESSION_STORAGE_ERROR)
|
||||
setStatus('unavailable')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
profileRequestIdRef.current += 1
|
||||
sessionKeyRef.current = null
|
||||
setSessionKey(null)
|
||||
setUser(null)
|
||||
setSessionError(null)
|
||||
setStatus('guest')
|
||||
return
|
||||
}
|
||||
|
||||
const session = currentResult.session
|
||||
|
||||
if (session === null) {
|
||||
profileRequestIdRef.current += 1
|
||||
sessionKeyRef.current = null
|
||||
setSessionKey(null)
|
||||
setUser(null)
|
||||
setSessionError(null)
|
||||
setStatus('guest')
|
||||
return
|
||||
}
|
||||
|
||||
const isCleared = await clearSimpleAuthSessionScope(session.sessionId)
|
||||
|
||||
if (!isCleared) {
|
||||
setSessionError(SESSION_STORAGE_ERROR)
|
||||
setStatus('unavailable')
|
||||
return
|
||||
}
|
||||
|
||||
profileRequestIdRef.current += 1
|
||||
sessionKeyRef.current = null
|
||||
setSessionKey(null)
|
||||
setUser(null)
|
||||
setSessionError(null)
|
||||
setStatus('guest')
|
||||
|
||||
try {
|
||||
await simpleRestApi.auth.simpleAuthLogout({
|
||||
refreshToken: session.tokens.refreshToken
|
||||
})
|
||||
} catch {
|
||||
// Remote revocation is best-effort after the local scope is already closed.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет authority-профиль или публикует recoverable session failure.
|
||||
*/
|
||||
const refreshCurrentUser = async (): Promise<Result<AuthUser, AuthError>> => {
|
||||
const expectedResult = readSimpleAuthSession()
|
||||
|
||||
if (expectedResult.status === 'unavailable') {
|
||||
setSessionError(SESSION_STORAGE_ERROR)
|
||||
setStatus('unavailable')
|
||||
return { isSuccess: false, error: SESSION_STORAGE_ERROR }
|
||||
}
|
||||
|
||||
const expectedSession = expectedResult.session
|
||||
|
||||
if (expectedSession === null) {
|
||||
profileRequestIdRef.current += 1
|
||||
sessionKeyRef.current = null
|
||||
setSessionKey(null)
|
||||
setUser(null)
|
||||
setSessionError(null)
|
||||
setStatus('guest')
|
||||
return { isSuccess: false, error: SESSION_CHANGED_ERROR }
|
||||
}
|
||||
|
||||
const requestId = ++profileRequestIdRef.current
|
||||
|
||||
try {
|
||||
const response = await simpleRestApi.users.simpleUsersMe()
|
||||
const nextUser = mapAuthUser(response.data)
|
||||
const currentResult = readSimpleAuthSession()
|
||||
|
||||
if (currentResult.status === 'unavailable') {
|
||||
setSessionError(SESSION_STORAGE_ERROR)
|
||||
setStatus('unavailable')
|
||||
return { isSuccess: false, error: SESSION_STORAGE_ERROR }
|
||||
}
|
||||
|
||||
const currentSession = currentResult.session
|
||||
|
||||
if (
|
||||
requestId !== profileRequestIdRef.current ||
|
||||
currentSession?.sessionId !== expectedSession.sessionId
|
||||
) {
|
||||
return { isSuccess: false, error: SESSION_CHANGED_ERROR }
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
sessionKeyRef.current = expectedSession.sessionId
|
||||
setSessionKey(expectedSession.sessionId)
|
||||
setUser(nextUser)
|
||||
setSessionError(null)
|
||||
setStatus('authenticated')
|
||||
})
|
||||
|
||||
return { isSuccess: true, data: nextUser }
|
||||
} catch (error) {
|
||||
const authError = mapAuthError(error)
|
||||
const currentResult = readSimpleAuthSession()
|
||||
|
||||
if (currentResult.status === 'unavailable') {
|
||||
setSessionError(SESSION_STORAGE_ERROR)
|
||||
setStatus('unavailable')
|
||||
return { isSuccess: false, error: SESSION_STORAGE_ERROR }
|
||||
}
|
||||
|
||||
const currentSession = currentResult.session
|
||||
|
||||
if (
|
||||
requestId !== profileRequestIdRef.current ||
|
||||
currentSession?.sessionId !== expectedSession.sessionId
|
||||
) {
|
||||
return { isSuccess: false, error: SESSION_CHANGED_ERROR }
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
sessionKeyRef.current = expectedSession.sessionId
|
||||
setSessionKey(expectedSession.sessionId)
|
||||
setSessionError(authError)
|
||||
setStatus('unavailable')
|
||||
})
|
||||
|
||||
return { isSuccess: false, error: authError }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сверяет captured session scope с domain state и persisted authority.
|
||||
*/
|
||||
const isCurrentSession = (expectedSessionKey: string): boolean => {
|
||||
const currentResult = readSimpleAuthSession()
|
||||
const persistedSession = currentResult.status === 'ready' ? currentResult.session : null
|
||||
|
||||
return (
|
||||
sessionKeyRef.current === expectedSessionKey &&
|
||||
persistedSession?.sessionId === expectedSessionKey
|
||||
)
|
||||
}
|
||||
|
||||
const value: AuthContextValue = {
|
||||
status,
|
||||
user,
|
||||
sessionKey,
|
||||
sessionError,
|
||||
isCurrentSession,
|
||||
signIn,
|
||||
signOut,
|
||||
refreshCurrentUser
|
||||
}
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { readSimpleApiFailure } from '@/infra/simple-rest-api'
|
||||
|
||||
import type { AuthError } from '../types/auth-error.type'
|
||||
|
||||
/**
|
||||
* Переводит technical failure в ожидаемую ошибку auth-домена.
|
||||
*/
|
||||
export const mapAuthError = (error: unknown): AuthError => {
|
||||
const failure = readSimpleApiFailure(error)
|
||||
|
||||
if (failure.code === 'INVALID_CREDENTIALS') {
|
||||
return { code: 'invalid-credentials', message: 'Email or password is incorrect.' }
|
||||
}
|
||||
|
||||
if (failure.status === 401) {
|
||||
return { code: 'session-expired', message: 'Your session expired. Sign in again.' }
|
||||
}
|
||||
|
||||
if (failure.status === 403) {
|
||||
return { code: 'access-denied', message: 'This account cannot perform that action.' }
|
||||
}
|
||||
|
||||
if (failure.status === 0) {
|
||||
return { code: 'service-unavailable', message: failure.message }
|
||||
}
|
||||
|
||||
return { code: 'unknown', message: 'Authentication could not be completed.' }
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useContext } from 'react'
|
||||
|
||||
import { AuthContext } from '../auth.context'
|
||||
import type { AuthContextValue } from '../types/auth-context.type'
|
||||
|
||||
/**
|
||||
* Возвращает application-scoped auth API текущего пользователя.
|
||||
*/
|
||||
export const useAuth = (): AuthContextValue => {
|
||||
const context = useContext(AuthContext)
|
||||
|
||||
if (context === null) {
|
||||
throw new Error('useAuth must be used inside AuthProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
'use client'
|
||||
|
||||
export { AuthProvider } from './auth.provider'
|
||||
export { AUTH_DEMO_ACCOUNTS } from './auth.constants'
|
||||
export { useAuth } from './hooks/use-auth.hook'
|
||||
export type { AuthError, AuthErrorCode } from './types/auth-error.type'
|
||||
export type {
|
||||
AuthContextValue,
|
||||
AuthStatus,
|
||||
SignInCredentials
|
||||
} from './types/auth-context.type'
|
||||
export type { AuthProviderProps } from './types/auth-provider-props.type'
|
||||
export type { AuthUser, AuthUserRole } from './types/auth-user.type'
|
||||
export type { DemoAccount } from './types/demo-account.type'
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { SimpleUserDto } from '@/infra/simple-rest-api'
|
||||
|
||||
import type { AuthUser } from '../types/auth-user.type'
|
||||
|
||||
/**
|
||||
* Переводит внешний user DTO в модель auth-домена.
|
||||
*/
|
||||
export const mapAuthUser = (user: SimpleUserDto): AuthUser => {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
avatarUrl: typeof user.avatarUrl === 'string' ? user.avatarUrl : null
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { Result } from '@/shared/types/result.type'
|
||||
|
||||
import type { AuthError } from './auth-error.type'
|
||||
import type { AuthUser } from './auth-user.type'
|
||||
|
||||
/**
|
||||
* Состояние восстановления пользовательской сессии.
|
||||
*/
|
||||
export type AuthStatus = 'checking' | 'guest' | 'authenticated' | 'unavailable'
|
||||
|
||||
/**
|
||||
* Данные формы входа.
|
||||
*/
|
||||
export type SignInCredentials = {
|
||||
/** Email demo-пользователя. */
|
||||
email: string
|
||||
/** Пароль demo-пользователя. */
|
||||
password: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Публичный API auth-домена в application scope.
|
||||
*/
|
||||
export type AuthContextValue = {
|
||||
/** Текущее состояние восстановления или авторизации. */
|
||||
status: AuthStatus
|
||||
/** Авторизованный пользователь, если сессия активна. */
|
||||
user: AuthUser | null
|
||||
/** Непрозрачный ключ логической сессии для изоляции protected state. */
|
||||
sessionKey: string | null
|
||||
/** Recoverable ошибка проверки сохранённой сессии. */
|
||||
sessionError: AuthError | null
|
||||
/** Проверяет актуальность captured logical session для async workflow. */
|
||||
isCurrentSession: (sessionKey: string) => boolean
|
||||
/** Выполняет login и сохраняет технические JWT-данные. */
|
||||
signIn: (credentials: SignInCredentials) => Promise<Result<AuthUser, AuthError>>
|
||||
/** Завершает локальную и backend-сессию. */
|
||||
signOut: () => Promise<void>
|
||||
/** Повторно загружает профиль после внешнего изменения роли. */
|
||||
refreshCurrentUser: () => Promise<Result<AuthUser, AuthError>>
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* Стабильные коды ожидаемых ошибок auth-сценариев.
|
||||
*/
|
||||
export type AuthErrorCode =
|
||||
| 'invalid-credentials'
|
||||
| 'session-expired'
|
||||
| 'access-denied'
|
||||
| 'service-unavailable'
|
||||
| 'unknown'
|
||||
|
||||
/**
|
||||
* Безопасная ошибка auth-домена.
|
||||
*/
|
||||
export type AuthError = {
|
||||
/** Код для UI-ветвления. */
|
||||
code: AuthErrorCode
|
||||
/** Сообщение без transport payload. */
|
||||
message: string
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Props application-scoped auth provider.
|
||||
*/
|
||||
export type AuthProviderProps = {
|
||||
/** Поддерево, использующее auth API. */
|
||||
children: ReactNode
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* Роль пользователя в Simple storefront.
|
||||
*/
|
||||
export type AuthUserRole = 'admin' | 'customer'
|
||||
|
||||
/**
|
||||
* Авторизованный пользователь без transport-полей.
|
||||
*/
|
||||
export type AuthUser = {
|
||||
/** Стабильный идентификатор пользователя. */
|
||||
id: string
|
||||
/** Email для входа и отображения профиля. */
|
||||
email: string
|
||||
/** Отображаемое имя. */
|
||||
name: string
|
||||
/** Текущая роль доступа. */
|
||||
role: AuthUserRole
|
||||
/** Необязательный URL аватара. */
|
||||
avatarUrl: string | null
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { AuthUserRole } from './auth-user.type'
|
||||
|
||||
/**
|
||||
* Детерминированная учётная запись fixture-бэкенда.
|
||||
*/
|
||||
export type DemoAccount = {
|
||||
/** Email для формы входа. */
|
||||
email: string
|
||||
/** Общий fixture-пароль. */
|
||||
password: string
|
||||
/** Роль, которую демонстрирует учётная запись. */
|
||||
role: AuthUserRole
|
||||
/** Краткое описание доступных сценариев. */
|
||||
description: string
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
import type { CartContextValue } from './types/cart.type'
|
||||
|
||||
/**
|
||||
* Внутренний React context application-scoped корзины.
|
||||
*/
|
||||
export const CartContext = createContext<CartContextValue | null>(null)
|
||||
@@ -1,133 +0,0 @@
|
||||
import type { Product } from '@/domains/catalog'
|
||||
|
||||
import type { CartLine, CartReconciliation, CartTotals } from './types/cart.type'
|
||||
|
||||
const MAX_CART_LINE_QUANTITY = 20
|
||||
|
||||
/**
|
||||
* Возвращает единый cart/order limit для конкретного продукта.
|
||||
*/
|
||||
export const getCartProductQuantityLimit = (product: Product): number => {
|
||||
return Math.min(product.stock, MAX_CART_LINE_QUANTITY)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ограничивает cart quantity текущим stock продукта.
|
||||
*/
|
||||
const clampQuantity = (product: Product, quantity: number): number => {
|
||||
if (!Number.isFinite(quantity)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(Math.floor(quantity), getCartProductQuantityLimit(product)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавляет продукт или увеличивает существующую строку корзины.
|
||||
*/
|
||||
export const addCartProduct = (lines: CartLine[], product: Product): CartLine[] => {
|
||||
const existingLine = lines.find((line) => line.product.id === product.id)
|
||||
|
||||
if (existingLine === undefined) {
|
||||
const quantity = clampQuantity(product, 1)
|
||||
|
||||
return quantity === 0 ? lines : [...lines, { product, quantity }]
|
||||
}
|
||||
|
||||
return lines.map((line) => {
|
||||
if (line.product.id !== product.id) {
|
||||
return line
|
||||
}
|
||||
|
||||
return {
|
||||
product,
|
||||
quantity: clampQuantity(product, line.quantity + 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Устанавливает количество строки и удаляет её при нулевом результате.
|
||||
*/
|
||||
export const setCartProductQuantity = (
|
||||
lines: CartLine[],
|
||||
productId: string,
|
||||
quantity: number
|
||||
): CartLine[] => {
|
||||
return lines.flatMap((line) => {
|
||||
if (line.product.id !== productId) {
|
||||
return [line]
|
||||
}
|
||||
|
||||
const nextQuantity = clampQuantity(line.product, quantity)
|
||||
|
||||
return nextQuantity === 0 ? [] : [{ ...line, quantity: nextQuantity }]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет одну продуктовую строку.
|
||||
*/
|
||||
export const removeCartProduct = (lines: CartLine[], productId: string): CartLine[] => {
|
||||
return lines.filter((line) => line.product.id !== productId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Заменяет product snapshots authority-значениями перед checkout.
|
||||
*/
|
||||
export const reconcileCartProducts = (
|
||||
lines: CartLine[],
|
||||
products: Product[]
|
||||
): CartReconciliation => {
|
||||
const productsById = new Map(products.map((product) => [product.id, product]))
|
||||
let hasChanges = false
|
||||
const nextLines = lines
|
||||
.map((line) => {
|
||||
const currentProduct = productsById.get(line.product.id)
|
||||
|
||||
if (currentProduct === undefined) {
|
||||
return line
|
||||
}
|
||||
|
||||
const hasProductChanged =
|
||||
currentProduct.version !== line.product.version ||
|
||||
currentProduct.priceCents !== line.product.priceCents ||
|
||||
currentProduct.currency !== line.product.currency ||
|
||||
currentProduct.stock !== line.product.stock
|
||||
|
||||
if (hasProductChanged) {
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
return {
|
||||
product: currentProduct,
|
||||
quantity: clampQuantity(currentProduct, line.quantity)
|
||||
}
|
||||
})
|
||||
.filter((line) => line.quantity > 0)
|
||||
|
||||
if (nextLines.length !== lines.length) {
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
return { lines: nextLines, hasChanges }
|
||||
}
|
||||
|
||||
/**
|
||||
* Рассчитывает item count, subtotal и допустимость checkout.
|
||||
*/
|
||||
export const calculateCartTotals = (lines: CartLine[]): CartTotals => {
|
||||
const itemCount = lines.reduce((sum, line) => sum + line.quantity, 0)
|
||||
const subtotalCents = lines.reduce(
|
||||
(sum, line) => sum + line.product.priceCents * line.quantity,
|
||||
0
|
||||
)
|
||||
const firstCurrency = lines[0]?.product.currency ?? null
|
||||
const hasSingleCurrency = lines.every((line) => line.product.currency === firstCurrency)
|
||||
|
||||
return {
|
||||
itemCount,
|
||||
subtotalCents,
|
||||
currency: hasSingleCurrency ? firstCurrency : null
|
||||
}
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
import { hasOwn, isArrayOf, isNumber, isOneOf, isRecord, isString } from '@/shared/lib/value-predicates'
|
||||
import {
|
||||
readJsonStorageResult,
|
||||
withBrowserStorageLock,
|
||||
writeJsonStorage
|
||||
} from '@/infra/browser-storage'
|
||||
|
||||
import { isSupportedProductImageUrl } from '@/domains/catalog'
|
||||
import type { Product } from '@/domains/catalog'
|
||||
|
||||
import { getCartProductQuantityLimit } from './cart.logic'
|
||||
import type { CartLine } from './types/cart.type'
|
||||
|
||||
const CURRENCIES = ['USD', 'EUR'] as const
|
||||
const CART_STORAGE_KEY = 'demo-frontend:cart'
|
||||
const CART_STORAGE_LOCK = 'demo-frontend:cart-lock'
|
||||
|
||||
/**
|
||||
* Создаёт revision, сохраняющую порядок обычных updates и forced recovery.
|
||||
*/
|
||||
const createCartRevision = (currentRevision = 0): number => {
|
||||
return Math.max(currentRevision + 1, Date.now() * 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted cart snapshot с cross-tab monotonic revision.
|
||||
*/
|
||||
export type PersistedCartSnapshot = {
|
||||
/** Монотонная revision, увеличиваемая под storage lock. */
|
||||
revision: number
|
||||
/** Валидированные product snapshots и quantities. */
|
||||
lines: CartLine[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Результат чтения persisted cart repository.
|
||||
*/
|
||||
export type PersistedCartReadResult =
|
||||
| {
|
||||
/** Repository доступен. */
|
||||
status: 'ready'
|
||||
/** Последний snapshot либо null до инициализации. */
|
||||
snapshot: PersistedCartSnapshot | null
|
||||
/** Требуется ли commit мигрированного legacy payload. */
|
||||
shouldCommit: boolean
|
||||
}
|
||||
| {
|
||||
/** Browser storage временно недоступен. */
|
||||
status: 'unavailable'
|
||||
}
|
||||
|
||||
/**
|
||||
* Результат atomic cart transition.
|
||||
*/
|
||||
export type PersistedCartUpdateResult =
|
||||
| {
|
||||
/** Snapshot записан с новой revision. */
|
||||
status: 'updated'
|
||||
/** Authority snapshot после transition. */
|
||||
snapshot: PersistedCartSnapshot
|
||||
}
|
||||
|
||||
| {
|
||||
/** Transform не изменил authority snapshot. */
|
||||
status: 'unchanged'
|
||||
/** Текущий authority snapshot. */
|
||||
snapshot: PersistedCartSnapshot
|
||||
}
|
||||
| {
|
||||
/** Expected revision больше не актуальна. */
|
||||
status: 'stale'
|
||||
}
|
||||
| {
|
||||
/** Repository transition не удалось подтвердить. */
|
||||
status: 'unavailable'
|
||||
}
|
||||
|
||||
/**
|
||||
* Результат проверки persisted cart revision.
|
||||
*/
|
||||
export type PersistedCartRevisionStatus = 'current' | 'stale' | 'unavailable'
|
||||
|
||||
/**
|
||||
* Проверяет persisted-снимок продукта перед восстановлением cart state.
|
||||
*/
|
||||
const isStoredProduct = (value: unknown): value is Product => {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
hasOwn(value, 'id') && isString(value.id) &&
|
||||
hasOwn(value, 'name') && isString(value.name) &&
|
||||
hasOwn(value, 'slug') && isString(value.slug) &&
|
||||
hasOwn(value, 'description') && isString(value.description) &&
|
||||
hasOwn(value, 'priceCents') && isNumber(value.priceCents) &&
|
||||
hasOwn(value, 'currency') && isOneOf(value.currency, CURRENCIES) &&
|
||||
hasOwn(value, 'categoryId') && isString(value.categoryId) &&
|
||||
hasOwn(value, 'stock') && isNumber(value.stock) &&
|
||||
hasOwn(value, 'rating') && isNumber(value.rating) &&
|
||||
hasOwn(value, 'imageUrl') && isString(value.imageUrl) && isSupportedProductImageUrl(value.imageUrl) &&
|
||||
hasOwn(value, 'createdAt') && isString(value.createdAt) &&
|
||||
hasOwn(value, 'version') && isNumber(value.version)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет persisted-строку корзины.
|
||||
*/
|
||||
const isStoredCartLine = (value: unknown): value is CartLine => {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
hasOwn(value, 'product') && isStoredProduct(value.product) &&
|
||||
hasOwn(value, 'quantity') && isNumber(value.quantity) &&
|
||||
Number.isInteger(value.quantity) &&
|
||||
value.quantity > 0 &&
|
||||
value.quantity <= getCartProductQuantityLimit(value.product)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Восстанавливает только полностью валидный persisted cart snapshot.
|
||||
*/
|
||||
export const hydrateCartLines = (value: unknown): CartLine[] => {
|
||||
return isArrayOf(value, isStoredCartLine) ? value : []
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет persisted snapshot envelope после чтения storage.
|
||||
*/
|
||||
const isPersistedCartSnapshot = (value: unknown): value is PersistedCartSnapshot => {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
hasOwn(value, 'revision') &&
|
||||
isNumber(value.revision) &&
|
||||
Number.isInteger(value.revision) &&
|
||||
value.revision >= 1 &&
|
||||
hasOwn(value, 'lines') &&
|
||||
isArrayOf(value.lines, isStoredCartLine)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Читает snapshot и мигрирует legacy lines-array в memory representation.
|
||||
*/
|
||||
export const readPersistedCartSnapshot = (): PersistedCartReadResult => {
|
||||
const result = readJsonStorageResult(CART_STORAGE_KEY)
|
||||
|
||||
if (result.status === 'unavailable' || result.status === 'invalid') {
|
||||
return { status: 'unavailable' }
|
||||
}
|
||||
|
||||
if (result.status === 'missing') {
|
||||
return { status: 'ready', snapshot: null, shouldCommit: true }
|
||||
}
|
||||
|
||||
if (isPersistedCartSnapshot(result.value)) {
|
||||
return { status: 'ready', snapshot: result.value, shouldCommit: false }
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'ready',
|
||||
snapshot: {
|
||||
revision: createCartRevision(),
|
||||
lines: hydrateCartLines(result.value)
|
||||
},
|
||||
shouldCommit: true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт или мигрирует persisted cart snapshot под cross-tab lock.
|
||||
*/
|
||||
export const initializePersistedCartSnapshot = async (): Promise<PersistedCartSnapshot | null> => {
|
||||
try {
|
||||
return await withBrowserStorageLock(CART_STORAGE_LOCK, () => {
|
||||
const result = readPersistedCartSnapshot()
|
||||
|
||||
if (result.status === 'unavailable') {
|
||||
return null
|
||||
}
|
||||
|
||||
const snapshot = result.snapshot ?? { revision: createCartRevision(), lines: [] }
|
||||
|
||||
if (result.shouldCommit && !writeJsonStorage(CART_STORAGE_KEY, snapshot)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return snapshot
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Перезаписывает unreadable cart безопасным пустым snapshot по явному действию пользователя.
|
||||
*/
|
||||
export const resetPersistedCartSnapshot = async (
|
||||
minimumRevision = 0
|
||||
): Promise<PersistedCartSnapshot | null> => {
|
||||
try {
|
||||
return await withBrowserStorageLock(CART_STORAGE_LOCK, () => {
|
||||
const snapshot: PersistedCartSnapshot = {
|
||||
revision: createCartRevision(minimumRevision),
|
||||
lines: []
|
||||
}
|
||||
|
||||
return writeJsonStorage(CART_STORAGE_KEY, snapshot) ? snapshot : null
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Применяет transform к последнему persisted snapshot под cross-tab lock.
|
||||
*/
|
||||
export const updatePersistedCartSnapshot = async (
|
||||
transform: (lines: CartLine[]) => CartLine[] | null,
|
||||
expectedRevision?: number
|
||||
): Promise<PersistedCartUpdateResult> => {
|
||||
try {
|
||||
return await withBrowserStorageLock(CART_STORAGE_LOCK, () => {
|
||||
const result = readPersistedCartSnapshot()
|
||||
|
||||
if (result.status === 'unavailable') {
|
||||
return { status: 'unavailable' }
|
||||
}
|
||||
|
||||
const currentSnapshot = result.snapshot ?? {
|
||||
revision: createCartRevision(),
|
||||
lines: []
|
||||
}
|
||||
|
||||
if (
|
||||
expectedRevision !== undefined &&
|
||||
currentSnapshot.revision !== expectedRevision
|
||||
) {
|
||||
return { status: 'stale' }
|
||||
}
|
||||
|
||||
const nextLines = transform(currentSnapshot.lines)
|
||||
|
||||
if (nextLines === null || nextLines === currentSnapshot.lines) {
|
||||
return { status: 'unchanged', snapshot: currentSnapshot }
|
||||
}
|
||||
|
||||
const nextSnapshot: PersistedCartSnapshot = {
|
||||
revision: createCartRevision(currentSnapshot.revision),
|
||||
lines: nextLines
|
||||
}
|
||||
|
||||
if (!writeJsonStorage(CART_STORAGE_KEY, nextSnapshot)) {
|
||||
return { status: 'unavailable' }
|
||||
}
|
||||
|
||||
return { status: 'updated', snapshot: nextSnapshot }
|
||||
})
|
||||
} catch {
|
||||
return { status: 'unavailable' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сверяет expected revision с persisted authority под тем же lock.
|
||||
*/
|
||||
export const isPersistedCartRevisionCurrent = async (
|
||||
expectedRevision: number
|
||||
): Promise<PersistedCartRevisionStatus> => {
|
||||
try {
|
||||
return await withBrowserStorageLock(CART_STORAGE_LOCK, () => {
|
||||
const result = readPersistedCartSnapshot()
|
||||
|
||||
if (result.status === 'unavailable') {
|
||||
return 'unavailable'
|
||||
}
|
||||
|
||||
return result.snapshot?.revision === expectedRevision ? 'current' : 'stale'
|
||||
})
|
||||
} catch {
|
||||
return 'unavailable'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Подписывает cart owner на committed snapshots других вкладок.
|
||||
*/
|
||||
export const subscribePersistedCartSnapshot = (
|
||||
listener: (result: PersistedCartReadResult) => void
|
||||
): (() => void) => {
|
||||
/**
|
||||
* Читает committed snapshot после cross-tab storage event.
|
||||
*/
|
||||
const handleStorage = (event: StorageEvent): void => {
|
||||
if (event.key === CART_STORAGE_KEY) {
|
||||
listener(readPersistedCartSnapshot())
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('storage', handleStorage)
|
||||
|
||||
return () => window.removeEventListener('storage', handleStorage)
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { CartContext } from './cart.context'
|
||||
import {
|
||||
addCartProduct,
|
||||
calculateCartTotals,
|
||||
reconcileCartProducts,
|
||||
removeCartProduct,
|
||||
setCartProductQuantity
|
||||
} from './cart.logic'
|
||||
import {
|
||||
initializePersistedCartSnapshot,
|
||||
isPersistedCartRevisionCurrent,
|
||||
resetPersistedCartSnapshot,
|
||||
subscribePersistedCartSnapshot,
|
||||
updatePersistedCartSnapshot
|
||||
} from './cart.persistence'
|
||||
import type { PersistedCartSnapshot } from './cart.persistence'
|
||||
import type { CartProviderProps } from './types/cart-provider-props.type'
|
||||
import type { CartContextValue, CartLine, CartReconciliationStatus } from './types/cart.type'
|
||||
import type { Product } from '@/domains/catalog'
|
||||
|
||||
const CART_STORAGE_ERROR = {
|
||||
code: 'storage-unavailable',
|
||||
message: 'Cart storage is unavailable. Changes are paused to prevent cross-tab data loss.'
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Владеет корзиной в application scope и сохраняет её между перезагрузками.
|
||||
*
|
||||
* Используется для:
|
||||
* - координации каталога и checkout composition
|
||||
* - восстановления валидного cart snapshot из browser storage
|
||||
*/
|
||||
export const CartProvider = (props: CartProviderProps) => {
|
||||
const { children } = props
|
||||
const [cartState, setCartState] = useState({
|
||||
lines: [] as CartLine[],
|
||||
revision: 0
|
||||
})
|
||||
const cartStateRef = useRef(cartState)
|
||||
const [isHydrated, setIsHydrated] = useState(false)
|
||||
const [error, setError] = useState<CartContextValue['error']>(null)
|
||||
const lines = cartState.lines
|
||||
|
||||
/**
|
||||
* Публикует только текущий или более новый persisted cart snapshot.
|
||||
*/
|
||||
const publishSnapshot = (snapshot: PersistedCartSnapshot): void => {
|
||||
if (snapshot.revision < cartStateRef.current.revision) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextState = {
|
||||
lines: snapshot.lines,
|
||||
revision: snapshot.revision
|
||||
}
|
||||
|
||||
cartStateRef.current = nextState
|
||||
setCartState(nextState)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true
|
||||
|
||||
void initializePersistedCartSnapshot().then((snapshot) => {
|
||||
if (isActive) {
|
||||
if (snapshot !== null) {
|
||||
publishSnapshot(snapshot)
|
||||
setError(null)
|
||||
} else {
|
||||
setError(CART_STORAGE_ERROR)
|
||||
}
|
||||
|
||||
setIsHydrated(true)
|
||||
}
|
||||
})
|
||||
|
||||
const unsubscribe = subscribePersistedCartSnapshot((result) => {
|
||||
if (isActive && result.status === 'ready' && result.snapshot !== null) {
|
||||
publishSnapshot(result.snapshot)
|
||||
setError(null)
|
||||
} else if (isActive && result.status === 'unavailable') {
|
||||
setError(CART_STORAGE_ERROR)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
isActive = false
|
||||
unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Применяет cart transform к последнему cross-tab authority snapshot.
|
||||
*/
|
||||
const updateLines = async (
|
||||
transform: (currentLines: CartLine[]) => CartLine[]
|
||||
): Promise<void> => {
|
||||
const result = await updatePersistedCartSnapshot(transform)
|
||||
|
||||
if (result.status === 'updated' || result.status === 'unchanged') {
|
||||
publishSnapshot(result.snapshot)
|
||||
setError(null)
|
||||
} else if (result.status === 'unavailable') {
|
||||
setError(CART_STORAGE_ERROR)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавляет одну доступную единицу продукта.
|
||||
*/
|
||||
const addProduct = async (product: Product): Promise<void> => {
|
||||
await updateLines((currentLines) => addCartProduct(currentLines, product))
|
||||
}
|
||||
|
||||
/**
|
||||
* Изменяет количество выбранного продукта.
|
||||
*/
|
||||
const setQuantity = async (productId: string, quantity: number): Promise<void> => {
|
||||
await updateLines((currentLines) => {
|
||||
return setCartProductQuantity(currentLines, productId, quantity)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет продукт из корзины.
|
||||
*/
|
||||
const removeProduct = async (productId: string): Promise<void> => {
|
||||
await updateLines((currentLines) => removeCartProduct(currentLines, productId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет stale snapshots перед checkout и сообщает о расхождении.
|
||||
*/
|
||||
const reconcileProducts = (
|
||||
products: Product[],
|
||||
expectedRevision: number
|
||||
): Promise<CartReconciliationStatus> => {
|
||||
return updatePersistedCartSnapshot((currentLines) => {
|
||||
const reconciliation = reconcileCartProducts(currentLines, products)
|
||||
|
||||
return reconciliation.hasChanges ? reconciliation.lines : null
|
||||
}, expectedRevision).then((result) => {
|
||||
if (result.status === 'unavailable') {
|
||||
setError(CART_STORAGE_ERROR)
|
||||
return 'unavailable'
|
||||
}
|
||||
|
||||
if (result.status === 'stale') {
|
||||
return 'stale'
|
||||
}
|
||||
|
||||
publishSnapshot(result.snapshot)
|
||||
setError(null)
|
||||
|
||||
return result.status === 'updated' ? 'updated' : 'unchanged'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет revision перед необратимым продолжением async checkout.
|
||||
*/
|
||||
const isCurrentRevision: CartContextValue['isCurrentRevision'] = (revision) => {
|
||||
return isPersistedCartRevisionCurrent(revision).then((status) => {
|
||||
if (status === 'unavailable') {
|
||||
setError(CART_STORAGE_ERROR)
|
||||
}
|
||||
|
||||
return status
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Завершает checkout очисткой всех строк.
|
||||
*/
|
||||
const clearCart: CartContextValue['clearCart'] = async (expectedRevision) => {
|
||||
const result = await updatePersistedCartSnapshot(() => [], expectedRevision)
|
||||
|
||||
if (result.status === 'unavailable') {
|
||||
if (expectedRevision === undefined) {
|
||||
const recoveredSnapshot = await resetPersistedCartSnapshot(
|
||||
cartStateRef.current.revision
|
||||
)
|
||||
|
||||
if (recoveredSnapshot !== null) {
|
||||
publishSnapshot(recoveredSnapshot)
|
||||
setError(null)
|
||||
return 'cleared'
|
||||
}
|
||||
}
|
||||
|
||||
setError(CART_STORAGE_ERROR)
|
||||
return 'unavailable'
|
||||
}
|
||||
|
||||
if (result.status === 'stale') {
|
||||
return 'stale'
|
||||
}
|
||||
|
||||
publishSnapshot(result.snapshot)
|
||||
setError(null)
|
||||
|
||||
return 'cleared'
|
||||
}
|
||||
|
||||
const totals = calculateCartTotals(lines)
|
||||
const value: CartContextValue = {
|
||||
...totals,
|
||||
lines,
|
||||
isHydrated,
|
||||
revision: cartState.revision,
|
||||
error,
|
||||
addProduct,
|
||||
setQuantity,
|
||||
removeProduct,
|
||||
reconcileProducts,
|
||||
isCurrentRevision,
|
||||
clearCart
|
||||
}
|
||||
|
||||
return <CartContext.Provider value={value}>{children}</CartContext.Provider>
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useContext } from 'react'
|
||||
|
||||
import { CartContext } from '../cart.context'
|
||||
import type { CartContextValue } from '../types/cart.type'
|
||||
|
||||
/**
|
||||
* Возвращает application-scoped API корзины.
|
||||
*/
|
||||
export const useCart = (): CartContextValue => {
|
||||
const context = useContext(CartContext)
|
||||
|
||||
if (context === null) {
|
||||
throw new Error('useCart must be used inside CartProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
'use client'
|
||||
|
||||
export { CartProvider } from './cart.provider'
|
||||
export { getCartProductQuantityLimit } from './cart.logic'
|
||||
export { useCart } from './hooks/use-cart.hook'
|
||||
export type { CartProviderProps } from './types/cart-provider-props.type'
|
||||
export type {
|
||||
CartContextValue,
|
||||
CartClearStatus,
|
||||
CartError,
|
||||
CartLine,
|
||||
CartReconciliation,
|
||||
CartRevisionStatus,
|
||||
CartTotals
|
||||
} from './types/cart.type'
|
||||
@@ -1,74 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { Product } from '@/domains/catalog'
|
||||
|
||||
import {
|
||||
addCartProduct,
|
||||
calculateCartTotals,
|
||||
reconcileCartProducts,
|
||||
setCartProductQuantity
|
||||
} from '../cart.logic'
|
||||
|
||||
const PRODUCT: Product = {
|
||||
id: 'product-test',
|
||||
name: 'Test object',
|
||||
slug: 'test-object',
|
||||
description: 'A deterministic product for cart owner tests.',
|
||||
priceCents: 2500,
|
||||
currency: 'USD',
|
||||
categoryId: 'category-test',
|
||||
stock: 2,
|
||||
rating: 4.5,
|
||||
imageUrl: 'https://picsum.photos/seed/test/640/480',
|
||||
createdAt: '2026-07-20T10:00:00.000Z',
|
||||
version: 1
|
||||
}
|
||||
|
||||
describe('cart domain logic', () => {
|
||||
it('caps quantity at the current product stock', () => {
|
||||
const once = addCartProduct([], PRODUCT)
|
||||
const twice = addCartProduct(once, PRODUCT)
|
||||
const threeAttempts = addCartProduct(twice, PRODUCT)
|
||||
|
||||
expect(threeAttempts).toEqual([{ product: PRODUCT, quantity: 2 }])
|
||||
})
|
||||
|
||||
it('caps high-stock products at the order line contract limit', () => {
|
||||
const highStockProduct = { ...PRODUCT, stock: 100 }
|
||||
const lines = addCartProduct([], highStockProduct)
|
||||
|
||||
expect(setCartProductQuantity(lines, highStockProduct.id, 21)).toEqual([
|
||||
{ product: highStockProduct, quantity: 20 }
|
||||
])
|
||||
})
|
||||
|
||||
it('removes a line when quantity becomes zero', () => {
|
||||
const lines = addCartProduct([], PRODUCT)
|
||||
|
||||
expect(setCartProductQuantity(lines, PRODUCT.id, 0)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects checkout for mixed currencies while retaining item totals', () => {
|
||||
const eurProduct: Product = { ...PRODUCT, id: 'product-eur', currency: 'EUR' }
|
||||
const totals = calculateCartTotals([
|
||||
{ product: PRODUCT, quantity: 1 },
|
||||
{ product: eurProduct, quantity: 1 }
|
||||
])
|
||||
|
||||
expect(totals).toEqual({
|
||||
itemCount: 2,
|
||||
subtotalCents: 5000,
|
||||
currency: null
|
||||
})
|
||||
})
|
||||
|
||||
it('reconciles a stale price and version before checkout', () => {
|
||||
const lines = [{ product: PRODUCT, quantity: 1 }]
|
||||
const latestProduct = { ...PRODUCT, priceCents: 3100, version: 2 }
|
||||
|
||||
expect(reconcileCartProducts(lines, [latestProduct])).toEqual({
|
||||
lines: [{ product: latestProduct, quantity: 1 }],
|
||||
hasChanges: true
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,140 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
hydrateCartLines,
|
||||
initializePersistedCartSnapshot,
|
||||
readPersistedCartSnapshot,
|
||||
resetPersistedCartSnapshot,
|
||||
updatePersistedCartSnapshot
|
||||
} from '../cart.persistence'
|
||||
|
||||
const STORED_LINE = {
|
||||
product: {
|
||||
id: 'product-test',
|
||||
name: 'Test object',
|
||||
slug: 'test-object',
|
||||
description: 'Persisted cart fixture.',
|
||||
priceCents: 2500,
|
||||
currency: 'USD' as const,
|
||||
categoryId: 'category-test',
|
||||
stock: 2,
|
||||
rating: 4.5,
|
||||
imageUrl: 'https://picsum.photos/seed/test/640/480',
|
||||
createdAt: '2026-07-20T10:00:00.000Z',
|
||||
version: 1
|
||||
},
|
||||
quantity: 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт localStorage для persisted cart CAS tests.
|
||||
*/
|
||||
const createStorage = (): Storage => {
|
||||
const values = new Map<string, string>()
|
||||
|
||||
return {
|
||||
get length() {
|
||||
return values.size
|
||||
},
|
||||
clear: () => values.clear(),
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => [...values.keys()][index] ?? null,
|
||||
removeItem: (key) => values.delete(key),
|
||||
setItem: (key, value) => values.set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: createStorage(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('cart persistence boundary', () => {
|
||||
it('rejects a persisted product image outside the configured host', () => {
|
||||
const storedLines = [
|
||||
{
|
||||
...STORED_LINE,
|
||||
product: {
|
||||
...STORED_LINE.product,
|
||||
imageUrl: 'https://example.com/untrusted.png'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
expect(hydrateCartLines(storedLines)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a legacy quantity above the order line limit', () => {
|
||||
const storedLines = [
|
||||
{
|
||||
...STORED_LINE,
|
||||
product: { ...STORED_LINE.product, stock: 100 },
|
||||
quantity: 21
|
||||
}
|
||||
]
|
||||
|
||||
expect(hydrateCartLines(storedLines)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a fractional persisted quantity', () => {
|
||||
expect(hydrateCartLines([{ ...STORED_LINE, quantity: 1.5 }])).toEqual([])
|
||||
})
|
||||
|
||||
it('does not clear a newer cross-tab snapshot before its storage event arrives', async () => {
|
||||
const initialSnapshot = await initializePersistedCartSnapshot()
|
||||
|
||||
expect(initialSnapshot).not.toBeNull()
|
||||
|
||||
if (initialSnapshot === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const checkoutSnapshot = await updatePersistedCartSnapshot(() => [STORED_LINE])
|
||||
|
||||
expect(checkoutSnapshot.status).toBe('updated')
|
||||
|
||||
if (checkoutSnapshot.status !== 'updated') {
|
||||
return
|
||||
}
|
||||
|
||||
await updatePersistedCartSnapshot((lines) => {
|
||||
const line = lines[0]
|
||||
|
||||
return line === undefined ? lines : [{ ...line, quantity: 2 }]
|
||||
})
|
||||
|
||||
const staleClear = await updatePersistedCartSnapshot(
|
||||
() => [],
|
||||
checkoutSnapshot.snapshot.revision
|
||||
)
|
||||
const current = readPersistedCartSnapshot()
|
||||
|
||||
expect(staleClear).toEqual({ status: 'stale' })
|
||||
expect(current.status === 'ready' ? current.snapshot?.lines[0]?.quantity : null).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps forced recovery above the local revision when the clock moves backwards', async () => {
|
||||
const currentSnapshot = await initializePersistedCartSnapshot()
|
||||
|
||||
expect(currentSnapshot).not.toBeNull()
|
||||
|
||||
if (currentSnapshot === null) {
|
||||
return
|
||||
}
|
||||
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1)
|
||||
window.localStorage.setItem('demo-frontend:cart', '{broken-json')
|
||||
|
||||
const recoveredSnapshot = await resetPersistedCartSnapshot(currentSnapshot.revision)
|
||||
|
||||
expect(recoveredSnapshot?.revision).toBeGreaterThan(currentSnapshot.revision)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user