mirror of
https://github.com/gromlab-ru/slm-design.git
synced 2026-08-22 07:30:16 +03:00
chore: demo app
This commit is contained in:
246
.opencode/skills/slm-design/SKILL.md
Normal file
246
.opencode/skills/slm-design/SKILL.md
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
---
|
||||||
|
name: slm-design
|
||||||
|
description: "Экспертная работа с архитектурой SLM Design: проектирование, изменение, миграция и ревью слоёв app/compositions/domains/infra/ui/shared, модулей, доменов, публичных фасетов index/client/browser/server, групп, сегментов, вложенных модулей, зависимостей, состояния и lifecycle. Триггеры: SLM, Scoped Layered Module Design, SLM root, ответственность, владелец, модульная граница, domains vs compositions, доменный контракт, DTO, глубокий импорт, модульный цикл, архитектурное ревью. НЕ применять для обычного code style или локальной правки, не затрагивающей архитектурное решение."
|
||||||
|
---
|
||||||
|
|
||||||
|
# SLM Design
|
||||||
|
|
||||||
|
Работай как архитектор SLM, а не как генератор заранее заданного дерева каталогов. Сначала устанавливай ответственность и владельца, затем выражай решение слоями, публичными границами и зависимостями. Пути, имена, framework-роли и размер кода не заменяют смысловое решение.
|
||||||
|
|
||||||
|
## Источники истины
|
||||||
|
|
||||||
|
Весь нормативный и поясняющий материал находится в `reference/docs`. Не воспроизводи правила по памяти, если от точности формулировки зависит решение.
|
||||||
|
|
||||||
|
Материалы выполняют разные нормативные роли:
|
||||||
|
|
||||||
|
1. [`rules/registry.md`](./reference/docs/rules/registry.md) содержит единственные точные блокирующие правила.
|
||||||
|
2. [`reference/terminology.md`](./reference/docs/reference/terminology.md) задаёт нормативный смысл терминов.
|
||||||
|
3. [`architecture/layers.md`](./reference/docs/architecture/layers.md) и [`architecture/dependencies.md`](./reference/docs/architecture/dependencies.md) задают роли слоёв и матрицу направлений.
|
||||||
|
4. Остальные главы `architecture` объясняют модель и способы проектирования.
|
||||||
|
5. [`reference/validation.md`](./reference/docs/reference/validation.md) задаёт процедуру проверки и критерий завершения.
|
||||||
|
6. [`README.md`](./reference/docs/README.md) даёт обзор, мотивацию и навигацию.
|
||||||
|
|
||||||
|
Не превращай рекомендацию или пример в правило. При обязательном вердикте указывай существующий код из реестра. Если требование относится к локальному стайлгайду, lint-конфигурации, framework или продуктовой policy, называй его проектным ограничением, а не правилом SLM. Если реестр, определение или нормативная матрица действительно противоречат друг другу, не выбирай победителя молча: останови обязательный вывод и зафиксируй противоречие документации.
|
||||||
|
|
||||||
|
## Рабочий режим
|
||||||
|
|
||||||
|
1. Определи тип задачи: проектирование, реализация, изменение существующей границы, миграция, ревью или объяснение.
|
||||||
|
2. Исследуй фактический код, импорты и локальные архитектурные соглашения. Не делай вывод о сущности только по имени каталога.
|
||||||
|
3. Открой базовую модель и только относящиеся к задаче references по [карте файлов](#карта-файлов).
|
||||||
|
4. Зафиксируй наблюдаемые факты отдельно от архитектурных выводов.
|
||||||
|
5. Для проектирования, структурного изменения или миграции составь карточку решения по [`reference/validation.md`](./reference/docs/reference/validation.md#карточка-решения).
|
||||||
|
6. Для ревью используй review-checklists и реестр; для объяснения открывай только тематические references и не требуй карточку решения.
|
||||||
|
7. Если задача предполагает изменение кода, спроектируй минимальное решение, которое оставляет одного владельца, закрытый внутренний код и ацикличный модульный граф.
|
||||||
|
8. Редактируй код только для задачи реализации, изменения или миграции; ревью, проектирование и объяснение заверши соответствующим отчётом без самовольных правок.
|
||||||
|
9. Выполни применимые проверки: для ревью - доказательства findings, для реализации - смысловую, структурную и функциональную валидацию.
|
||||||
|
10. В результате сообщи принятое решение, затронутые границы, применимые правила, выполненные проверки и оставшиеся риски.
|
||||||
|
|
||||||
|
Если задача локальна и не меняет ответственность, публичный API, зависимость, состояние, lifecycle или физическую модульную границу, не инициируй архитектурный рефакторинг без отдельной причины.
|
||||||
|
|
||||||
|
## Сбор контекста
|
||||||
|
|
||||||
|
До проектирования установи:
|
||||||
|
|
||||||
|
- границу SLM root и локальное сопоставление путей со слоями, группами, модулями, сегментами и фасетами;
|
||||||
|
- для каждого изменяемого файла - модульного владельца либо подтверждённый статус точки входа `app`, немодульного ресурса `shared` или кода вне SLM root;
|
||||||
|
- существующие публичные фасеты и реальные внешние импорты модуля;
|
||||||
|
- потребителей изменяемого поведения и среды, в которых они выполняются;
|
||||||
|
- межмодульные связи, включая `import type` и реэкспорты;
|
||||||
|
- владельца изменяемого состояния, источник истины и область жизни ресурсов;
|
||||||
|
- для продуктовых данных - доменный контракт, контракт источника и место адаптации;
|
||||||
|
- локальные lint-правила, alias-настройки, test/build-команды и дополнительные project policies.
|
||||||
|
|
||||||
|
Проверяй историю или соседние модули только как свидетельство принятой локальной policy. Существующий код может быть legacy и не является доказательством нормы SLM.
|
||||||
|
|
||||||
|
Если проект не объявляет физическое сопоставление SLM-сущностей, выведи рабочую гипотезу из структуры и конфигурации и явно обозначь её. Гипотеза подходит для проектирования и адресных вопросов, но не доказывает нарушение класса `A`. До блокирующего структурного finding подтверди mapping конфигурацией проекта или однозначно установленными модульными границами.
|
||||||
|
|
||||||
|
## Проектирование
|
||||||
|
|
||||||
|
Двигайся от смысла к структуре:
|
||||||
|
|
||||||
|
1. Сформулируй один изменяемый результат или поведение без названий файлов, папок, библиотек и паттернов.
|
||||||
|
2. Определи, является ли поведение доменным сценарием.
|
||||||
|
3. Найди существующего владельца или обоснуй новую самостоятельную ответственность.
|
||||||
|
4. Зафиксируй, что владелец делает сам и какие готовые возможности получает от других модулей.
|
||||||
|
5. Назови реальных внешних потребителей.
|
||||||
|
6. Выбери слой по роли ответственности.
|
||||||
|
7. Спроектируй минимальный публичный API и только необходимые фасеты сред выполнения.
|
||||||
|
8. Построй impact map межмодульных рёбер и проверь публичные пути, матрицу слоёв и ацикличность.
|
||||||
|
9. Назначь владельца состоянию и каждому lifecycle-ресурсу.
|
||||||
|
10. Только после этого выбери папку модуля, главный файл, сегменты, группы или вложенные модули.
|
||||||
|
|
||||||
|
Для каждого спорного вывода используй цепочку:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Факт в коде -> ближайший владелец -> архитектурный смысл -> решение -> reference или код правила
|
||||||
|
```
|
||||||
|
|
||||||
|
### Выбор структурной сущности
|
||||||
|
|
||||||
|
Открой [`architecture/modules.md`](./reference/docs/architecture/modules.md), [`architecture/segments.md`](./reference/docs/architecture/segments.md) и при необходимости [`architecture/groups.md`](./reference/docs/architecture/groups.md). По таблицам и критериям этих глав последовательно установи:
|
||||||
|
|
||||||
|
1. Продолжает ли код существующий результат или вводит отдельно формулируемую ответственность.
|
||||||
|
2. Достаточны ли колокация или сегмент, либо нужен новый владелец.
|
||||||
|
3. Является ли новый владелец внутренней подответственностью родителя или общим модулем для внешних потребителей.
|
||||||
|
4. Нужна ли только навигационная группа без реализации и API.
|
||||||
|
|
||||||
|
Зафиксируй решение о владельце до выбора пути. Количество файлов, props, Context, Provider, store, hook или lifecycle-код сами по себе не выбирают структурную сущность.
|
||||||
|
|
||||||
|
### Выбор слоя
|
||||||
|
|
||||||
|
Открой таблицу ролей и границу доменов/композиций в [`architecture/layers.md`](./reference/docs/architecture/layers.md). Сопоставь одно предложение об ответственности с нормативной ролью слоя. Отдельно проверь немодульные исключения `app` и `shared`, прямой доступ композиции к HTTP, SDK или storage и координацию нескольких доменов. Технический механизм и разрешённое направление импорта не доказывают правильность владельца.
|
||||||
|
|
||||||
|
### Проектирование домена
|
||||||
|
|
||||||
|
Для любого создаваемого или изменяемого доменного сценария открой [`architecture/domains.md`](./reference/docs/architecture/domains.md) до проектирования интеграции.
|
||||||
|
|
||||||
|
Следуй порядку из раздела [«Порядок создания домена»](./reference/docs/architecture/domains.md#порядок-создания-домена). Результатом проектирования должны стать четыре явных артефакта: предметный контракт, карта ожидаемых исходов и defects, план адаптации source boundary и список реально нужных публичных runtime-capabilities.
|
||||||
|
|
||||||
|
Не начинай контракт с endpoint, SDK, DTO или формы ответа. Публично экспортируй guard, parser, schema, constructor или другой механизм runtime-идентификации доменной ошибки только для доказанного потребителя и подходящей среды. Внутреннюю валидацию недоверенных данных и адаптацию источника оценивай отдельно: им не нужен внешний потребитель.
|
||||||
|
|
||||||
|
### Проектирование API и фасетов
|
||||||
|
|
||||||
|
Открой разделы о публичном API и фасетах в [`architecture/modules.md`](./reference/docs/architecture/modules.md#публичный-api), затем проверь executable-граф по [`reference/validation.md`](./reference/docs/reference/validation.md#проверка-фасетов).
|
||||||
|
|
||||||
|
Составь consumer/environment map: какая capability нужна какому внешнему потребителю и в какой среде. По ней выбери минимально подходящие фасеты, затем проверь весь транзитивный executable-граф. Не открывай внутренние механизмы про запас и отдельно проверь browser-only и server-only пути.
|
||||||
|
|
||||||
|
### Проектирование зависимостей
|
||||||
|
|
||||||
|
Для каждого нового или изменённого импорта открой [`architecture/dependencies.md`](./reference/docs/architecture/dependencies.md).
|
||||||
|
|
||||||
|
1. Классифицируй обе стороны как модуль, точку входа `app`, немодульный ресурс `shared` или код вне текущего SLM root.
|
||||||
|
2. Если оба файла принадлежат одному модулю, считай связь внутренней реализацией.
|
||||||
|
3. Если оба файла принадлежат разным модулям, проверь фасет, направление слоёв и добавь ребро в свёрнутый граф; вложенный модуль является отдельным узлом.
|
||||||
|
4. Для точки входа `app` или немодульного ресурса `shared` сначала повторно проверь право исходной единицы оставаться немодульной после изменения. Если критерии исключения сохранены, применяй относящиеся к ней правила слоя и публичной границы цели; иначе спроектируй модульного владельца.
|
||||||
|
5. Внешний package или код за пределами SLM root не становится узлом внутреннего модульного графа. Проверь его влияние на ответственность и среду исходной архитектурной единицы, которой может быть модуль, точка входа `app` или ресурс `shared`, а также на project policy.
|
||||||
|
6. Проверь весь свёрнутый граф на цикл, а не только пути между конкретными файлами.
|
||||||
|
7. Отдельно проверь смысл связи: формально разрешённый импорт не должен скрывать неверное владение.
|
||||||
|
|
||||||
|
## Реализация изменений
|
||||||
|
|
||||||
|
После принятия решения:
|
||||||
|
|
||||||
|
- изменяй самую узкую достаточную область и сохраняй принятые соглашения проекта;
|
||||||
|
- создавай модульную папку только для уже обоснованного владельца;
|
||||||
|
- добавляй обязательную публичную точку входа и специализированные фасеты только по фактической потребности;
|
||||||
|
- оставляй детали реализации закрытыми и размещай их по правилам корня, сегментов и компонентных единиц;
|
||||||
|
- не создавай группы, сегменты, вложенные модули, guards, factories или runtime schemas про запас;
|
||||||
|
- перенос доменного поведения выполняй вместе с его контрактом, состоянием, UI и интерпретацией ошибок, не оставляя второго владельца;
|
||||||
|
- при изменении источника сохраняй доменный контракт, пока продуктовый смысл не требует отдельного изменения;
|
||||||
|
- переключай потребителей на публичный API согласованно с переносом, затем удаляй ставшие недоступными глубокие пути;
|
||||||
|
- обновляй тесты на контракт и поведение владельца, а интеграционную адаптацию проверяй отдельно от доменных сценариев;
|
||||||
|
- не исправляй структурный симптом новым barrel или реэкспортом, если проблема находится в ответственности или положении владельца.
|
||||||
|
|
||||||
|
Если реализация обнаружила новый продуктовый смысл, внешнего потребителя или lifecycle, которого не было в карточке решения, останови механическое редактирование и пересмотри архитектурное решение.
|
||||||
|
|
||||||
|
## Архитектурное ревью
|
||||||
|
|
||||||
|
Перед вердиктом открой [`rules/registry.md`](./reference/docs/rules/registry.md), [`reference/validation.md`](./reference/docs/reference/validation.md) и тематическую главу. Проверяй отдельно:
|
||||||
|
|
||||||
|
- смысл: ответственность, единственного владельца, слой, доменный контракт, состояние и lifecycle;
|
||||||
|
- структуру: модульные корни, фасеты, глубокие импорты, вложенные модули, внутреннюю глубину и свёрнутый граф;
|
||||||
|
- поведение изменения: не появился ли новый публичный контракт, источник истины или скрытая междоменная координация.
|
||||||
|
|
||||||
|
Оформляй подтверждённое замечание так:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[Серьёзность] SLM-<код> (<A или R>)
|
||||||
|
Доказательства: path:line, другие рёбра или отсутствующий обязательный артефакт.
|
||||||
|
Факт: что наблюдается в коде.
|
||||||
|
Нарушение: почему факт противоречит точной формулировке правила.
|
||||||
|
Исправление: какая ответственность, граница или связь должна измениться.
|
||||||
|
```
|
||||||
|
|
||||||
|
Правила ревью:
|
||||||
|
|
||||||
|
- findings идут первыми и сортируются по риску;
|
||||||
|
- один finding описывает один нарушенный инвариант;
|
||||||
|
- код правила берётся только из реестра, без выдуманных номеров;
|
||||||
|
- серьёзность следует принятой шкале проекта; если её нет, используй `high`, `medium`, `low` только как оценку влияния, а не как часть SLM;
|
||||||
|
- `A`/`R` обозначает способ окончательной проверки, а не серьёзность;
|
||||||
|
- класс `A` подтверждается структурным фактом при доказанном path mapping, класс `R` требует смыслового обоснования;
|
||||||
|
- для цикла покажи замкнутую последовательность модулей и location каждого ребра; для отсутствующего фасета или файла назови ожидаемый путь и доказательство модульной границы;
|
||||||
|
- сигнал вроде `fetch`, Provider, большого файла или локального `index.ts` не является нарушением без проверки владельца;
|
||||||
|
- рекомендация и project policy маркируются отдельно и не выдаются за блокирующее правило;
|
||||||
|
- если продуктового контекста недостаточно, формулируй адресный вопрос или риск, а не категоричный finding;
|
||||||
|
- при отсутствии findings сообщи это явно и перечисли только непроверенные области или ограничения проверки.
|
||||||
|
|
||||||
|
Не ограничивай ревью изменёнными строками, если новая связь меняет публичный API, транзитивную среду фасета или модульный цикл.
|
||||||
|
|
||||||
|
## Миграция
|
||||||
|
|
||||||
|
Мигрируй небольшими связными срезами, каждый из которых оставляет понятного владельца и рабочий публичный контракт:
|
||||||
|
|
||||||
|
1. Инвентаризируй фактические ответственности, внешних потребителей и текущие межмодульные рёбра выбранного участка.
|
||||||
|
2. Составь целевую карточку решения, не начиная с желаемого дерева папок.
|
||||||
|
3. Объяви целевой публичный контракт; для домена до интеграции также зафиксируй предметный контракт, ожидаемые исходы и границу defects.
|
||||||
|
4. Создай или скорректируй границу владельца; для домена добавь внутреннюю адаптацию источников и ошибок.
|
||||||
|
5. Перенеси поведение, состояние, доменный UI и lifecycle целиком, не создавая параллельного владельца.
|
||||||
|
6. Переключи потребителей на фасеты и удаляй глубокие импорты.
|
||||||
|
7. Пересчитай свёрнутый граф, проверь среды фасетов и очистку ресурсов.
|
||||||
|
8. Удали legacy-путь после перехода всех реальных потребителей.
|
||||||
|
9. Повтори процесс для следующего независимого среза.
|
||||||
|
|
||||||
|
Не используй `compositions` как временного владельца нового доменного сценария. Если промежуточное состояние ещё нарушает правило, не называй его завершённой SLM-миграцией и явно фиксируй ограничение.
|
||||||
|
|
||||||
|
## Проверка результата
|
||||||
|
|
||||||
|
Перед завершением открой полный [критерий завершения](./reference/docs/reference/validation.md#критерий-завершения) и проверь только применимые пункты. Сохрани доказательства по смысловым решениям, доменной границе, структуре, средам выполнения и проектным test/lint/build-командам, не копируя checklist в отчёт.
|
||||||
|
|
||||||
|
Успешная сборка не заменяет смысловую проверку. Если автоматического SLM lint нет, выполни структурную проверку вручную и перечисли проверенные модули, фасеты и рёбра. Не утверждай прохождение проверки, которую фактически не запускал или не мог выполнить.
|
||||||
|
|
||||||
|
## Stop conditions
|
||||||
|
|
||||||
|
Сначала ищи ответ в коде, конфигурации и references. Задавай пользователю адресный вопрос только когда решение зависит от отсутствующего продуктового или эксплуатационного факта:
|
||||||
|
|
||||||
|
- результат поведения нельзя однозначно сформулировать;
|
||||||
|
- подходят несколько владельцев, а предметная граница не следует из кода;
|
||||||
|
- неизвестно, является ли координация техническим связыванием или новым доменным сценарием;
|
||||||
|
- ожидаемые неуспешные исходы и граница programming defect не определены продуктом;
|
||||||
|
- неизвестны реальные внешние потребители или требуемая среда выполнения;
|
||||||
|
- неизвестны область жизни, число экземпляров или момент очистки ресурса;
|
||||||
|
- локальное сопоставление путей с SLM-сущностями нельзя подтвердить, а задача требует блокирующего структурного вердикта.
|
||||||
|
|
||||||
|
В вопросе укажи наблюдаемый факт, архитектурное последствие и конкретные варианты выбора. Не проси пользователя решать то, что можно установить поиском по репозиторию.
|
||||||
|
|
||||||
|
## Формат результата
|
||||||
|
|
||||||
|
Для проектирования, реализации или миграции сообщай:
|
||||||
|
|
||||||
|
- ответственность, владельца и выбранный слой;
|
||||||
|
- изменённые публичные границы и межмодульные связи;
|
||||||
|
- существенные решения по доменному контракту, состоянию и lifecycle;
|
||||||
|
- какие references и правила повлияли на решение;
|
||||||
|
- выполненные проверки и непроверенные риски.
|
||||||
|
|
||||||
|
Для чистого проектирования вместо списка изменённых файлов дай целевую physical form, consumer/environment map, impact map и нерешённые продуктовые факты.
|
||||||
|
|
||||||
|
Для объяснения отделяй определения и правила SLM от рекомендаций и project policy. Для ревью используй формат findings из раздела выше.
|
||||||
|
|
||||||
|
## Карта файлов
|
||||||
|
|
||||||
|
References являются частью собранного skill. Карта покрывает весь комплект материалов; открывай минимальный набор для текущей задачи, но перед блокирующим вердиктом всегда сверяй точную формулировку с реестром.
|
||||||
|
|
||||||
|
| Файл | Что содержит | Когда открывать |
|
||||||
|
|---|---|---|
|
||||||
|
| [`reference/docs/README.md`](./reference/docs/README.md) | Обзор SLM, мотивация, область вопросов и стартовая навигация | Первое знакомство, объяснение подхода, выбор начального участка внедрения |
|
||||||
|
| [`reference/docs/architecture/README.md`](./reference/docs/architecture/README.md) | Базовая модель владения, структурное дерево, порядок проектирования и область применения | В начале проектирования, миграции или широкого ревью |
|
||||||
|
| [`reference/docs/architecture/layers.md`](./reference/docs/architecture/layers.md) | Роли шести слоёв, граница `domains`/`compositions`, немодульные исключения | Выбор или проверка слоя, страницы, доменного UI, `app`, `infra`, `shared` |
|
||||||
|
| [`reference/docs/architecture/modules.md`](./reference/docs/architecture/modules.md) | Ответственность модуля, ближайший владелец, API, фасеты, корень, компоненты, вложенность, состояние и lifecycle | Создание и изменение модуля, проектирование экспортов и внутренней структуры |
|
||||||
|
| [`reference/docs/architecture/domains.md`](./reference/docs/architecture/domains.md) | Доменный контракт, source boundary, адаптация, ошибки, runtime-идентификация и порядок создания домена | Любой доменный сценарий, продуктовые данные, DTO, SDK, storage или error contract |
|
||||||
|
| [`reference/docs/architecture/dependencies.md`](./reference/docs/architecture/dependencies.md) | Свёрнутый модульный граф, публичные пути, матрица слоёв, same-layer связи и циклы | Добавление импорта, реэкспорта, фасета, анализ deep import или цикла |
|
||||||
|
| [`reference/docs/architecture/groups.md`](./reference/docs/architecture/groups.md) | Навигационные группы и их отличие от модулей и сегментов | Группировка модулей, каталоги `pages`/`layouts`/`widgets`, group barrel |
|
||||||
|
| [`reference/docs/architecture/segments.md`](./reference/docs/architecture/segments.md) | Внутренняя организация владельца, колокация, компонентные единицы и переход к вложенному модулю | Размещение внутреннего файла, рост модуля, спор о `components`/`hooks`/`services` |
|
||||||
|
| [`reference/docs/reference/terminology.md`](./reference/docs/reference/terminology.md) | Нормативные определения всех сущностей и границ SLM | Спор о термине, классификация сущности, точное толкование правила |
|
||||||
|
| [`reference/docs/reference/validation.md`](./reference/docs/reference/validation.md) | Карточка решения, review-checklists, автоматические проверки, фасеты и критерий завершения | До структурного изменения, на ревью и перед завершением любой архитектурной задачи |
|
||||||
|
| [`reference/docs/rules/README.md`](./reference/docs/rules/README.md) | Разница между определением, правилом, рекомендацией и примером; классы `A`/`R` | Оформление вердикта, проектирование lint-проверки, оценка нормативной силы утверждения |
|
||||||
|
| [`reference/docs/rules/registry.md`](./reference/docs/rules/registry.md) | Единственный реестр точных блокирующих требований и стабильных кодов | Любой finding, заявление о нарушении или обязательном соответствии |
|
||||||
|
|
||||||
|
### Маршруты чтения
|
||||||
|
|
||||||
|
- Новый или изменяемый модуль: `architecture/README.md` -> `architecture/modules.md` -> нужная глава о слое или домене -> `architecture/dependencies.md` -> `reference/validation.md`.
|
||||||
|
- Доменный сценарий: `architecture/domains.md` -> `architecture/layers.md` -> `architecture/dependencies.md` -> `reference/validation.md`.
|
||||||
|
- Размещение внутреннего кода: `architecture/modules.md` -> `architecture/segments.md`; `architecture/groups.md` добавляется только для внешней навигации модулей.
|
||||||
|
- Фасеты и runtime boundaries: `architecture/modules.md` -> раздел проверки фасетов в `reference/validation.md` -> environment-правила в `rules/registry.md`.
|
||||||
|
- Архитектурное ревью: `reference/validation.md` -> `rules/registry.md` -> тематические главы по каждому найденному риску.
|
||||||
|
- Терминологический спор: `reference/terminology.md` -> тематическая глава -> `rules/registry.md`, если требуется обязательный вердикт.
|
||||||
108
.opencode/skills/slm-design/reference/docs/README.md
Normal file
108
.opencode/skills/slm-design/reference/docs/README.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
---
|
||||||
|
layout: home
|
||||||
|
title: Архитектура фронтенд-приложений
|
||||||
|
description: 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/registry
|
||||||
|
|
||||||
|
features:
|
||||||
|
- title: Один язык для всей команды
|
||||||
|
details: Разработчики одинаково понимают границы, ответственность и место нового кода. Архитектурные решения перестают зависеть от личных предпочтений.
|
||||||
|
- title: Предсказуемые изменения
|
||||||
|
details: Локальная правка остаётся локальной. Команда может развивать внутреннюю реализацию, не переписывая половину приложения.
|
||||||
|
- title: Рост без хаоса
|
||||||
|
details: Структура усложняется только вместе с продуктом, а не из-за количества файлов, компонентов или выбранных библиотек.
|
||||||
|
- title: Архитектура видна в репозитории
|
||||||
|
details: Правила выражены кодом и структурой проекта, поэтому документация не расходится с реальным приложением.
|
||||||
|
- title: Независимость от стека
|
||||||
|
details: SLM не требует конкретного фреймворка, state manager или способа работы с данными и не ограничивает внутреннюю реализацию.
|
||||||
|
- title: Постепенное внедрение
|
||||||
|
details: Начните с одного спорного участка и расширяйте модель по мере необходимости, без полной перестройки приложения.
|
||||||
|
---
|
||||||
|
|
||||||
|
## Папки перестают быть архитектурой, когда продукт начинает расти
|
||||||
|
|
||||||
|
На старте почти любая структура выглядит понятной. Затем появляются десятки компонентов, общие hooks, Providers, stores, глубокие импорты и модули, которые знают друг о друге слишком много. Папки остаются на месте, но границы ответственности исчезают.
|
||||||
|
|
||||||
|
SLM возвращает архитектуре наблюдаемый смысл:
|
||||||
|
|
||||||
|
> **Модуль владеет ответственностью. Весь код внутри ближайшей модульной границы реализует её.**
|
||||||
|
|
||||||
|
Это правило одинаково работает для страницы, доменного сценария, UI-библиотеки, инфраструктурного сервиса и небольшого внутреннего модуля.
|
||||||
|
|
||||||
|
## Что меняется для команды
|
||||||
|
|
||||||
|
| Когда границ нет | С SLM Design |
|
||||||
|
|---|---|
|
||||||
|
| Решение о размещении кода принимается по похожей папке | Сначала определяется ответственность и её владелец |
|
||||||
|
| Компоненты и services становятся скрытыми архитектурными центрами | Любой внутренний механизм остаётся реализацией ближайшего модуля |
|
||||||
|
| Потребители импортируют удобный внутренний файл | Чужой модуль доступен только через публичный фасет |
|
||||||
|
| Циклы обнаруживаются во время большого рефакторинга | Модульный граф можно проверять lint-инструментами |
|
||||||
|
| Новые уровни создаются из-за размера каталога | Вложенный модуль появляется только для самостоятельной подответственности |
|
||||||
|
|
||||||
|
Результат: меньше случайной связанности, меньше споров о папках и предсказуемый радиус каждого изменения.
|
||||||
|
|
||||||
|
## Не ещё один шаблон директорий
|
||||||
|
|
||||||
|
SLM не диктует, какие библиотеки, state managers или framework-механизмы использовать. Внутри модуля могут находиться компоненты, Providers, Guards, hooks, stores, services, utilities и сторонние SDK.
|
||||||
|
|
||||||
|
Архитектура отвечает на другие вопросы:
|
||||||
|
|
||||||
|
1. За какой результат отвечает этот код?
|
||||||
|
2. Какой модуль владеет его контрактом и состоянием?
|
||||||
|
3. Что действительно нужно внешним потребителям?
|
||||||
|
4. Какие зависимости допустимы и не создают ли они цикл?
|
||||||
|
5. Где заканчивается область жизни ресурсов?
|
||||||
|
6. Какой доменный контракт и какие ошибки определены до подключения источника данных?
|
||||||
|
|
||||||
|
Файловая структура появляется после ответов, а не заменяет их.
|
||||||
|
|
||||||
|
## От одного файла до дерева владельцев
|
||||||
|
|
||||||
|
Модуль может начинаться с одного главного файла и расти без смены архитектурной сущности:
|
||||||
|
|
||||||
|
```text
|
||||||
|
checkout/
|
||||||
|
├── index.ts # Публичный контракт
|
||||||
|
├── checkout.tsx # Главная реализация
|
||||||
|
├── components/ # Внутренний код checkout
|
||||||
|
├── hooks/
|
||||||
|
├── services/
|
||||||
|
└── modules/
|
||||||
|
└── form-session/ # Самостоятельная подответственность
|
||||||
|
├── index.ts
|
||||||
|
├── form-session.provider.tsx
|
||||||
|
└── hooks/
|
||||||
|
```
|
||||||
|
|
||||||
|
Размер, количество файлов и framework-роли не создают владельца. Только отдельно сформулированная ответственность получает модульную границу.
|
||||||
|
|
||||||
|
## Правила, которые можно проверить
|
||||||
|
|
||||||
|
SLM разделяет смысловые решения и структурные инварианты:
|
||||||
|
|
||||||
|
- ответственность, владелец и минимальный публичный контракт проверяются на архитектурном ревью;
|
||||||
|
- направления между слоями, глубокие импорты, публичные фасеты и модульные циклы проверяются автоматически;
|
||||||
|
- каждый rule имеет стабильный код и одно место нормативной формулировки;
|
||||||
|
- framework-компоненты не образуют бесконечную файловую рекурсию, а новые уровни появляются только через вложенные модули.
|
||||||
|
|
||||||
|
Вы получаете не абстрактный набор рекомендаций, а модель, которую можно обсуждать одинаковыми терминами, видеть в репозитории и постепенно автоматизировать.
|
||||||
|
|
||||||
|
## Начните с одной ответственности
|
||||||
|
|
||||||
|
Не нужно переписывать приложение целиком. Выберите один спорный участок, сформулируйте его ответственность, назначьте владельца и закройте внутреннюю реализацию публичным API. Этого достаточно, чтобы увидеть разницу между папкой и архитектурной границей.
|
||||||
|
|
||||||
|
[Спроектировать первый модуль](./architecture/modules.md) · [Спроектировать домен](./architecture/domains.md) · [Разобрать зависимости](./architecture/dependencies.md) · [Проверить существующую структуру](./reference/validation.md)
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Архитектура SLM
|
||||||
|
|
||||||
|
SLM описывает владение ответственностями внутри одного фронтенд-приложения. Слой определяет роль кода, группа классифицирует модули, модуль владеет ответственностью, домен специализирует модуль для предметной ответственности, а сегмент организует реализацию владельца.
|
||||||
|
|
||||||
|
## Владение как основа
|
||||||
|
|
||||||
|
**Ответственность** — результат или поведение приложения, за которое отвечает один модуль-владелец. Она становится самостоятельной, когда ей нужны собственный публичный контракт, зависимости, состояние или область жизни, а не только внутренняя роль в работе другого модуля. Props, импорты, локальное состояние и lifecycle-код сами по себе этого не доказывают.
|
||||||
|
|
||||||
|
У каждой самостоятельной ответственности есть ровно один владелец. В SLM владельцем является модуль. Он определяет:
|
||||||
|
|
||||||
|
- какие возможности доступны внешним потребителям;
|
||||||
|
- от каких других модулей зависит ответственность;
|
||||||
|
- кому принадлежат данные и изменяемое состояние;
|
||||||
|
- когда создаются и уничтожаются долгоживущие ресурсы;
|
||||||
|
- как устроена внутренняя реализация.
|
||||||
|
|
||||||
|
Каждый файл принадлежит ближайшей модульной границе и реализует ответственность этого модуля. Место выполнения или вид кода не меняют владельца.
|
||||||
|
|
||||||
|
Доменный сценарий всегда получает владельца в слое `domains`. Его бизнес-правила, продуктовое состояние, операции с предметными данными, доменный UI и обслуживающие framework-механизмы остаются внутри доменной границы. Модуль `compositions` использует и компонует готовый публичный API домена, но не реализует сценарий вместо него. Если подходящего доменного модуля ещё нет, его отсутствие не является основанием временно разместить сценарий в композиции. Эта граница закреплена правилом [`SLM-DOMAIN-R022`](../rules/registry.md#slm-domain-r022) и подробно описана в разделе [Домены](./domains.md).
|
||||||
|
|
||||||
|
## Структурная модель
|
||||||
|
|
||||||
|
```text
|
||||||
|
SLM root
|
||||||
|
└── слой
|
||||||
|
├── модуль
|
||||||
|
│ ├── сегмент
|
||||||
|
│ └── вложенный модуль
|
||||||
|
│ ├── сегмент
|
||||||
|
│ └── вложенный модуль
|
||||||
|
└── группа
|
||||||
|
├── модуль
|
||||||
|
└── группа
|
||||||
|
└── модуль
|
||||||
|
```
|
||||||
|
|
||||||
|
| Сущность | Назначение | Владеет ответственностью |
|
||||||
|
|---|---|---|
|
||||||
|
| Слой | Классифицирует код по архитектурной роли | Нет |
|
||||||
|
| Группа | Навигационно классифицирует модули внутри слоя | Нет |
|
||||||
|
| Модуль | Владеет одной самостоятельной ответственностью | Да |
|
||||||
|
| Домен | Специализирует модуль для предметной ответственности, контракта и ошибок | Да, как модуль |
|
||||||
|
| Сегмент | Организует внутренности одного модуля | Нет |
|
||||||
|
|
||||||
|
Домен не добавляет уровень в структурное дерево: в слое `domains` он занимает место обычного модуля и отличается дополнительными инвариантами.
|
||||||
|
|
||||||
|
Вложенный модуль является обычным модулем, размещённым внутри родительского. Он владеет отдельно сформулированной подответственностью и создаёт следующий рекурсивный структурный уровень.
|
||||||
|
|
||||||
|
Слой и группа находятся снаружи модульной границы. Сегмент находится внутри неё. Framework-компоненты, Providers, Guards, hooks, stores, services и другой внутренний код не являются архитектурными сущностями SLM и принадлежат ближайшему модулю.
|
||||||
|
|
||||||
|
## Внутренняя реализация
|
||||||
|
|
||||||
|
Модуль может содержать любой код, относящийся к его ответственности. SLM ограничивает не набор framework-механизмов, а владение и наблюдаемую структуру:
|
||||||
|
|
||||||
|
- помимо опционального главного framework-файла в корне, остальные компонентные единицы располагаются на одном внутреннем уровне относительно модуля;
|
||||||
|
- каталог компонентной единицы не содержит другие компонентные единицы или вложенные модули;
|
||||||
|
- рекурсивная структурная вложенность создаётся только вложенными модулями;
|
||||||
|
- помимо публичных фасетов, в корне находится не более одного главного implementation- или assembly-файла;
|
||||||
|
- если главный файл нельзя определить уверенно, реализация размещается в сегментах.
|
||||||
|
|
||||||
|
Ограничение глубины framework-компонентов относится к файловой организации, а не к runtime-дереву фреймворка.
|
||||||
|
|
||||||
|
## Порядок проектирования
|
||||||
|
|
||||||
|
Архитектурное решение принимается от смысла к структуре:
|
||||||
|
|
||||||
|
1. Описать результат, который должен получить пользователь или приложение.
|
||||||
|
2. Назначить модуль, который отвечает за этот результат.
|
||||||
|
3. Определить, что модуль делает сам, а что получает от других модулей.
|
||||||
|
4. Определить, кто использует результат работы модуля.
|
||||||
|
5. Выбрать [слой](./layers.md) по роли ответственности.
|
||||||
|
6. Спроектировать публичный API и допустимые [зависимости](./dependencies.md).
|
||||||
|
7. При необходимости классифицировать модули [группами](./groups.md), организовать внутренний код [сегментами](./segments.md) и выбрать физические пути.
|
||||||
|
|
||||||
|
Для домена после определения сценариев сначала проектируются [доменный контракт и ожидаемые неуспешные исходы](./domains.md#порядок-создания-домена), и только затем выбираются источники данных и механизм их адаптации.
|
||||||
|
|
||||||
|
Например, Header сам определяет расположение шапки, отображение навигации и состояние мобильного меню. Текущего пользователя он получает от модуля `auth`, а `Button` и `Avatar` — от модулей `ui`. Если удалить Header, авторизация и UI-компоненты останутся нужны приложению, поэтому Header использует их, но не владеет ими. Header может сообщить через `infra` о показе собственной раскладки, но получение пользователя и события сценария авторизации остаются ответственностью `auth`.
|
||||||
|
|
||||||
|
Если ответственность или владелец не определены, файловая структура не может исправить архитектурную неопределённость.
|
||||||
|
|
||||||
|
## Логическая и физическая границы
|
||||||
|
|
||||||
|
Модуль не определяется наличием папки, `index.ts`, framework-компонента или нескольких файлов. Его определяет самостоятельная ответственность и владение её контрактом, зависимостями, состоянием и жизненным циклом.
|
||||||
|
|
||||||
|
После принятия решения модуль получает отдельную папку и публичные точки входа. Это обязательное физическое представление модульной границы, необходимое потребителям и автоматическим проверкам.
|
||||||
|
|
||||||
|
Верны обе формулировки:
|
||||||
|
|
||||||
|
- самостоятельная ответственность требует модульной границы;
|
||||||
|
- отдельная папка сама по себе не доказывает наличие модуля.
|
||||||
|
|
||||||
|
Пути сопоставляются со слоями, группами, модулями, вложенными модулями и сегментами в стайлгайде или конфигурации конкретного проекта. Имя пути не меняет нормативный смысл сущности.
|
||||||
|
|
||||||
|
## Область применения
|
||||||
|
|
||||||
|
SLM применяется внутри **SLM root** — границы структурной архитектуры одного приложения. Это может быть `src/` или другая область, установленная проектом.
|
||||||
|
|
||||||
|
Архитектура определяет:
|
||||||
|
|
||||||
|
- роли слоёв и допустимые межслойные направления;
|
||||||
|
- владельцев самостоятельных ответственностей;
|
||||||
|
- публичные границы модулей;
|
||||||
|
- общий ацикличный граф модулей;
|
||||||
|
- назначение групп и сегментов;
|
||||||
|
- внутреннюю глубину framework-компонентных единиц;
|
||||||
|
- владение состоянием и жизненным циклом ресурсов;
|
||||||
|
- независимость доменных контрактов и ошибок от внешних источников.
|
||||||
|
|
||||||
|
SLM не задаёт обязательный поток данных, конкретный framework, фиксированные имена сегментов, правила монорепозиториев или полный файловый стайлгайд.
|
||||||
|
|
||||||
|
Нормативный смысл терминов находится в [терминологии](../reference/terminology.md). Точные блокирующие требования объявлены только в [реестре правил](../rules/registry.md).
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# Зависимости
|
||||||
|
|
||||||
|
Зависимость связывает архитектурных владельцев. Исходный файл создаёт ребро от своего ближайшего модуля к ближайшему модулю импортируемого файла.
|
||||||
|
|
||||||
|
## Модульный граф
|
||||||
|
|
||||||
|
Узлами архитектурного графа являются модули, включая вложенные. Группы, сегменты, framework-компоненты, hooks, stores и другие файлы реализации отдельных узлов не создают.
|
||||||
|
|
||||||
|
Для каждой связи определяются:
|
||||||
|
|
||||||
|
1. Ближайший модуль-владелец исходного файла.
|
||||||
|
2. Ближайший модуль-владелец целевого файла.
|
||||||
|
3. Слои исходного и целевого владельцев.
|
||||||
|
4. Публичный фасет, через который пересечена граница.
|
||||||
|
|
||||||
|
Обычный импорт, `import type` и реэкспорт одинаково создают архитектурное ребро. Связь файлов внутри одного модуля остаётся внутренней реализацией и не создаёт межмодульную зависимость.
|
||||||
|
|
||||||
|
Вложенный модуль начинает новый узел. Импорт из родительского модуля во вложенный или обратно проверяется как обычная межмодульная связь.
|
||||||
|
|
||||||
|
## Публичная граница
|
||||||
|
|
||||||
|
При пересечении модульной границы используется только объявленный публичный фасет целевого модуля:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Допустимо
|
||||||
|
import { Button } from '@/ui/button'
|
||||||
|
|
||||||
|
// Недопустимый глубокий импорт
|
||||||
|
import { Button } from '@/ui/button/button'
|
||||||
|
```
|
||||||
|
|
||||||
|
Разрешённое направление слоя или отсутствие цикла не делает глубокий импорт допустимым.
|
||||||
|
|
||||||
|
## Направление между слоями
|
||||||
|
|
||||||
|
Матрица определяет, от каких слоёв может зависеть исходный слой:
|
||||||
|
|
||||||
|
| Исходный слой | Допустимые целевые слои |
|
||||||
|
|---|---|
|
||||||
|
| `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` |
|
||||||
|
|
||||||
|
Разрешённая зависимость может пропускать промежуточные слои. Например, `compositions` может напрямую использовать модуль `ui`, не создавая посредника в `domains` или `infra`.
|
||||||
|
|
||||||
|
Разрешённое направление не переносит владение. Если модуль `domains` использует `infra`, предметный сценарий остаётся ответственностью доменного модуля, а техническая возможность — ответственностью инфраструктурного.
|
||||||
|
|
||||||
|
## Допустимость связи и владение
|
||||||
|
|
||||||
|
Матрица отвечает только на вопрос, может ли один слой зависеть от другого. Она не разрешает исходному модулю реализовывать ответственность, которая по своей роли принадлежит целевому или другому слою.
|
||||||
|
|
||||||
|
`compositions` может зависеть от `domains` и `infra`, но использует эти направления по-разному:
|
||||||
|
|
||||||
|
- доменный сценарий доступен композиции только как готовый публичный API доменного модуля;
|
||||||
|
- инфраструктурный API может обслуживать собственную техническую потребность композиции, например тему, локализацию или доставку метрики показа страницы;
|
||||||
|
- инфраструктурный HTTP-клиент, SDK или storage не используются композицией для реализации продуктовой операции, загрузки предметных данных или определения доменного исхода.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Допустимо: композиция использует готовый доменный UI.
|
||||||
|
import { OrdersList } from '@/domains/orders/client'
|
||||||
|
|
||||||
|
// Допустимо: техническая возможность обслуживает саму композицию.
|
||||||
|
import { useTheme } from '@/infra/theme/client'
|
||||||
|
|
||||||
|
// Направление импорта допустимо, но ответственность выбрана неверно.
|
||||||
|
import { http } from '@/infra/http'
|
||||||
|
|
||||||
|
await http.get('/orders')
|
||||||
|
```
|
||||||
|
|
||||||
|
В последнем примере запрос получает предметные данные и участвует в доменном сценарии. Его смысл, параметры, продуктовые исходы и вызов принадлежат доменному модулю, который открывает композиции готовый API.
|
||||||
|
|
||||||
|
Разрешённая зависимость `domains` от `infra` также не объединяет их контракты. Домен может использовать HTTP-транспорт, SDK или storage через публичный API инфраструктурного модуля, но DTO и ошибки источника остаются только во внутреннем интеграционном коде домена. До попадания в правила, состояние, доменный UI или публичный результат данные адаптируются к доменному контракту, а ошибка источника интерпретируется в терминах текущего сценария. Полные требования описаны в разделе [Домены](./domains.md#граница-внешних-данных).
|
||||||
|
|
||||||
|
Аналогично, технической доставкой метрик владеет `infra`, но смысл события определяется модулем-владельцем наблюдаемого поведения. Разрешённый вызов telemetry API из композиции не позволяет ей объявлять события доменного сценария от своего имени.
|
||||||
|
|
||||||
|
Композиция может размещать и связывать несколько доменных API. Если эта связь задаёт обязательный порядок, продуктовые условия, общий предметный результат или политику ошибок между доменами, она является отдельным доменным сценарием, а не внутренней логикой композиции.
|
||||||
|
|
||||||
|
## Зависимости внутри слоя
|
||||||
|
|
||||||
|
Модули одного слоя могут зависеть друг от друга в любом направлении при одновременном выполнении двух условий:
|
||||||
|
|
||||||
|
1. Целевой модуль используется только через публичный API.
|
||||||
|
2. Общий модульный граф остаётся ацикличным.
|
||||||
|
|
||||||
|
Принадлежность модулей одной или разным [группам](./groups.md) не влияет на разрешение связи. Группа не имеет API и не является промежуточным узлом импорта.
|
||||||
|
|
||||||
|
SLM не задаёт отдельные same-layer матрицы для `pages`, `layouts`, `widgets`, доменов, инфраструктуры, UI или shared. Если проекту нужна более строгая локальная политика, она является дополнительным проектным ограничением, а не общим правилом SLM.
|
||||||
|
|
||||||
|
## Запрет циклов
|
||||||
|
|
||||||
|
Общий граф модулей внутри одного SLM root остаётся ацикличным. Запрет действует для модулей одного слоя, разных разрешённых слоёв и вложенных модулей.
|
||||||
|
|
||||||
|
Проверки только файлового графа недостаточно. Например:
|
||||||
|
|
||||||
|
```text
|
||||||
|
module-a/file-1.ts → module-b/file-1.ts
|
||||||
|
module-b/file-2.ts → module-a/file-2.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Между конкретными файлами может не существовать замкнутого пути, но после сопоставления файлов владельцам возникает архитектурный цикл:
|
||||||
|
|
||||||
|
```text
|
||||||
|
module-a ↔ module-b
|
||||||
|
```
|
||||||
|
|
||||||
|
Lint-проверка модульных циклов должна:
|
||||||
|
|
||||||
|
1. Сопоставить каждый файл ближайшему модулю-владельцу.
|
||||||
|
2. Свернуть внутренние импорты файлов одного модуля.
|
||||||
|
3. Добавить межмодульные рёбра для импортов типов, исполняемого кода и реэкспортов.
|
||||||
|
4. Считать каждый вложенный модуль отдельным узлом.
|
||||||
|
5. Блокировать любое сильносвязное множество из нескольких модулей.
|
||||||
|
|
||||||
|
Стандартная file-level проверка циклов может использоваться дополнительно, но не заменяет проверку модульного графа.
|
||||||
|
|
||||||
|
## Проверка связи
|
||||||
|
|
||||||
|
Для каждого нового или изменённого импорта проверяются три условия:
|
||||||
|
|
||||||
|
1. Направление разрешено матрицей слоёв.
|
||||||
|
2. Целевая модульная граница пересечена через публичный фасет.
|
||||||
|
3. После добавления ребра модульный граф остаётся ацикличным.
|
||||||
|
|
||||||
|
## Связанные правила
|
||||||
|
|
||||||
|
- [`SLM-LAYER-A002`](../rules/registry.md#slm-layer-a002)
|
||||||
|
- [`SLM-DOMAIN-R022`](../rules/registry.md#slm-domain-r022)
|
||||||
|
- [`SLM-DOMAIN-R023`](../rules/registry.md#slm-domain-r023)
|
||||||
|
- [`SLM-DOMAIN-R024`](../rules/registry.md#slm-domain-r024)
|
||||||
|
- [`SLM-DOMAIN-R025`](../rules/registry.md#slm-domain-r025)
|
||||||
|
- [`SLM-DOMAIN-R026`](../rules/registry.md#slm-domain-r026)
|
||||||
|
- [`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)
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
# Домены
|
||||||
|
|
||||||
|
Домен является специализированным SLM-модулем слоя `domains`. Он владеет одной связной предметной ответственностью, её сценариями, публичным контрактом, ошибками, состоянием, доменным UI и интеграцией с источниками данных.
|
||||||
|
|
||||||
|
Домен не создаёт новый структурный уровень над модулями. Он остаётся модулем-владельцем, отдельным узлом графа зависимостей и подчиняется всем общим [правилам модулей](./modules.md). Дополнительные правила домена защищают независимость предметной модели от внешних сервисов и технических контрактов.
|
||||||
|
|
||||||
|
## Место в структурной модели
|
||||||
|
|
||||||
|
```text
|
||||||
|
SLM root
|
||||||
|
└── domains # Слой
|
||||||
|
└── orders # Домен, специализированный модуль
|
||||||
|
├── index.ts # Публичный контракт
|
||||||
|
├── client.ts # Доменный UI при необходимости
|
||||||
|
├── ... # Внутренняя реализация
|
||||||
|
└── modules/ # Вложенные модули при необходимости
|
||||||
|
```
|
||||||
|
|
||||||
|
Как обычный модуль, домен:
|
||||||
|
|
||||||
|
- имеет одну физическую модульную границу;
|
||||||
|
- предоставляет единый логический публичный API через фасеты;
|
||||||
|
- владеет состоянием и жизненным циклом своей ответственности;
|
||||||
|
- использует другие модули только через их публичные API;
|
||||||
|
- может содержать сегменты и вложенные модули;
|
||||||
|
- участвует в общем ацикличном модульном графе.
|
||||||
|
|
||||||
|
Корень домена не является package-контейнером или группой модулей. Сегменты, функции преобразования, компоненты и source-specific код остаются внутренней реализацией ближайшего доменного модуля и не получают самостоятельного API только из-за технической роли.
|
||||||
|
|
||||||
|
## Что принадлежит домену
|
||||||
|
|
||||||
|
Домен полностью определяет предметный смысл ответственности:
|
||||||
|
|
||||||
|
- принимаемые команды, параметры и значения;
|
||||||
|
- возвращаемые модели и результаты;
|
||||||
|
- бизнес-правила, переходы и допустимые состояния;
|
||||||
|
- ожидаемые неуспешные исходы сценариев;
|
||||||
|
- смысл операций с продуктовыми данными;
|
||||||
|
- адаптацию внешних данных к доменному контракту;
|
||||||
|
- интерпретацию ошибок источника;
|
||||||
|
- состояние, доменный UI и framework-механизмы сценариев.
|
||||||
|
|
||||||
|
Техническая возможность сохраняет собственного владельца. Например, `infra` может владеть HTTP-транспортом, SDK runtime или storage, но домен определяет, зачем выполняется операция, какие данные она принимает и возвращает и какой предметный результат получает потребитель.
|
||||||
|
|
||||||
|
## Доменный контракт
|
||||||
|
|
||||||
|
Доменный контракт описывает ответственность в терминах продукта, а не источника данных. Он включает публичные входы, модели, результаты, события, доступные потребителям формы состояния и ожидаемые неуспешные исходы.
|
||||||
|
|
||||||
|
Контракт объявляется самим доменом. Даже если форма внешнего DTO временно совпадает с нужной моделью, домен создаёт собственную форму. Совпадение полей не передаёт источнику владение предметным контрактом.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Один из возможных способов объявить доменный контракт.
|
||||||
|
export type Order = Readonly<{
|
||||||
|
id: OrderId
|
||||||
|
state: OrderState
|
||||||
|
total: Money
|
||||||
|
}>
|
||||||
|
|
||||||
|
export type GetOrderInput = Readonly<{
|
||||||
|
orderId: OrderId
|
||||||
|
}>
|
||||||
|
```
|
||||||
|
|
||||||
|
Недопустимо строить публичный контракт из типов источника:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// DTO источника стал доменной моделью.
|
||||||
|
export type Order = OrdersApiDto
|
||||||
|
|
||||||
|
// Внешний вызов стал публичным сценарием домена.
|
||||||
|
export const getOrder = ordersSdk.getOrder
|
||||||
|
|
||||||
|
// Форма результата выводится из SDK.
|
||||||
|
export type GetOrderResult = Awaited<ReturnType<typeof ordersSdk.getOrder>>
|
||||||
|
```
|
||||||
|
|
||||||
|
Источник может измениться, не меняя доменный контракт. Если новая форма источника не позволяет выполнить уже объявленный сценарий, меняется интеграция или принимается отдельное продуктовое решение, но контракт не подгоняется автоматически под DTO.
|
||||||
|
|
||||||
|
## Граница внешних данных
|
||||||
|
|
||||||
|
DTO, request types, response types и ошибки источника допускаются только во внутреннем интеграционном коде домена. До использования в правилах, состоянии, доменном UI или публичном результате внешнее значение адаптируется к доменному контракту.
|
||||||
|
|
||||||
|
Адаптация принадлежит домену, потому что только он определяет целевой предметный смысл. Она может быть реализована mapper-функцией, adapter-объектом, parser-ом или другим внутренним механизмом. SLM ограничивает результат пересечения границы, а не имя файла, функции или выбранный паттерн. Общий технический клиент при этом может принадлежать `infra`.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Mapper является одним из возможных механизмов адаптации.
|
||||||
|
type OrderDto = Awaited<ReturnType<typeof ordersSdk.getOrder>>
|
||||||
|
|
||||||
|
const mapOrderDto = (dto: OrderDto): Order => ({
|
||||||
|
id: dto.order_id,
|
||||||
|
state: mapOrderState(dto.status),
|
||||||
|
total: mapMoney(dto.total),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Входящие и исходящие направления симметричны:
|
||||||
|
|
||||||
|
- response источника адаптируется к доменной модели;
|
||||||
|
- доменная команда адаптируется к контракту запроса источника;
|
||||||
|
- source-specific enum, nullable semantics и служебные поля не становятся частью доменной модели автоматически;
|
||||||
|
- невалидный ответ источника получает смысл, определённый доменом;
|
||||||
|
- DTO не сохраняется как продуктовое состояние и не передаётся доменному UI.
|
||||||
|
|
||||||
|
Внутренний механизм может быть близок к identity-преобразованию, но публичная граница остаётся независимой. Запрещены прямой реэкспорт, type alias, `Pick`, `Omit`, `ReturnType` или другое выведение публичной доменной модели из source type.
|
||||||
|
|
||||||
|
## Доменные ошибки
|
||||||
|
|
||||||
|
Домен самостоятельно определяет, какие неуспешные исходы его сценариев являются ожидаемыми и какой публичный контракт получают потребители. Ошибка источника не становится доменной ошибкой только потому, что была получена во время выполнения сценария.
|
||||||
|
|
||||||
|
SLM не устанавливает способ представления или передачи доменных ошибок. Проект может использовать exception, `Result`, discriminated union, отдельные типы сценариев или другую форму. Архитектурным инвариантом остаётся владелец смысла: потребитель зависит только от контракта текущего домена.
|
||||||
|
|
||||||
|
### Декларация и реализация
|
||||||
|
|
||||||
|
Доменная декларация определяет допустимые неуспешные исходы до реализации сценария и подключения источника. Реализация домена конструирует и возвращает только объявленные исходы, а интеграционный код преобразует ошибки источника в уже существующий доменный контракт.
|
||||||
|
|
||||||
|
Новый ожидаемый исход сначала добавляется в декларацию домена и только затем используется реализацией. Реализация, mapper, adapter или framework-механизм не объявляют собственные ошибки параллельно доменному контракту.
|
||||||
|
|
||||||
|
Декларация и реализация являются ролями внутри одного доменного модуля, а не новыми структурными сущностями, обязательными сегментами или именами файлов.
|
||||||
|
|
||||||
|
Публичный контракт ожидаемой ошибки не включает чужую ошибку в исходной форме:
|
||||||
|
|
||||||
|
- тип или экземпляр ошибки SDK;
|
||||||
|
- код и message внешнего сервиса;
|
||||||
|
- HTTP status или другой транспортный status источника;
|
||||||
|
- raw response payload;
|
||||||
|
- `cause`, stack trace или другие source-specific диагностические данные.
|
||||||
|
|
||||||
|
Домен может объявить собственные идентификаторы, данные для обработки и представления ошибки. Их форма, общий каталог, casing, имена полей и группировка по сценариям являются проектной policy, а не правилами SLM. Если проект выбирает машинные коды или единый union, соответствующие соглашения закрепляются в style guide и могут проверяться отдельным lint-правилом.
|
||||||
|
|
||||||
|
В примерах этой документации доменные коды ошибок записываются в `SCREAMING_SNAKE_CASE` по соглашению команды. Это соглашение определяет оформление примеров, но не является архитектурным требованием SLM.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Публичная декларация домена Orders.
|
||||||
|
// Форма является project policy, а не обязательной формой SLM.
|
||||||
|
export type OrdersError =
|
||||||
|
| Readonly<{
|
||||||
|
code: 'ORDER_NOT_FOUND'
|
||||||
|
}>
|
||||||
|
| Readonly<{
|
||||||
|
code: 'ORDER_CANNOT_BE_CANCELLED'
|
||||||
|
payload: Readonly<{
|
||||||
|
currentState: OrderState
|
||||||
|
}>
|
||||||
|
}>
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Внутренняя реализация использует декларацию домена.
|
||||||
|
const createOrderNotFoundError = (): OrdersError => ({
|
||||||
|
code: 'ORDER_NOT_FOUND',
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Runtime-идентификация
|
||||||
|
|
||||||
|
SLM не требует универсального constructor, base class, marker, guard или parser для доменных ошибок. Домен предоставляет runtime-механизм идентификации только тогда, когда он необходим реальному потребителю и совместим с его средой выполнения.
|
||||||
|
|
||||||
|
| Условия использования | Возможный механизм |
|
||||||
|
|---|---|
|
||||||
|
| Типизированный результат внутри одного TypeScript-графа | Discriminated result без дополнительного guard |
|
||||||
|
| Ошибка поступает как `unknown` через `catch` | Domain guard или class с `instanceof` |
|
||||||
|
| Ошибка пересекает JSON, SSR, RSC, worker или другую serialization boundary | Сериализуемый discriminant и runtime parser |
|
||||||
|
| Значение приходит из недоверенной среды | Schema validation |
|
||||||
|
| Ошибка не покидает доменную реализацию | Публичный runtime-механизм не нужен |
|
||||||
|
|
||||||
|
Constructor или factory обычно остаётся внутренней частью реализации: внешние потребители распознают и обрабатывают доменные ошибки, но не создают их. Если потребителю действительно требуется runtime-идентификация, домен может открыть минимальную capability, например `isOrdersError` или `parseOrdersError`, через подходящий публичный фасет.
|
||||||
|
|
||||||
|
Механизм идентификации не переносит владение ошибкой. Общий product-agnostic marker или guard может принадлежать `shared`, но перечень ожидаемых исходов и domain-specific проверка остаются контрактом соответствующего домена.
|
||||||
|
|
||||||
|
## Преобразование ошибок источника
|
||||||
|
|
||||||
|
Домен интерпретирует ошибку источника в контексте текущего сценария. Одинаковый HTTP status может означать отсутствие предметного объекта, конфликт состояния, ошибку доступа или технический сбой, поэтому транспортный признак не передаётся потребителю как готовый доменный исход.
|
||||||
|
|
||||||
|
Ошибка источника, влияющая на публичный результат, преобразуется в собственный ожидаемый исход домена либо в неожиданный дефект согласно общей политике приложения. Raw ошибка может использоваться для внутренней диагностики и телеметрии, но не становится публичным контрактом домена.
|
||||||
|
|
||||||
|
Если домен использует API другого домена, он также не возвращает чужой error contract от своего имени. Неуспешный исход зависимого домена интерпретируется в терминах текущего сценария.
|
||||||
|
|
||||||
|
## Порядок создания домена
|
||||||
|
|
||||||
|
Интеграция с источником начинается только после определения предметной границы:
|
||||||
|
|
||||||
|
1. Сформулировать ответственность и сценарии домена.
|
||||||
|
2. Объявить доменный контракт входов, моделей и результатов.
|
||||||
|
3. Определить ожидаемые неуспешные исходы и их публичный контракт.
|
||||||
|
4. Определить границу между ожидаемым исходом и programming defect.
|
||||||
|
5. Только после этого определить внешние источники и технические зависимости.
|
||||||
|
6. Реализовать адаптацию запросов, ответов и ошибок выбранным внутренним механизмом.
|
||||||
|
7. Проверить сценарии и адаптацию источников независимо друг от друга.
|
||||||
|
8. Убедиться, что публичный API транзитивно не содержит типов источника.
|
||||||
|
|
||||||
|
Если контракт или семантика ошибок ещё не определены, запрос к реальному источнику не считается допустимым временным началом домена. Сначала создаётся предметная граница, затем к ней адаптируется источник.
|
||||||
|
|
||||||
|
## Проверка границы
|
||||||
|
|
||||||
|
При ревью домена проверяется:
|
||||||
|
|
||||||
|
- можно ли описать публичный контракт без упоминания API, endpoint, SDK или DTO;
|
||||||
|
- объявлены ли модели, результаты и ожидаемые неуспешные исходы самим доменом;
|
||||||
|
- использует ли реализация только исходы, объявленные доменной декларацией;
|
||||||
|
- не навязывает ли контракт источника форму доменной модели;
|
||||||
|
- адаптируются ли внешние значения до использования в правилах, состоянии и доменном UI;
|
||||||
|
- отсутствуют ли source types в публичных фасетах, состоянии и доменном UI;
|
||||||
|
- интерпретируются ли ошибки источника и зависимых доменов в терминах текущего сценария;
|
||||||
|
- не протекают ли наружу чужие error types, codes, messages, transport statuses, raw payload или cause;
|
||||||
|
- не маскируется ли programming defect под ожидаемый доменный исход;
|
||||||
|
- нужен ли реальным потребителям runtime-механизм идентификации и совместим ли он с их средой;
|
||||||
|
- не экспортирует ли домен constructor, guard, parser или schema без реального потребителя;
|
||||||
|
- не объявлена ли выбранная форма error contract универсальным требованием SLM.
|
||||||
|
|
||||||
|
## Связанные правила
|
||||||
|
|
||||||
|
- [`SLM-DOMAIN-R022`](../rules/registry.md#slm-domain-r022)
|
||||||
|
- [`SLM-DOMAIN-R023`](../rules/registry.md#slm-domain-r023)
|
||||||
|
- [`SLM-DOMAIN-R024`](../rules/registry.md#slm-domain-r024)
|
||||||
|
- [`SLM-DOMAIN-R025`](../rules/registry.md#slm-domain-r025)
|
||||||
|
- [`SLM-DOMAIN-R026`](../rules/registry.md#slm-domain-r026)
|
||||||
|
- [`SLM-MODULE-A004`](../rules/registry.md#slm-module-a004)
|
||||||
|
- [`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)
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Группы
|
||||||
|
|
||||||
|
Группа является необязательным навигационным классификатором модулей внутри одного слоя. Она помогает ориентироваться в дереве владельцев, но не реализует ответственность и не создаёт архитектурную границу.
|
||||||
|
|
||||||
|
## Место в модели
|
||||||
|
|
||||||
|
Модуль может находиться непосредственно в слое или внутри одной или нескольких групп:
|
||||||
|
|
||||||
|
```text
|
||||||
|
слой
|
||||||
|
├── модуль
|
||||||
|
└── группа
|
||||||
|
├── модуль
|
||||||
|
└── группа
|
||||||
|
└── модуль
|
||||||
|
```
|
||||||
|
|
||||||
|
Группа создаётся, когда плоский список модулей перестаёт быть понятным. Названия и глубину групп определяет проект.
|
||||||
|
|
||||||
|
```text
|
||||||
|
compositions/
|
||||||
|
├── pages/ # Группа
|
||||||
|
│ ├── catalog/ # Модуль
|
||||||
|
│ └── profile/ # Модуль
|
||||||
|
├── layouts/ # Группа
|
||||||
|
│ └── main/ # Модуль
|
||||||
|
└── widgets/ # Группа
|
||||||
|
└── dashboard/ # Модуль, компонующий несколько доменных API
|
||||||
|
```
|
||||||
|
|
||||||
|
Названия `pages`, `layouts` и `widgets` показывают один из вариантов навигации и не создают дополнительные слои или обязательные роли.
|
||||||
|
|
||||||
|
Модули `catalog`, `profile` и `dashboard` в примере отвечают только за представление и связывание готовых публичных API. Сценарии каталога, профиля и других предметных областей остаются в соответствующих модулях `domains`.
|
||||||
|
|
||||||
|
## Ограничения группы
|
||||||
|
|
||||||
|
Группа:
|
||||||
|
|
||||||
|
- содержит только модули и вложенные группы;
|
||||||
|
- не владеет файлами реализации;
|
||||||
|
- не имеет состояния или жизненного цикла;
|
||||||
|
- не предоставляет публичный API;
|
||||||
|
- не является узлом графа зависимостей;
|
||||||
|
- не импортируется внешним кодом;
|
||||||
|
- не реэкспортирует содержащиеся в ней модули.
|
||||||
|
|
||||||
|
Barrel-файл, открывающий несколько модулей группы как единый контракт, превращает каталог в новую модульную границу. Если такой контракт действительно нужен, для него определяется ответственность и создаётся обычный модуль.
|
||||||
|
|
||||||
|
## Группы и зависимости
|
||||||
|
|
||||||
|
Принадлежность модулей одной или разным группам не влияет на допустимость импорта. Модули одного слоя могут зависеть друг от друга через публичные API, если общий граф остаётся ацикличным.
|
||||||
|
|
||||||
|
Код импортирует конкретный модуль:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Dashboard } from '@/compositions/widgets/dashboard'
|
||||||
|
```
|
||||||
|
|
||||||
|
Группа не становится промежуточной точкой доступа:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Недопустимый API группы
|
||||||
|
import { Dashboard } from '@/compositions/widgets'
|
||||||
|
```
|
||||||
|
|
||||||
|
Полные правила графа находятся в разделе [Зависимости](./dependencies.md).
|
||||||
|
|
||||||
|
## Группа и сегмент
|
||||||
|
|
||||||
|
Группа организует несколько модулей внутри слоя. [Сегмент](./segments.md) организует код внутри одного модуля.
|
||||||
|
|
||||||
|
| Группа | Сегмент |
|
||||||
|
|---|---|
|
||||||
|
| Находится снаружи модульной границы | Находится внутри модульной границы |
|
||||||
|
| Содержит модули и группы | Содержит внутреннюю реализацию владельца |
|
||||||
|
| Не принадлежит одному модулю | Всегда принадлежит ближайшему модулю |
|
||||||
|
| Не содержит файлы реализации | Существует для организации файлов реализации |
|
||||||
|
|
||||||
|
## Связанные правила
|
||||||
|
|
||||||
|
- [`SLM-DOMAIN-R022`](../rules/registry.md#slm-domain-r022)
|
||||||
|
- [`SLM-GROUP-R007`](../rules/registry.md#slm-group-r007)
|
||||||
|
- [`SLM-MODULE-R011`](../rules/registry.md#slm-module-r011)
|
||||||
|
- [`SLM-DEPENDENCY-A005`](../rules/registry.md#slm-dependency-a005)
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Слои
|
||||||
|
|
||||||
|
Слой классифицирует код по архитектурной роли. Слой не владеет ответственностью: владельцем остаётся модуль, размещённый в этом слое.
|
||||||
|
|
||||||
|
Слой выбирается после определения ответственности. Похожее имя папки или наличие зависимости от конкретной библиотеки не являются основанием для выбора слоя.
|
||||||
|
|
||||||
|
## Роли слоёв
|
||||||
|
|
||||||
|
SLM определяет шесть ролей:
|
||||||
|
|
||||||
|
| Слой | Роль |
|
||||||
|
|---|---|
|
||||||
|
| `app` | Связь приложения с фреймворком: запуск, маршруты, преобразование внешних входных данных и подключение готовых публичных API |
|
||||||
|
| `compositions` | Представление и связывание готовых публичных API в страницы, макеты, экраны, виджеты и другие продуктовые композиции |
|
||||||
|
| `domains` | Полная реализация предметных ответственностей и сценариев, включая их модели, правила, состояние, операции с продуктовыми данными и доменный UI |
|
||||||
|
| `infra` | Технические сервисы и возможности приложения без собственной предметной модели |
|
||||||
|
| `ui` | Универсальные интерфейсные модули без зависимости от конкретной продуктовой композиции |
|
||||||
|
| `shared` | Детерминированный фундамент без знания о продукте, изменяемого состояния и ввода-вывода |
|
||||||
|
|
||||||
|
Отсутствующая роль не требует пустой папки. Проект создаёт слой только тогда, когда в нём появляется соответствующая ответственность.
|
||||||
|
|
||||||
|
### App
|
||||||
|
|
||||||
|
`app` содержит точки связи с фреймворком: запуск, файлы маршрутов и преобразование внешних входных данных. Они подключают готовые публичные API других слоёв, но не присваивают их ответственность.
|
||||||
|
|
||||||
|
Точки входа `app` являются специальным немодульным исключением. Самостоятельная продуктовая ответственность, даже если она представлена страницей, макетом или Provider, реализуется в подходящем модуле и только подключается из `app`.
|
||||||
|
|
||||||
|
### Compositions
|
||||||
|
|
||||||
|
`compositions` содержит владельцев представления продуктовых композиций: страниц, макетов, экранов, виджетов, результатов маршрутов и интерфейса, объединяющего несколько готовых модульных возможностей. Композиция размещает и связывает публичные API доменных, инфраструктурных и UI-модулей, но не присваивает их ответственность.
|
||||||
|
|
||||||
|
Модуль композиции может владеть структурой страницы, расположением частей интерфейса и состоянием, смысл которого существует только внутри этой композиции. Он не определяет, не реализует, не расширяет и не замещает доменный сценарий. Количество потребителей и использование сценария только на одной странице не меняют эту границу.
|
||||||
|
|
||||||
|
Конкретная организация слоя определяется продуктом и фреймворком. Названия `pages`, `layouts`, `screens` и `widgets` могут использоваться как [группы](./groups.md), но не являются дополнительными слоями и не задают направление импортов.
|
||||||
|
|
||||||
|
### Domains
|
||||||
|
|
||||||
|
`domains` содержит модули-владельцы полных предметных ответственностей и доменных сценариев. Доменный модуль владеет не только моделями и бизнес-правилами, но и продуктовым состоянием, смыслом операций с продуктовыми данными, предметными исходами, доменным UI и framework-механизмами, которые обслуживают сценарий.
|
||||||
|
|
||||||
|
Домен является вертикальным владельцем ответственности, а не только каталогом независимой от интерфейса бизнес-логики. Внутри него могут находиться компоненты, Providers, hooks, stores, services и другой код, если он реализует принадлежащий домену результат. Универсальные визуальные элементы домен получает из `ui`, а технические возможности без предметной модели — из `infra`.
|
||||||
|
|
||||||
|
Домен является специализированным SLM-модулем: он сохраняет обычную модульную форму и получает дополнительные требования к предметному контракту, адаптации источников и ошибкам. Полная модель описана в разделе [Домены](./domains.md).
|
||||||
|
|
||||||
|
### Infra
|
||||||
|
|
||||||
|
`infra` содержит технические возможности приложения: аналитику, локализацию, тему, телеметрию, интеграции с платформой и другие сервисы без собственной предметной модели.
|
||||||
|
|
||||||
|
Технический способ выполнения предметного сценария не переносит владение сценарием из `domains` в `infra`.
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
`ui` содержит универсальные интерфейсные модули, которые не знают о конкретной странице, маршруте или продуктовой композиции.
|
||||||
|
|
||||||
|
### Shared
|
||||||
|
|
||||||
|
`shared` содержит детерминированный фундамент, не зависящий от продукта и не имеющий ввода-вывода, изменяемого состояния или жизненного цикла.
|
||||||
|
|
||||||
|
В `shared` могут находиться обычные модули и небольшие немодульные ресурсы: чистые функции, общие типы, стили, декларативная конфигурация и статические файлы.
|
||||||
|
|
||||||
|
## Граница доменов и композиций
|
||||||
|
|
||||||
|
Граница определяется смыслом поведения, а не местом его вызова или отображения:
|
||||||
|
|
||||||
|
| Код определяет | Слой-владелец |
|
||||||
|
|---|---|
|
||||||
|
| Продуктовую операцию, правило, переход, предметный исход или состояние | `domains` |
|
||||||
|
| Получение или изменение продуктовых данных, параметры операции и смысл её ошибок | `domains` |
|
||||||
|
| Форму, список, карточку, Guard или другой UI, выраженный в терминах одного домена | `domains` |
|
||||||
|
| Расположение и связывание готовых публичных API на странице или экране | `compositions` |
|
||||||
|
| Состояние панели, секции или раскладки, имеющее смысл только в одной композиции | `compositions` |
|
||||||
|
| Универсальный визуальный элемент без предметного смысла | `ui` |
|
||||||
|
| HTTP-транспорт, тему, локализацию или техническую доставку телеметрии | `infra` |
|
||||||
|
|
||||||
|
Если для нового доменного сценария ещё нет подходящего владельца, сначала выбирается существующий или создаётся новый модуль в `domains`. Реализация сценария в `compositions` с намерением перенести её позже не является допустимым промежуточным архитектурным решением.
|
||||||
|
|
||||||
|
Композиция может показать рядом несколько доменных возможностей и вызвать их готовые публичные операции. Если координация определяет продуктовый порядок действий, условия, общий предметный результат, политику ошибок или компенсацию между доменами, такая координация сама является доменным сценарием и требует владельца в `domains`.
|
||||||
|
|
||||||
|
Разрешённая зависимость `compositions` от `infra` сохраняется. Композиция может использовать тему, локализацию, доставку метрик и другие технические возможности для собственной ответственности. Эта связь не разрешает получать или изменять продуктовые данные через HTTP-клиент, SDK или storage непосредственно из композиции: техническим механизмом владеет `infra`, а смысл такой операции и её продуктовый результат принадлежат домену.
|
||||||
|
|
||||||
|
Смысл события метрики принадлежит владельцу наблюдаемого поведения. Домен определяет событие доменного сценария, композиция — событие показа или взаимодействия со своей раскладкой, `app` — событие запуска или маршрутизации, а `infra` отвечает за техническую доставку телеметрии.
|
||||||
|
|
||||||
|
## Направление зависимостей
|
||||||
|
|
||||||
|
Слой ограничивает только межслойное направление. Модули одного слоя могут зависеть друг от друга через публичные API, если общий граф остаётся ацикличным.
|
||||||
|
|
||||||
|
Нормативная матрица, правила same-layer импортов и требования к lint-проверке находятся в разделе [Зависимости](./dependencies.md).
|
||||||
|
|
||||||
|
## Группировка
|
||||||
|
|
||||||
|
Модули могут находиться непосредственно в слое или объединяться в необязательные навигационные [группы](./groups.md). Группа не владеет кодом и не влияет на допустимость зависимостей.
|
||||||
|
|
||||||
|
## Немодульные исключения
|
||||||
|
|
||||||
|
Внутри SLM root код по умолчанию принадлежит модулю. Исключения ограничены двумя случаями:
|
||||||
|
|
||||||
|
- точка входа `app` непосредственно связывает приложение с фреймворком;
|
||||||
|
- ресурс `shared` является небольшой самостоятельной детерминированной единицей без внутренней границы.
|
||||||
|
|
||||||
|
Если ресурсу `shared` нужны несколько файлов реализации, собственные архитектурные зависимости, изменяемое состояние, ввод-вывод или жизненный цикл, ему требуется модуль-владелец.
|
||||||
|
|
||||||
|
## Связанные правила
|
||||||
|
|
||||||
|
- [`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-DOMAIN-R022`](../rules/registry.md#slm-domain-r022)
|
||||||
|
- [`SLM-DOMAIN-R023`](../rules/registry.md#slm-domain-r023)
|
||||||
|
- [`SLM-DOMAIN-R024`](../rules/registry.md#slm-domain-r024)
|
||||||
|
- [`SLM-DOMAIN-R025`](../rules/registry.md#slm-domain-r025)
|
||||||
|
- [`SLM-DOMAIN-R026`](../rules/registry.md#slm-domain-r026)
|
||||||
|
- [`SLM-GROUP-R007`](../rules/registry.md#slm-group-r007)
|
||||||
|
- [`SLM-MODULE-R011`](../rules/registry.md#slm-module-r011)
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
# Модули
|
||||||
|
|
||||||
|
Модуль является владельцем самостоятельной ответственности. Публичный API, зависимости, состояние, жизненный цикл и внутренняя реализация ответственности относятся к модулю независимо от того, в каком файле или механизме выполняется код.
|
||||||
|
|
||||||
|
## Ответственность и владелец
|
||||||
|
|
||||||
|
Перед созданием модуля ответственность формулируется без упоминания желаемой папки, файла или библиотеки.
|
||||||
|
|
||||||
|
Самостоятельность ответственности определяется вопросами:
|
||||||
|
|
||||||
|
- какой один результат или поведение она обеспечивает;
|
||||||
|
- что модуль должен делать сам для получения этого результата;
|
||||||
|
- какие возможности ему нужны от других модулей;
|
||||||
|
- нужен ли внешним потребителям собственный контракт;
|
||||||
|
- требуют ли зависимости отдельного архитектурного владения;
|
||||||
|
- владеет ли она смыслом данных или изменяемого состояния;
|
||||||
|
- нужна ли ей собственная область жизни;
|
||||||
|
- можно ли назвать её независимо от внутренней реализации.
|
||||||
|
|
||||||
|
Props, импорты, Context, локальное состояние, lifecycle-код или количество файлов сами по себе не доказывают самостоятельность ответственности. Если ответственность самостоятельна, она получает ровно один модуль-владелец.
|
||||||
|
|
||||||
|
Для доменного сценария выбор владельца дополнительно ограничен ролью слоя: такой сценарий принадлежит модулю `domains`. Этот модуль является [доменом](./domains.md) и дополнительно владеет предметным контрактом, ожидаемыми неуспешными исходами и адаптацией источников. Модуль `compositions` может владеть представлением страницы или экрана и использовать готовый доменный API, но не становится владельцем сценария из-за места вызова, единственного потребителя или отсутствия уже созданного доменного модуля. Подробная граница описана в разделе [Слои](./layers.md#граница-доменов-и-композиций).
|
||||||
|
|
||||||
|
Размер не определяет модуль. Модуль может состоять из одного файла реализации, а большая папка может оставаться частью другого владельца.
|
||||||
|
|
||||||
|
## Ближайшая граница
|
||||||
|
|
||||||
|
Каждый файл принадлежит ближайшей модульной границе и реализует ответственность этого модуля. Вид кода не меняет владельца: framework-компонент, Provider, Guard, hook, store, service или utility остаются внутренней реализацией ближайшего модуля.
|
||||||
|
|
||||||
|
Вложенный модуль начинает новую границу. Его содержимое реализует выделенную подответственность, а сам вложенный модуль как единица участвует в реализации общего результата родителя:
|
||||||
|
|
||||||
|
```text
|
||||||
|
checkout/ # Владеет ответственностью checkout
|
||||||
|
├── checkout.tsx # Реализует checkout
|
||||||
|
├── components/ # Реализуют checkout
|
||||||
|
└── modules/
|
||||||
|
└── form-session/ # Владеет подответственностью form session
|
||||||
|
├── form-session.provider.tsx
|
||||||
|
└── hooks/ # Реализуют form session
|
||||||
|
```
|
||||||
|
|
||||||
|
Родитель и вложенный модуль не владеют одной ответственностью одновременно. Родитель определяет общий результат, а вложенный модуль — отдельно сформулированную связную часть этого результата.
|
||||||
|
|
||||||
|
## Граница владения
|
||||||
|
|
||||||
|
Модуль определяет:
|
||||||
|
|
||||||
|
- публичные возможности ответственности;
|
||||||
|
- допустимые внешние зависимости;
|
||||||
|
- модели и правила, принадлежащие ответственности;
|
||||||
|
- состояние и источник истины;
|
||||||
|
- создание и очистку долгоживущих ресурсов;
|
||||||
|
- устройство внутренней реализации.
|
||||||
|
|
||||||
|
Внутренний файл может технически выполнять часть или всю эту работу. Архитектурное владение остаётся у модуля и не переносится в компонент, Provider, store или другой механизм реализации.
|
||||||
|
|
||||||
|
## Публичный API
|
||||||
|
|
||||||
|
У модуля один логический публичный API. Он является контрактом между владельцем и внешними потребителями, а не перечнем всех внутренних файлов.
|
||||||
|
|
||||||
|
Публичный API:
|
||||||
|
|
||||||
|
- открывает только возможности, необходимые реальным внешним потребителям;
|
||||||
|
- скрывает детали реализации и изменяемые внутренние механизмы;
|
||||||
|
- не раскрывает внутренние сегменты;
|
||||||
|
- представлен объявленными публичными фасетами;
|
||||||
|
- является единственным способом доступа к модулю извне.
|
||||||
|
|
||||||
|
Внутри своей границы модуль может обращаться к собственным файлам напрямую. Требование публичного API действует при пересечении модульной границы.
|
||||||
|
|
||||||
|
### Фасеты
|
||||||
|
|
||||||
|
Фасет — публичная точка входа, открывающая часть единого API для определённой среды выполнения.
|
||||||
|
|
||||||
|
| Фасет | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `index` | Универсальные типы и исполняемый код, совместимый с серверным рендерингом, включая RSC, и клиентским выполнением |
|
||||||
|
| `client` | Клиентская граница фреймворка, участвующая в серверной предварительной отрисовке и выполняющаяся при гидратации и в браузере |
|
||||||
|
| `browser` | Browser-only и динамически подключаемый клиентский код; фасет всегда импортируется динамически с отключённым SSR |
|
||||||
|
| `server` | Код только для сервера, недоступный универсальной и клиентской среде выполнения |
|
||||||
|
|
||||||
|
`index` обязателен. Остальные фасеты создаются только при наличии реального потребителя и несовместимых требований к среде.
|
||||||
|
|
||||||
|
Ограничение среды распространяется на все исполняемые импорты и реэкспорты фасета, включая транзитивные. Имя файла, директива `use client`, tree shaking или проверка `typeof window` сами по себе не доказывают совместимость.
|
||||||
|
|
||||||
|
Один исполняемый экспорт размещается в минимально подходящем фасете и не дублируется между ними. Универсальный фасет не реэкспортирует специализированные фасеты.
|
||||||
|
|
||||||
|
```text
|
||||||
|
auth/
|
||||||
|
├── index.ts # Обязательный универсальный фасет
|
||||||
|
├── client.ts # При необходимости
|
||||||
|
├── browser.ts # При необходимости
|
||||||
|
├── server.ts # При необходимости
|
||||||
|
└── ... # Внутренняя реализация
|
||||||
|
```
|
||||||
|
|
||||||
|
Эта файловая форма представляет уже определённую публичную границу. Наличие `index.ts` само по себе не создаёт модуль.
|
||||||
|
|
||||||
|
## Зависимости
|
||||||
|
|
||||||
|
Модули одного слоя могут зависеть друг от друга. При пересечении любой модульной границы код использует публичный фасет целевого модуля, а общий граф модулей должен оставаться ацикличным.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Допустимо
|
||||||
|
import { Button } from '@/ui/button'
|
||||||
|
|
||||||
|
// Недопустимый глубокий импорт
|
||||||
|
import { Button } from '@/ui/button/button'
|
||||||
|
```
|
||||||
|
|
||||||
|
Направления между слоями, same-layer импорты, роль групп и требования к lint-проверке описаны в разделе [Зависимости](./dependencies.md).
|
||||||
|
|
||||||
|
## Корень модуля
|
||||||
|
|
||||||
|
Корень модуля не используется как плоский каталог реализации. В нём находятся:
|
||||||
|
|
||||||
|
- объявленные публичные фасеты;
|
||||||
|
- не более одного опционального главного implementation- или assembly-файла.
|
||||||
|
|
||||||
|
Главный файл непосредственно реализует или собирает ответственность модуля и однозначно соответствует ей по имени и роли. Возможные примеры:
|
||||||
|
|
||||||
|
```text
|
||||||
|
header/header.tsx
|
||||||
|
footer/footer.tsx
|
||||||
|
auth-guard/auth-guard.provider.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Архитектурным владельцем остаётся модуль, а главный файл является основной реализацией или точкой её сборки. Если проблемно доказать, что файл является главным, его не выносят в корень. Вся остальная реализация размещается в [сегментах](./segments.md).
|
||||||
|
|
||||||
|
Главный framework-файл не обязан экспортироваться через `index`. Модуль открывает его через минимально подходящий фасет среды выполнения:
|
||||||
|
|
||||||
|
```text
|
||||||
|
theme/
|
||||||
|
├── index.ts # Универсальные публичные типы
|
||||||
|
├── client.ts # Экспортирует ThemeProvider и useTheme
|
||||||
|
├── theme.provider.tsx # Главная framework-реализация
|
||||||
|
├── context/
|
||||||
|
│ └── theme-context.ts
|
||||||
|
├── hooks/
|
||||||
|
│ └── use-theme.ts
|
||||||
|
├── types/
|
||||||
|
└── styles/
|
||||||
|
```
|
||||||
|
|
||||||
|
`ThemeProvider` может хранить состояние, синхронизироваться с платформой, предоставлять Context и очищать ресурсы при размонтировании. Он технически реализует ответственность, но её смысл, контракт, зависимости и область жизни определяет модуль `theme`.
|
||||||
|
|
||||||
|
## Framework-компоненты
|
||||||
|
|
||||||
|
SLM не вводит собственный вид компонента. Модуль может содержать любые framework-компоненты: визуальные элементы, Providers, Guards, Error Boundaries и другие сущности, которые используемый фреймворк считает компонентами.
|
||||||
|
|
||||||
|
Помимо опционального главного framework-файла в корне, остальные framework-компоненты организуются на одном внутреннем уровне относительно модуля. Они могут быть одиночными файлами или каталогами с локальными стилями, типами, hooks, тестами и внутренним `index.ts`, но каталог компонентной единицы не содержит другие компонентные единицы или вложенные модули.
|
||||||
|
|
||||||
|
```text
|
||||||
|
main-layout/ # Модуль
|
||||||
|
├── index.ts # Публичный фасет
|
||||||
|
├── main-layout.tsx # Главная реализация
|
||||||
|
├── components/ # Сегмент
|
||||||
|
│ ├── header/
|
||||||
|
│ │ ├── index.ts # Локальная точка входа
|
||||||
|
│ │ ├── header.tsx
|
||||||
|
│ │ ├── styles/
|
||||||
|
│ │ └── types/
|
||||||
|
│ ├── navigation-item.tsx
|
||||||
|
│ └── footer.tsx
|
||||||
|
└── providers/ # Сегмент
|
||||||
|
└── layout-state/
|
||||||
|
├── layout-state.provider.tsx
|
||||||
|
├── hooks/
|
||||||
|
└── types/
|
||||||
|
```
|
||||||
|
|
||||||
|
`Header` может рендерить `NavigationItem`, но их файловые области остаются соседними относительно `main-layout`. Ограничение относится к организации файлов, а не к runtime-дереву фреймворка.
|
||||||
|
|
||||||
|
Локальный `index.ts` компонентной единицы упрощает импорты внутри модуля, но не создаёт публичный API, владельца или узел графа зависимостей. Если компонент нужен внешнему потребителю, фасет модуля явно реэкспортирует его, а внешний код по-прежнему импортирует модуль.
|
||||||
|
|
||||||
|
Названия `components`, `providers`, `styles`, `types` и `hooks` являются примерами локального стайлгайда, а не обязательными путями SLM.
|
||||||
|
|
||||||
|
## Вложенные модули
|
||||||
|
|
||||||
|
Вложенный модуль — обычный модуль, физически размещённый внутри родительского. Он владеет отдельно сформулированной связной подответственностью, имеет публичный API, собственные зависимости и область жизни и подчиняется всем правилам модулей.
|
||||||
|
|
||||||
|
```text
|
||||||
|
checkout/ # Родительский модуль
|
||||||
|
├── index.ts
|
||||||
|
├── checkout.tsx # Главная реализация checkout
|
||||||
|
├── components/
|
||||||
|
│ ├── order-summary.tsx
|
||||||
|
│ └── submit-order.tsx
|
||||||
|
└── modules/
|
||||||
|
└── form-session/ # Вложенный модуль
|
||||||
|
├── index.ts # Универсальные публичные типы
|
||||||
|
├── client.ts # Экспортирует Provider и hook
|
||||||
|
├── form-session.provider.tsx # Главная реализация form session
|
||||||
|
├── hooks/
|
||||||
|
│ └── use-form-session.ts
|
||||||
|
└── types/
|
||||||
|
```
|
||||||
|
|
||||||
|
Framework-компонент не превращается в архитектурную сущность. Если окружающему его коду требуется самостоятельная ответственность, вокруг кода создаётся вложенный модуль, а компонент остаётся его обычной framework-реализацией.
|
||||||
|
|
||||||
|
Код родительского модуля использует вложенный модуль через его публичный API. Код за пределами родительской границы не импортирует вложенный модуль напрямую и получает необходимые возможности через API родителя.
|
||||||
|
|
||||||
|
Вложенный модуль может рекурсивно содержать другие вложенные модули. Именно модульная граница, а не каталог компонента, создаёт каждый следующий структурный уровень. Если вложенный модуль становится нужен нескольким внешним владельцам, его переносят в минимальную общую область.
|
||||||
|
|
||||||
|
## Колокация и рост
|
||||||
|
|
||||||
|
Код создаётся в самой узкой допустимой области внутри своего архитектурного владельца:
|
||||||
|
|
||||||
|
1. Вспомогательный код, нужный одной компонентной единице, колоцируется в её файле или каталоге.
|
||||||
|
2. Каждая выделенная framework-компонентная единица размещается на общем внутреннем уровне модуля, даже если используется только одним другим компонентом.
|
||||||
|
3. Код, нужный нескольким внутренним единицам, поднимается в их ближайший общий сегмент.
|
||||||
|
4. При появлении самостоятельной подответственности вокруг кода создаётся вложенный модуль.
|
||||||
|
5. Вложенный модуль переносится в общую область, когда прямой доступ к нему требуется внешним владельцам.
|
||||||
|
|
||||||
|
Расширение числа потребителей меняет колокацию внутри владельца. Появление самостоятельной ответственности меняет модульный граф.
|
||||||
|
|
||||||
|
## Состояние и жизненный цикл
|
||||||
|
|
||||||
|
Состояние принадлежит модулю, ответственность которого определяет его смысл, допустимые изменения и область жизни. Место хранения состояния не переносит владение в store, Provider или Context.
|
||||||
|
|
||||||
|
Для каждого долгоживущего ресурса модуль-владелец определяет:
|
||||||
|
|
||||||
|
- место создания;
|
||||||
|
- момент запуска;
|
||||||
|
- область жизни;
|
||||||
|
- допустимое число экземпляров;
|
||||||
|
- способ остановки, отмены или освобождения.
|
||||||
|
|
||||||
|
Подписка, обработчик событий, таймер, наблюдатель, запрос или соединение не остаются активными после завершения своей области жизни. Очистку может технически выполнить framework-компонент или сам фреймворк, но ответственность за корректную границу остаётся у модуля.
|
||||||
|
|
||||||
|
Размещение экземпляра на уровне файла не доказывает область жизни всего приложения. Точка входа `app` может запустить ресурс через публичный API модуля, но не становится его владельцем.
|
||||||
|
|
||||||
|
## Связанные правила
|
||||||
|
|
||||||
|
- [`SLM-MODULE-A004`](../rules/registry.md#slm-module-a004)
|
||||||
|
- [`SLM-MODULE-A014`](../rules/registry.md#slm-module-a014)
|
||||||
|
- [`SLM-DOMAIN-R022`](../rules/registry.md#slm-domain-r022)
|
||||||
|
- [`SLM-DOMAIN-R023`](../rules/registry.md#slm-domain-r023)
|
||||||
|
- [`SLM-DOMAIN-R024`](../rules/registry.md#slm-domain-r024)
|
||||||
|
- [`SLM-DOMAIN-R025`](../rules/registry.md#slm-domain-r025)
|
||||||
|
- [`SLM-DOMAIN-R026`](../rules/registry.md#slm-domain-r026)
|
||||||
|
- [`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-MODULE-R020`](../rules/registry.md#slm-module-r020)
|
||||||
|
- [`SLM-MODULE-R021`](../rules/registry.md#slm-module-r021)
|
||||||
|
- [`SLM-DEPENDENCY-A005`](../rules/registry.md#slm-dependency-a005)
|
||||||
|
- [`SLM-NESTED_MODULE-A010`](../rules/registry.md#slm-nested_module-a010)
|
||||||
|
- [`SLM-LIFECYCLE-R013`](../rules/registry.md#slm-lifecycle-r013)
|
||||||
|
- [`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)
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Сегменты
|
||||||
|
|
||||||
|
Сегмент организует внутреннее содержимое одного модуля по назначению. Он помогает ориентироваться в реализации владельца, но не создаёт новую ответственность или архитектурную границу.
|
||||||
|
|
||||||
|
## Место в модели
|
||||||
|
|
||||||
|
Сегмент появляется только внутри уже определённого модуля:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Слой → [Группа*] → Модуль → [Сегмент*]
|
||||||
|
```
|
||||||
|
|
||||||
|
Группа классифицирует модули внутри слоя. Сегмент классифицирует код одного модуля. Ни группа, ни сегмент не являются владельцами.
|
||||||
|
|
||||||
|
Все файлы, framework-компоненты, состояние, зависимости и lifecycle-код сегмента принадлежат ближайшему модулю. Исключением является только вложенный модуль, который начинает собственную границу владения.
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
|
||||||
|
Сегмент используется, когда группировка внутренних файлов по назначению упрощает навигацию. Набор и названия сегментов определяет проект.
|
||||||
|
|
||||||
|
```text
|
||||||
|
profile/
|
||||||
|
├── index.ts # Публичный фасет
|
||||||
|
├── profile.tsx # Главная реализация
|
||||||
|
├── components/ # Возможный сегмент
|
||||||
|
├── hooks/ # Возможный сегмент
|
||||||
|
├── services/ # Возможный сегмент
|
||||||
|
├── stores/ # Возможный сегмент
|
||||||
|
├── types/ # Возможный сегмент
|
||||||
|
└── styles/ # Возможный сегмент
|
||||||
|
```
|
||||||
|
|
||||||
|
Ни один сегмент не создаётся заранее. Модуль может обойтись без сегментов, если помимо публичных фасетов содержит только один главный implementation- или assembly-файл, однозначно выражающий его ответственность. Любая остальная реализация размещается в подходящих сегментах. Если главный файл нельзя определить уверенно, вся реализация остаётся в сегментах.
|
||||||
|
|
||||||
|
Сегмент:
|
||||||
|
|
||||||
|
- не имеет самостоятельной ответственности;
|
||||||
|
- не предоставляет публичный API;
|
||||||
|
- не владеет состоянием или жизненным циклом;
|
||||||
|
- не является узлом графа зависимостей;
|
||||||
|
- не импортируется внешним кодом как отдельная архитектурная сущность.
|
||||||
|
|
||||||
|
Локальный `index.ts` может использоваться во внутренней единице сегмента. Он не превращает эту единицу или сегмент в модульную границу.
|
||||||
|
|
||||||
|
## Framework-компоненты
|
||||||
|
|
||||||
|
Framework-компоненты являются обычным внутренним кодом модуля. Они могут выполнять визуальные и невизуальные роли, включая Provider, Guard или Error Boundary, если используемый фреймворк считает соответствующую сущность компонентом.
|
||||||
|
|
||||||
|
Помимо опционального главного framework-файла в корне, остальные компонентные единицы размещаются на одном внутреннем уровне относительно модуля. Каталог такой единицы может содержать локальные `styles`, `types`, `hooks`, `tests` и внутренний `index.ts`, но не содержит другие компонентные единицы или вложенные модули.
|
||||||
|
|
||||||
|
```text
|
||||||
|
header/ # Модуль
|
||||||
|
├── index.ts # Публичный фасет
|
||||||
|
├── header.tsx # Главная реализация
|
||||||
|
└── components/ # Сегмент
|
||||||
|
├── button-submit/
|
||||||
|
│ ├── index.ts # Локальная точка входа
|
||||||
|
│ ├── button-submit.tsx
|
||||||
|
│ ├── styles/
|
||||||
|
│ ├── types/
|
||||||
|
│ └── hooks/
|
||||||
|
└── icon.tsx # Соседняя компонентная единица
|
||||||
|
```
|
||||||
|
|
||||||
|
`ButtonSubmit` может рендерить `Icon`, но их файловые области не вкладываются друг в друга. Ограничение относится к файловой структуре, а не к runtime-дереву.
|
||||||
|
|
||||||
|
Внешний код не импортирует `components/button-submit`. Если компонент нужен снаружи, модуль-владелец реэкспортирует его через собственный публичный фасет.
|
||||||
|
|
||||||
|
## Вложенные модули
|
||||||
|
|
||||||
|
Сегмент может содержать вложенные модули. В отличие от остальных файлов сегмента, каждый вложенный модуль владеет отдельно сформулированной подответственностью и имеет публичный API и границу зависимостей.
|
||||||
|
|
||||||
|
```text
|
||||||
|
landing/ # Родительский модуль
|
||||||
|
└── modules/ # Сегмент
|
||||||
|
└── hero/ # Вложенный модуль
|
||||||
|
├── index.ts # Публичный фасет вложенного модуля
|
||||||
|
├── hero.tsx # Главная реализация hero
|
||||||
|
└── modules/ # Допустимая модульная рекурсия
|
||||||
|
└── media/
|
||||||
|
└── index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Компонентный каталог не содержит `components` или `modules`. Рекурсивная структурная вложенность допускается только через вложенные модули. Имена `components` и `modules` являются примерами локального стайлгайда, а не обязательными соглашениями SLM.
|
||||||
|
|
||||||
|
## Выбор размещения
|
||||||
|
|
||||||
|
| Ситуация | Решение |
|
||||||
|
|---|---|
|
||||||
|
| Код относится к существующему владельцу и группируется по назначению | Сегмент |
|
||||||
|
| Вспомогательный код нужен только одной компонентной единице | Колоцировать в её локальном каталоге |
|
||||||
|
| Выделена отдельная framework-компонентная единица | Разместить на общем внутреннем уровне модуля |
|
||||||
|
| Код нужен нескольким внутренним единицам модуля | Поднять в ближайший общий сегмент |
|
||||||
|
| Появилась самостоятельная связная подответственность | Создать вложенный модуль |
|
||||||
|
| Файл однозначно является главной реализацией или сборкой ответственности | Допустимо разместить в корне модуля |
|
||||||
|
| Файл не является главным или его роль неоднозначна | Разместить в подходящем сегменте |
|
||||||
|
|
||||||
|
Размер каталога и количество файлов не определяют модульную границу. Её создаёт только самостоятельная ответственность и назначение нового владельца.
|
||||||
|
|
||||||
|
## Связанные правила
|
||||||
|
|
||||||
|
- [`SLM-SEGMENT-R008`](../rules/registry.md#slm-segment-r008)
|
||||||
|
- [`SLM-MODULE-A004`](../rules/registry.md#slm-module-a004)
|
||||||
|
- [`SLM-MODULE-R020`](../rules/registry.md#slm-module-r020)
|
||||||
|
- [`SLM-MODULE-R021`](../rules/registry.md#slm-module-r021)
|
||||||
|
- [`SLM-NESTED_MODULE-A010`](../rules/registry.md#slm-nested_module-a010)
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# Терминология SLM
|
||||||
|
|
||||||
|
Этот документ задаёт нормативный смысл терминов. Определения используются при толковании архитектуры и правил, но сами по себе не являются отдельными правилами.
|
||||||
|
|
||||||
|
## Владение
|
||||||
|
|
||||||
|
### SLM root
|
||||||
|
|
||||||
|
Граница структурной архитектуры одного приложения. Внутри неё определяются владельцы ответственностей, слои, модули и их зависимости.
|
||||||
|
|
||||||
|
### Ответственность
|
||||||
|
|
||||||
|
Результат или поведение приложения, за которое отвечает один модуль-владелец. Ответственность является самостоятельной, когда ей нужны собственный публичный контракт, зависимости, состояние или область жизни, а не только внутренняя роль в работе другого модуля. Наличие у framework-сущности props, импортов, локального состояния или lifecycle-кода само по себе не создаёт самостоятельную ответственность.
|
||||||
|
|
||||||
|
### Доменный сценарий
|
||||||
|
|
||||||
|
Продуктово значимое поведение, сформулированное в предметных терминах и приводящее к предметному результату. Его владелец определяет модели, правила, переходы, продуктовое состояние, смысл операций с продуктовыми данными, допустимые исходы и доменный UI. Количество потребителей, текущая страница и технический механизм выполнения не меняют принадлежность сценария.
|
||||||
|
|
||||||
|
Техническая возможность, которую сценарий получает через публичный API другого модуля, сохраняет собственного владельца. Например, `infra` может владеть HTTP-транспортом или доставкой телеметрии, но смысл продуктовой операции и доменного события остаётся у доменного сценария.
|
||||||
|
|
||||||
|
### Доменный UI
|
||||||
|
|
||||||
|
UI-код, чьи данные, действия, состояния или исходы выражены в терминах одного домена и представляют либо запускают его сценарий. Доменный UI является частью реализации доменной ответственности даже тогда, когда используется только одной страницей. Универсальные визуальные элементы принадлежат `ui`, а размещение и связывание готовых доменных API в страницу или экран принадлежит `compositions`.
|
||||||
|
|
||||||
|
### Владелец
|
||||||
|
|
||||||
|
Модуль, который определяет публичный API ответственности, её зависимости, состояние, область жизни и внутреннюю реализацию. Каждый файл принадлежит ближайшей модульной границе и реализует ответственность этого модуля. Место выполнения кода или вид framework-сущности не переносит владение.
|
||||||
|
|
||||||
|
## Структурные сущности
|
||||||
|
|
||||||
|
### Слой
|
||||||
|
|
||||||
|
Архитектурная роль кода внутри SLM root. Слой классифицирует владельцев по назначению и ограничивает допустимые направления зависимостей. Нормативные роли и матрица определены в разделе [Слои](../architecture/layers.md).
|
||||||
|
|
||||||
|
### Группа
|
||||||
|
|
||||||
|
Необязательный навигационный классификатор модулей внутри одного слоя или другой группы. Группа не является владельцем, публичным API или границей зависимостей.
|
||||||
|
|
||||||
|
### Модуль
|
||||||
|
|
||||||
|
Минимальная самостоятельная архитектурная единица SLM. Модуль владеет одной связной ответственностью, имеет публичный API и физически размещается в отдельной папке.
|
||||||
|
|
||||||
|
### Домен
|
||||||
|
|
||||||
|
Специализированный модуль слоя `domains`, владеющий одной связной предметной ответственностью и её сценариями. Домен самостоятельно определяет публичный доменный контракт, ожидаемые неуспешные исходы, продуктовое состояние, доменный UI и адаптацию внешних данных и ошибок. Он остаётся обычным узлом модульного графа, подчиняется всем правилам модулей и не создаёт дополнительный контейнерный уровень.
|
||||||
|
|
||||||
|
### Сегмент
|
||||||
|
|
||||||
|
Необязательная внутренняя часть одного модуля, группирующая его содержимое по назначению. Сегмент не является владельцем, публичным API или границей зависимостей.
|
||||||
|
|
||||||
|
### Вложенный модуль
|
||||||
|
|
||||||
|
Обычный модуль, физически размещённый внутри родительского модуля. Он владеет отдельно сформулированной связной частью ответственности родителя, имеет публичный API и собственную границу зависимостей. Родитель владеет общим результатом, а вложенный модуль — выделенной подответственностью; одна и та же ответственность не получает двух владельцев.
|
||||||
|
|
||||||
|
Для кода за пределами родительской границы вложенный модуль остаётся внутренней реализацией родителя. Внутри вложенного модуля снова действуют все правила обычного модуля, поэтому рекурсивная структурная вложенность создаётся только модульными границами.
|
||||||
|
|
||||||
|
## Публичная граница
|
||||||
|
|
||||||
|
### Публичный API
|
||||||
|
|
||||||
|
Единый логический контракт внешнего доступа к модулю. Он скрывает внутреннюю реализацию и физически представлен обязательным фасетом `index` и только необходимыми фасетами `client`, `browser` и `server`.
|
||||||
|
|
||||||
|
### Доменный контракт
|
||||||
|
|
||||||
|
Принадлежащая домену предметная форма его публичного API: принимаемые значения, возвращаемые модели и результаты, события, доступные потребителям состояния и ожидаемые неуспешные исходы. Доменный контракт определяется смыслом сценариев и не выводится из DTO, схемы, SDK или типов источника данных.
|
||||||
|
|
||||||
|
### Фасет
|
||||||
|
|
||||||
|
Объявленная публичная точка входа модуля, открывающая часть его единого API для определённой среды выполнения. Импорт фасета не является глубоким импортом; любой другой внешний путь внутрь модуля остаётся внутренним.
|
||||||
|
|
||||||
|
### Глубокий импорт
|
||||||
|
|
||||||
|
Импорт или реэкспорт внутреннего пути чужого модуля, который не объявлен его публичным фасетом.
|
||||||
|
|
||||||
|
## Граница источника данных
|
||||||
|
|
||||||
|
### Контракт источника
|
||||||
|
|
||||||
|
Техническая форма обмена с внешним сервисом, SDK, storage или другим источником данных. К ней относятся request и response DTO, source-specific enum, nullable semantics, статусы, payload и типы ошибок. Контракт источника не является доменным контрактом даже при полном структурном совпадении.
|
||||||
|
|
||||||
|
### DTO
|
||||||
|
|
||||||
|
Значение или тип контракта источника, предназначенный для передачи данных через техническую границу. DTO допускается во внутреннем интеграционном коде домена, но не используется как публичная модель, продуктовое состояние или значение доменного UI.
|
||||||
|
|
||||||
|
### Mapper
|
||||||
|
|
||||||
|
Один из возможных внутренних механизмов адаптации источника: функция или связный набор функций, преобразующий контракт источника в доменный контракт либо доменное значение в контракт запроса. Адаптация принадлежит доменному владельцу, но SLM не требует использовать mapper как конкретный паттерн, имя или файловую единицу.
|
||||||
|
|
||||||
|
## Доменные ошибки
|
||||||
|
|
||||||
|
### Доменная ошибка
|
||||||
|
|
||||||
|
Ожидаемый неуспешный исход доменного сценария, смысл и публичный контракт которого определены текущим доменом. Способ представления и передачи такого исхода, включая exception, `Result`, union или другую форму, SLM не устанавливает.
|
||||||
|
|
||||||
|
Доменная ошибка не является технической ошибкой источника. Чужой тип ошибки, source code, message, transport status, raw payload, `cause` и диагностические данные не входят в доменный контракт в исходной форме.
|
||||||
|
|
||||||
|
Реализация домена создаёт только исходы, объявленные доменным контрактом. Интеграционный или framework-код использует эту декларацию и не становится отдельным владельцем ошибок.
|
||||||
|
|
||||||
|
### Runtime-идентификация доменной ошибки
|
||||||
|
|
||||||
|
Публичная capability, позволяющая реальному потребителю отличить доменную ошибку от другого runtime-значения. Она может быть реализована constructor-ом, marker-ом, guard-ом, parser-ом, schema или иным способом, подходящим среде потребителя. SLM не требует такую capability для каждого домена и не устанавливает её форму.
|
||||||
|
|
||||||
|
### Неожиданный дефект
|
||||||
|
|
||||||
|
Неуспешное выполнение, которое не объявлено ожидаемым исходом доменного сценария и обрабатывается согласно общей политике приложения. Способ доставки и диагностики дефекта SLM не устанавливает, но техническая ошибка чужого источника не становится частью публичного API домена в исходной форме.
|
||||||
|
|
||||||
|
## Зависимости
|
||||||
|
|
||||||
|
### Зависимость
|
||||||
|
|
||||||
|
Направленная статическая связь между архитектурными границами внутри одного SLM root. Обычный импорт, импорт типа (`import type`) и реэкспорт одинаково создают архитектурную зависимость.
|
||||||
|
|
||||||
|
Зависимость внутреннего файла или сегмента относится к ближайшему модулю-владельцу. Вложенный модуль начинает собственную границу зависимостей.
|
||||||
|
|
||||||
|
### Нормативная матрица слоёв
|
||||||
|
|
||||||
|
Отношение допустимой зависимости между слоями одного SLM root. Матрица определяет доступные целевые роли, но не требует проходить через каждый промежуточный слой.
|
||||||
|
|
||||||
|
## Жизненный цикл
|
||||||
|
|
||||||
|
### Область жизни
|
||||||
|
|
||||||
|
Период, в течение которого принадлежащие модулю состояние или долгоживущий ресурс должны оставаться активными.
|
||||||
|
|
||||||
|
### Ресурс жизненного цикла
|
||||||
|
|
||||||
|
Ресурс, работа которого продолжается после первоначального вызова и требует остановки, отмены, отписки или освобождения. Например, подписка, обработчик событий, таймер, наблюдатель, запрос или соединение.
|
||||||
|
|
||||||
|
### Очистка
|
||||||
|
|
||||||
|
Гарантированное прекращение работы ресурса не позже завершения его области жизни. Автоматическая очистка фреймворка считается очисткой владельца, если модуль устанавливает и контролирует соответствующую границу.
|
||||||
|
|
||||||
|
## Немодульные единицы
|
||||||
|
|
||||||
|
### Точка входа фреймворка
|
||||||
|
|
||||||
|
Специальная немодульная единица слоя `app`, которая запускает приложение, объявляет точку маршрута, преобразует внешние входные данные или подключает готовые публичные API.
|
||||||
|
|
||||||
|
### Ресурс shared
|
||||||
|
|
||||||
|
Небольшая детерминированная единица слоя `shared`, не зависящая от продукта и не скрывающая отдельного внутреннего устройства. У неё нет изменяемого состояния, ввода-вывода, области жизни или собственного публичного API.
|
||||||
|
|
||||||
|
Путь и имя сами по себе не определяют ни одну из перечисленных сущностей. Физическое сопоставление задаётся стайлгайдом или конфигурацией проверки после определения ответственности и владельца.
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# Проверка архитектуры
|
||||||
|
|
||||||
|
Проверка SLM подтверждает две разные стороны решения:
|
||||||
|
|
||||||
|
- смысловая проверка устанавливает ответственность, владельца и корректность границ;
|
||||||
|
- структурная проверка подтверждает, что решение правильно выражено путями, публичными фасетами и зависимостями.
|
||||||
|
|
||||||
|
Успешная сборка или корректно отображаемый интерфейс не доказывают архитектурную корректность.
|
||||||
|
|
||||||
|
## Карточка решения
|
||||||
|
|
||||||
|
Перед изменением структуры нужно ответить:
|
||||||
|
|
||||||
|
| Вопрос | Что зафиксировать |
|
||||||
|
|---|---|
|
||||||
|
| Ответственность | Какой результат или поведение изменяется как единое целое |
|
||||||
|
| Доменный сценарий | Какой модуль `domains` владеет предметным результатом и через какой API его используют внешние потребители, включая композиции |
|
||||||
|
| Доменный контракт | Какие входы, модели и результаты определены предметным смыслом независимо от источника данных |
|
||||||
|
| Доменные ошибки | Какие ожидаемые неуспешные исходы определяет домен и где проходит граница с programming defects |
|
||||||
|
| Граница источника | Какие внешние контракты получает домен и как они адаптируются к предметному смыслу |
|
||||||
|
| Владелец | Какой модуль определяет контракт и внутреннюю реализацию |
|
||||||
|
| Слой | Какой архитектурной роли соответствует ответственность |
|
||||||
|
| Потребители | Кому действительно нужен публичный API |
|
||||||
|
| Зависимости | Какие другие владельцы и возможности необходимы |
|
||||||
|
| Состояние | Кто определяет смысл и допустимые изменения данных |
|
||||||
|
| Жизненный цикл | Кто создаёт ресурсы, какова их область жизни и очистка |
|
||||||
|
| Физическая форма | Какими путями и фасетами представлено принятое решение |
|
||||||
|
|
||||||
|
Если ответственность или владелец не определены, проверка путей откладывается: одинаковая файловая структура может представлять разные архитектурные решения.
|
||||||
|
|
||||||
|
## Архитектурное ревью
|
||||||
|
|
||||||
|
На ревью проверяется смысл кода, который нельзя надёжно вывести из файловой системы:
|
||||||
|
|
||||||
|
- одна ли связная ответственность находится внутри модульной границы;
|
||||||
|
- есть ли у каждой самостоятельной ответственности ровно один ближайший владелец;
|
||||||
|
- принадлежит ли каждый доменный сценарий модулю слоя `domains`;
|
||||||
|
- находятся ли бизнес-правила, продуктовое состояние, смысл операций с предметными данными, предметные исходы и доменный UI у владельца сценария;
|
||||||
|
- только ли использует и компонует модуль `compositions` готовые публичные API доменов, не определяя и не дополняя их сценарии;
|
||||||
|
- не размещён ли сценарий временно в композиции только потому, что подходящий доменный модуль ещё не создан;
|
||||||
|
- не скрывает ли связывание нескольких доменных API новый порядок, условие, общий предметный результат или политику ошибок;
|
||||||
|
- обслуживает ли прямое использование `infra` собственную техническую потребность композиции, а не продуктовую операцию или доступ к предметным данным;
|
||||||
|
- соответствует ли ответственность роли выбранного слоя;
|
||||||
|
- владеет ли вложенный модуль отдельно сформулированной подответственностью;
|
||||||
|
- не стали ли группа или сегмент скрытыми владельцами;
|
||||||
|
- реализует ли внутренний код ответственность ближайшего модуля;
|
||||||
|
- не принимаются ли props, Context, локальное состояние или lifecycle-код за достаточное основание для новой модульной границы;
|
||||||
|
- является ли главный файл в корне однозначной реализацией или сборкой ответственности;
|
||||||
|
- не лежат ли прочие файлы реализации в корне вместо подходящих сегментов;
|
||||||
|
- нужен ли каждый экспорт реальному внешнему потребителю;
|
||||||
|
- не раскрывает ли публичный API изменяемые внутренние механизмы;
|
||||||
|
- определены ли владелец, область жизни, число экземпляров и очистка каждого ресурса.
|
||||||
|
|
||||||
|
Окончательные смысловые требования имеют класс `R` в [реестре правил](../rules/registry.md).
|
||||||
|
|
||||||
|
Вызовы `fetch`, HTTP-клиента, SDK, query client или storage внутри `compositions` являются сигналами для проверки, но не самостоятельным доказательством нарушения. Ревью устанавливает, обслуживает ли вызов техническую ответственность самой композиции или реализует доменный сценарий в обход его владельца.
|
||||||
|
|
||||||
|
## Проверка домена
|
||||||
|
|
||||||
|
Для каждого создаваемого или изменяемого домена дополнительно проверяется:
|
||||||
|
|
||||||
|
- остаётся ли домен одним специализированным модулем и узлом графа, а не неявным контейнером нескольких владельцев;
|
||||||
|
- объявлены ли входы, модели и результаты самим доменом до подключения источника;
|
||||||
|
- можно ли описать доменный контракт без упоминания endpoint, SDK, DTO или схемы внешнего сервиса;
|
||||||
|
- не выведен ли публичный тип через alias, наследование, `Pick`, `Omit`, `ReturnType` или другой source type;
|
||||||
|
- определены ли ожидаемые неуспешные исходы самим доменом;
|
||||||
|
- создаёт ли реализация только исходы, объявленные доменным контрактом;
|
||||||
|
- не определяют ли mapper, adapter или framework-код независимые ошибки параллельно декларации домена;
|
||||||
|
- зависит ли потребитель только от error contract текущего домена независимо от выбранной формы его представления;
|
||||||
|
- не выдаётся ли project policy о code, payload, union или casing за универсальное правило SLM;
|
||||||
|
- отсутствуют ли в публичной ошибке чужие error type, source code, message, transport status, raw payload и `cause`;
|
||||||
|
- адаптируются ли request и response источника выбранным внутренним механизмом;
|
||||||
|
- попадают ли в правила, состояние и доменный UI только значения доменного контракта;
|
||||||
|
- интерпретируется ли каждая ошибка источника и зависимого домена в терминах текущего сценария;
|
||||||
|
- нужен ли runtime-механизм идентификации реальным потребителям и совместим ли он с их средой выполнения;
|
||||||
|
- не экспортируется ли constructor, guard, parser или schema без доказанной потребности;
|
||||||
|
- не замаскирована ли programming defect под ожидаемую доменную ошибку.
|
||||||
|
|
||||||
|
Прямой импорт source type во внутренний код адаптации сам по себе допустим. Нарушением является его достижимость из публичного фасета, использование как доменной модели или состояния либо передача потребителю без преобразования.
|
||||||
|
|
||||||
|
Интеграционный код ревьюится только после определения доменного контракта и семантики ожидаемых ошибок. Запрос к реальному источнику не считается допустимой временной реализацией домена, если предметная граница ещё не объявлена.
|
||||||
|
|
||||||
|
## Автоматическая проверка
|
||||||
|
|
||||||
|
Проект сопоставляет физические пути с SLM root, слоями, группами, модулями, вложенными модулями, сегментами, фасетами, точками входа `app` и ресурсами `shared`. Сопоставление задаётся локальной конфигурацией и не меняет смысл сущностей.
|
||||||
|
|
||||||
|
Наличие `index.ts` само по себе не объявляет модуль. Проверка отличает объявленные корни модулей и их публичные фасеты от локальных точек входа и других внутренних единиц.
|
||||||
|
|
||||||
|
Автоматически проверяются:
|
||||||
|
|
||||||
|
- допустимое направление импортов по матрице слоёв;
|
||||||
|
- отдельная папка каждого модуля;
|
||||||
|
- доступ к чужому модулю только через объявленные фасеты;
|
||||||
|
- отсутствие циклов в свёрнутом модульном графе;
|
||||||
|
- отсутствие прямого внешнего доступа к вложенным модулям;
|
||||||
|
- допустимый транзитивный граф исполняемых импортов каждого фасета среды выполнения;
|
||||||
|
- динамическое подключение `browser`-фасета с отключённым SSR.
|
||||||
|
|
||||||
|
Каждое правило класса `A` должно полностью блокировать проверку при нарушении. SLM не требует конкретного lint-инструмента.
|
||||||
|
|
||||||
|
## Проверка зависимостей
|
||||||
|
|
||||||
|
Для каждого внешнего импорта определяется:
|
||||||
|
|
||||||
|
1. Ближайший модуль-владелец исходного файла.
|
||||||
|
2. Ближайший модуль-владелец целевого файла.
|
||||||
|
3. Слои исходного и целевого владельцев.
|
||||||
|
4. Публичный фасет, через который выполнен импорт.
|
||||||
|
5. Отсутствие цикла после добавления межмодульного ребра.
|
||||||
|
|
||||||
|
Импорты типов (`import type`) и реэкспорты проверяются как архитектурные связи. Импорты файлов одного модуля сворачиваются и не создают межмодульного ребра. Вложенный модуль считается отдельным узлом.
|
||||||
|
|
||||||
|
File-level проверка не заменяет модульную: два модуля могут зависеть друг от друга через разные файлы без замкнутого пути между конкретными файлами. Полный алгоритм описан в разделе [Зависимости](../architecture/dependencies.md#запрет-циклов).
|
||||||
|
|
||||||
|
## Проверка внутренней структуры
|
||||||
|
|
||||||
|
Ревью или дополнительный project lint подтверждают:
|
||||||
|
|
||||||
|
- помимо опционального главного framework-файла, остальные компонентные единицы размещены на одном внутреннем уровне модуля;
|
||||||
|
- их локальные каталоги не содержат другие компонентные единицы или вложенные модули;
|
||||||
|
- runtime-дерево компонентов не используется как файловая иерархия;
|
||||||
|
- рекурсивная структурная вложенность проходит только через вложенные модули;
|
||||||
|
- в корне модуля находятся только фасеты и опциональный главный implementation- или assembly-файл;
|
||||||
|
- остальная реализация организована сегментами.
|
||||||
|
|
||||||
|
## Проверка фасетов
|
||||||
|
|
||||||
|
Совместимость фасета определяется всем достижимым исполняемым кодом, а не только его собственным файлом.
|
||||||
|
|
||||||
|
Проверка подтверждает:
|
||||||
|
|
||||||
|
- `index` не достигает `client`, `browser` или `server`;
|
||||||
|
- `client` не достигает `browser` или `server`;
|
||||||
|
- `browser` и `server` не достигают друг друга;
|
||||||
|
- `browser` экспортирует только browser-only или предназначенный для динамического подключения клиентский код;
|
||||||
|
- `browser` доступен только через поддерживаемую динамическую границу без SSR;
|
||||||
|
- специализированный фасет существует ради реального потребителя;
|
||||||
|
- один исполняемый экспорт не дублируется между фасетами.
|
||||||
|
|
||||||
|
Импорт типа остаётся архитектурной зависимостью, но не добавляет исполняемый код в среду фасета.
|
||||||
|
|
||||||
|
## Критерий завершения
|
||||||
|
|
||||||
|
Изменение соответствует SLM, когда одновременно выполнены условия:
|
||||||
|
|
||||||
|
- ответственность и единственный ближайший владелец определены;
|
||||||
|
- каждый доменный сценарий целиком принадлежит модулю `domains` и используется композициями только через его публичный API;
|
||||||
|
- отсутствие готового доменного модуля не привело к временной реализации сценария в `compositions`;
|
||||||
|
- междоменная координация с собственным продуктовым результатом получила доменного владельца;
|
||||||
|
- каждый домен остаётся специализированным модулем и самостоятельно объявляет предметный контракт;
|
||||||
|
- публичный API домена не содержит DTO, source types или чужие error contracts;
|
||||||
|
- все внешние значения адаптированы к доменному контракту до использования в правилах, состоянии или доменном UI;
|
||||||
|
- ожидаемые неуспешные исходы определены текущим доменом независимо от способа их представления;
|
||||||
|
- реализация и интеграции используют только исходы, объявленные доменным контрактом;
|
||||||
|
- ошибки источников и зависимых доменов не пересекают публичную границу в исходной форме;
|
||||||
|
- runtime-идентификация предоставлена только при наличии реального потребителя и совместима с его средой;
|
||||||
|
- роль слоя соответствует ответственности;
|
||||||
|
- публичный API минимален и используется всеми внешними потребителями;
|
||||||
|
- зависимости разрешены и не образуют модульных циклов;
|
||||||
|
- группа и сегменты не подменяют модульную границу;
|
||||||
|
- вложенные модули владеют отдельными подответственностями;
|
||||||
|
- внутренний код реализует ответственность ближайшего модуля;
|
||||||
|
- framework-компоненты имеют одноуровневую файловую организацию с единственным допустимым исключением для главного файла в корне;
|
||||||
|
- корень и сегменты соответствуют своим назначениям;
|
||||||
|
- состояние и ресурсы имеют владельца и корректную область жизни;
|
||||||
|
- физическая структура однозначно выражает принятое решение;
|
||||||
|
- применимые автоматические проверки и архитектурное ревью пройдены.
|
||||||
62
.opencode/skills/slm-design/reference/docs/rules/README.md
Normal file
62
.opencode/skills/slm-design/reference/docs/rules/README.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Правила SLM
|
||||||
|
|
||||||
|
Правило SLM задаёт один блокирующий архитектурный инвариант. Точные формулировки правил находятся только в [едином реестре](./registry.md); архитектурные главы объясняют модель и ссылаются на соответствующие коды.
|
||||||
|
|
||||||
|
## Виды утверждений
|
||||||
|
|
||||||
|
- **Определение** задаёт нормативный смысл термина.
|
||||||
|
- **Правило** задаёт блокирующее требование.
|
||||||
|
- **Рекомендация** помогает принять решение, но не является обязательной.
|
||||||
|
- **Пример** показывает один из вариантов реализации и не задаёт каркас проекта.
|
||||||
|
|
||||||
|
Определения собраны в [терминологии](../reference/terminology.md). Определение может быть обязательным для толкования правил, но не получает отдельный код.
|
||||||
|
|
||||||
|
## Код правила
|
||||||
|
|
||||||
|
```text
|
||||||
|
SLM-{group}-{class}{number}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Часть | Значение |
|
||||||
|
|---|---|
|
||||||
|
| `SLM` | Принадлежность архитектуре SLM |
|
||||||
|
| `group` | Предмет правила |
|
||||||
|
| `class` | Способ окончательной проверки: `A` или `R` |
|
||||||
|
| `number` | Глобально уникальный трёхзначный номер |
|
||||||
|
|
||||||
|
Пример: [`SLM-MODULE-A004`](./registry.md#slm-module-a004).
|
||||||
|
|
||||||
|
## Способ проверки
|
||||||
|
|
||||||
|
### Автоматические правила (`A`)
|
||||||
|
|
||||||
|
Всё требование можно однозначно проверить программно по структуре проекта, публичным путям и графу импортов. Нарушение блокирует автоматическую проверку.
|
||||||
|
|
||||||
|
### Правила для ревью (`R`)
|
||||||
|
|
||||||
|
Для окончательного решения требуется понимание ответственности, владельца, потребителей или области жизни. Инструмент может найти подозрительный код, но не заменяет архитектурное решение.
|
||||||
|
|
||||||
|
## Разделы правил
|
||||||
|
|
||||||
|
| Код | Предмет |
|
||||||
|
|---|---|
|
||||||
|
| `LAYER` | Роль слоя и направление зависимостей |
|
||||||
|
| `DOMAIN` | Владение доменными сценариями, контрактами, внешними данными и ошибками |
|
||||||
|
| `MODULE` | Ответственность, владение, публичная граница и внутренняя структура модуля |
|
||||||
|
| `DEPENDENCY` | Граф зависимостей модулей |
|
||||||
|
| `GROUP` | Навигационная группировка модулей |
|
||||||
|
| `SEGMENT` | Внутренняя организация модуля |
|
||||||
|
| `NESTED_MODULE` | Доступ к вложенному модулю |
|
||||||
|
| `LIFECYCLE` | Владение долгоживущими ресурсами |
|
||||||
|
| `ENVIRONMENT` | Совместимость публичных фасетов со средами выполнения |
|
||||||
|
|
||||||
|
Раздел правила не создаёт одноимённую главу или дополнительный уровень архитектуры. Например, `GROUP` классифицирует правило о группировке, а сама группа остаётся необязательной частью слоя.
|
||||||
|
|
||||||
|
## Требования к реестру
|
||||||
|
|
||||||
|
- Одно правило защищает один инвариант.
|
||||||
|
- Точная формулировка не повторяется в тематических документах.
|
||||||
|
- Название кратко обозначает предмет, а описание полностью формулирует требование.
|
||||||
|
- Рекомендации, обоснования и примеры не входят в формулировку правила.
|
||||||
|
- Один инвариант не получает отдельные автоматическую и ручную копии.
|
||||||
|
- Номер правила не обозначает важность и не переиспользуется после удаления.
|
||||||
165
.opencode/skills/slm-design/reference/docs/rules/registry.md
Normal file
165
.opencode/skills/slm-design/reference/docs/rules/registry.md
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
# Реестр правил SLM
|
||||||
|
|
||||||
|
Здесь собраны правила SLM. Это единственное место, где они формулируются; тематические документы объясняют архитектуру и ссылаются на коды.
|
||||||
|
|
||||||
|
## Размещение кода по слоям
|
||||||
|
|
||||||
|
### SLM-LAYER-R001
|
||||||
|
|
||||||
|
> **Назначение слоёв**
|
||||||
|
>
|
||||||
|
> Код внутри SLM root размещается в слое, нормативная роль которого соответствует ответственности этого кода.
|
||||||
|
|
||||||
|
### SLM-LAYER-A002
|
||||||
|
|
||||||
|
> **Направление зависимостей**
|
||||||
|
>
|
||||||
|
> Внутри одного SLM root код каждого слоя может зависеть только от кода целевых слоёв, разрешённых для него нормативной матрицей слоёв.
|
||||||
|
|
||||||
|
### SLM-LAYER-R003
|
||||||
|
|
||||||
|
> **Граница слоя `app`**
|
||||||
|
>
|
||||||
|
> В `app` размещаются только точки входа фреймворка для запуска, маршрутов, преобразования входных данных и подключения публичных API модулей разрешённых слоёв или ресурсов `shared`; ответственности этих модулей остаются за пределами `app`.
|
||||||
|
|
||||||
|
## Домены
|
||||||
|
|
||||||
|
### SLM-DOMAIN-R022
|
||||||
|
|
||||||
|
> **Владение доменным сценарием**
|
||||||
|
>
|
||||||
|
> Каждый доменный сценарий имеет ровно один модуль-владелец в слое `domains`. Код, который придаёт сценарию предметный смысл или определяет его продуктовый результат, включая модели, правила, переходы, продуктовое состояние, смысл операций с продуктовыми данными, предметные исходы, доменный UI и обслуживающие сценарий framework-механизмы, принадлежит этому модулю. Модули других слоёв, включая `compositions`, могут только использовать и компоновать сценарий через публичный API доменного модуля; отсутствие подходящего доменного модуля не разрешает временную или постоянную реализацию сценария вне `domains`.
|
||||||
|
|
||||||
|
### SLM-DOMAIN-R023
|
||||||
|
|
||||||
|
> **Владение доменным контрактом**
|
||||||
|
>
|
||||||
|
> Домен самостоятельно определяет публичный контракт своих сценариев на основе предметного смысла. Контракт источника данных не определяет форму доменного контракта и не становится его частью.
|
||||||
|
|
||||||
|
### SLM-DOMAIN-R024
|
||||||
|
|
||||||
|
> **Граница внешних данных**
|
||||||
|
>
|
||||||
|
> Данные и типы внешнего источника не пересекают публичную границу домена и не используются как доменные модели, состояние или результаты. Домен адаптирует внешние данные к собственному контракту до их использования в предметной реализации.
|
||||||
|
|
||||||
|
### SLM-DOMAIN-R025
|
||||||
|
|
||||||
|
> **Владение доменными ошибками**
|
||||||
|
>
|
||||||
|
> Домен объявляет публичный контракт ожидаемых неуспешных исходов своих сценариев. Реализация домена и интеграции с источниками используют этот контракт и не определяют независимые ошибки или новые исходы вне доменной декларации. Потребители зависят только от доменного контракта, а способ представления и передачи ошибок SLM не устанавливает.
|
||||||
|
|
||||||
|
### SLM-DOMAIN-R026
|
||||||
|
|
||||||
|
> **Изоляция чужих ошибок**
|
||||||
|
>
|
||||||
|
> Ошибка внешнего источника или другого модуля не пересекает публичную границу домена в исходной форме. Домен преобразует её в собственный ожидаемый исход либо в неожиданный дефект согласно общей политике приложения.
|
||||||
|
|
||||||
|
## Границы модулей
|
||||||
|
|
||||||
|
### 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-MODULE-R020
|
||||||
|
|
||||||
|
> **Глубина framework-компонентов**
|
||||||
|
>
|
||||||
|
> Помимо опционального главного framework-файла в корне модуля, остальные framework-компоненты, в том числе выполняющие роли Provider, Guard или Error Boundary, размещаются на одном внутреннем уровне относительно модуля; каталог такой единицы может содержать локальный вспомогательный код, но не содержит другие компонентные единицы или вложенные модули.
|
||||||
|
|
||||||
|
### SLM-MODULE-R021
|
||||||
|
|
||||||
|
> **Семантика корня модуля**
|
||||||
|
>
|
||||||
|
> Помимо объявленных публичных фасетов, в корне модуля допускается только один опциональный главный implementation- или assembly-файл, который однозначно отражает, непосредственно реализует или собирает ответственность модуля; вся остальная реализация размещается в сегментах.
|
||||||
|
|
||||||
|
## Зависимости между модулями
|
||||||
|
|
||||||
|
### SLM-DEPENDENCY-A005
|
||||||
|
|
||||||
|
> **Циклические зависимости**
|
||||||
|
>
|
||||||
|
> Зависимости между модулями внутри одного SLM root, включая вложенные модули, не образуют циклов.
|
||||||
|
|
||||||
|
## Назначение групп
|
||||||
|
|
||||||
|
### SLM-GROUP-R007
|
||||||
|
|
||||||
|
> **Назначение группы**
|
||||||
|
>
|
||||||
|
> Группа содержит только модули и другие группы, не владеет файлами реализации, состоянием, жизненным циклом или публичным API и не импортируется внешним кодом.
|
||||||
|
|
||||||
|
## Назначение сегментов
|
||||||
|
|
||||||
|
### SLM-SEGMENT-R008
|
||||||
|
|
||||||
|
> **Граница сегмента**
|
||||||
|
>
|
||||||
|
> Сегмент организует код только внутри одного модуля и не имеет собственной ответственности, публичного API или границы зависимостей.
|
||||||
|
|
||||||
|
## Границы вложенных модулей
|
||||||
|
|
||||||
|
### 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; потребители всегда импортируют его динамически с отключённым SSR.
|
||||||
|
|
||||||
|
### SLM-ENVIRONMENT-R019
|
||||||
|
|
||||||
|
> **Серверный фасет**
|
||||||
|
>
|
||||||
|
> Фасет `server` экспортирует только server-only код и не импортируется или реэкспортируется фасетами `index`, `client` или `browser` прямо либо транзитивно.
|
||||||
1
examples/react-vite/.env.example
Normal file
1
examples/react-vite/.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_SIMPLE_API_URL=http://localhost:3001
|
||||||
27
examples/react-vite/.gitignore
vendored
Normal file
27
examples/react-vite/.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
21
examples/react-vite/.oxlintrc.json
Normal file
21
examples/react-vite/.oxlintrc.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"no-restricted-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"patterns": [
|
||||||
|
"domains/*/*",
|
||||||
|
"infra/*/*",
|
||||||
|
"ui/*/*",
|
||||||
|
"shared/lib/*/*",
|
||||||
|
"compositions/screens/*/*",
|
||||||
|
"compositions/layouts/*/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
113
examples/react-vite/README.md
Normal file
113
examples/react-vite/README.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# SLM Store
|
||||||
|
|
||||||
|
Облегчённое React + Vite приложение по Scoped Layered Module Design. Оно использует только маленький контракт [`../demo-backend/openapi/simple.json`](../demo-backend/openapi/simple.json); `complex.json` намеренно не включён в runtime-граф.
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- JWT login, однократный конкурентный refresh и idempotent logout.
|
||||||
|
- Вход под admin и customer demo-учётными записями.
|
||||||
|
- Каталог с поиском, категориями, сортировкой и offset pagination.
|
||||||
|
- Admin create, update с optimistic locking и delete продукта.
|
||||||
|
- Draft order с фиксацией версии и цены продукта.
|
||||||
|
- Checkout, stock/currency validation, история и отмена заказов.
|
||||||
|
- Собственные доменные модели, исходы и runtime-проверка внешних ответов.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
Требуются Node.js 20+ и npm 10+.
|
||||||
|
|
||||||
|
Сначала запустите Simple backend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd examples/demo-backend
|
||||||
|
npm install
|
||||||
|
npm run dev:simple
|
||||||
|
```
|
||||||
|
|
||||||
|
Затем в отдельном терминале запустите frontend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd examples/react-vite
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend откроется на `http://localhost:5173`, backend работает на `http://localhost:3001`.
|
||||||
|
|
||||||
|
## Demo-пользователи
|
||||||
|
|
||||||
|
| Роль | Email | Пароль |
|
||||||
|
|---|---|---|
|
||||||
|
| Administrator | `admin@demo.local` | `demo1234` |
|
||||||
|
| Customer | `customer@demo.local` | `demo1234` |
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
| Переменная | Назначение | Значение по умолчанию |
|
||||||
|
|---|---|---|
|
||||||
|
| `VITE_SIMPLE_API_URL` | Base URL Simple API | `http://localhost:3001` |
|
||||||
|
|
||||||
|
Access token живёт в памяти вкладки. Refresh token хранится в `sessionStorage` и очищается вместе с session-scoped SWR cache при logout или окончательном истечении сессии.
|
||||||
|
|
||||||
|
## OpenAPI
|
||||||
|
|
||||||
|
Generated-код находится в `src/infra/simple-rest-api/generated` и не редактируется вручную. Регенерация использует зафиксированную версию `@gromlab/api-codegen`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run codegen:simple-rest-api
|
||||||
|
```
|
||||||
|
|
||||||
|
OpenAPI ошибочно описывает `page` и `limit` через пустую `Object` schema. Исправленный browser-контракт локализован внутри `infra/simple-rest-api/types`; generated-файлы остаются неизменными.
|
||||||
|
|
||||||
|
## SLM
|
||||||
|
|
||||||
|
SLM root: `src`.
|
||||||
|
|
||||||
|
| Слой | Владельцы и ответственность |
|
||||||
|
|---|---|
|
||||||
|
| `app` | Vite entry, router, application providers и cache lifecycle |
|
||||||
|
| `compositions` | `sign-in`, `storefront`, `app-shell`; только размещение и связывание публичных API |
|
||||||
|
| `domains` | `session`, `catalog`, `orders`; модели, сценарии, исходы, состояние, UI и source adaptation |
|
||||||
|
| `infra` | `simple-rest-api`; generated SDK, transport credentials, refresh race и SWR GET-хуки |
|
||||||
|
| `ui` | Универсальные `button` и `field` |
|
||||||
|
| `shared` | Чистые formatters и value predicates |
|
||||||
|
|
||||||
|
Свёрнутый граф модулей:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app -> compositions
|
||||||
|
app -> domains
|
||||||
|
compositions -> compositions
|
||||||
|
compositions -> domains
|
||||||
|
domains -> infra
|
||||||
|
domains -> ui
|
||||||
|
domains -> shared
|
||||||
|
```
|
||||||
|
|
||||||
|
Каждый внешний импорт проходит через корневой `index.ts` целевого модуля. В корне модуля находятся только публичные фасеты и не более одного главного implementation/assembly-файла; context, hooks, source, types и прочая реализация находятся в сегментах.
|
||||||
|
|
||||||
|
### Владение состоянием
|
||||||
|
|
||||||
|
| Состояние | Владелец | Область жизни |
|
||||||
|
|---|---|---|
|
||||||
|
| Пользователь и session status | `domains/session` | Всё browser-приложение |
|
||||||
|
| JWT credentials и refresh promise | `infra/simple-rest-api` | Вкладка / transport singleton |
|
||||||
|
| Product/order server state | REST hooks + доменная адаптация | Application SWR cache |
|
||||||
|
| Draft order | `domains/orders` | Авторизованный storefront route |
|
||||||
|
| Фильтры и editor state | `domains/catalog` | Экземпляр CatalogPanel |
|
||||||
|
|
||||||
|
## Проверки
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
npm run typecheck
|
||||||
|
npm run test
|
||||||
|
npm run build
|
||||||
|
npm run check
|
||||||
|
```
|
||||||
|
|
||||||
|
Тесты покрывают полный login-to-checkout smoke, объединение конкурентных 401 в один refresh и независимое преобразование source errors в доменные исходы.
|
||||||
17
examples/react-vite/index.html
Normal file
17
examples/react-vite/index.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/slm-store.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="SLM Store: демонстрационное React-приложение для Simple API."
|
||||||
|
/>
|
||||||
|
<title>SLM Store</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2657
examples/react-vite/package-lock.json
generated
Normal file
2657
examples/react-vite/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
examples/react-vite/package.json
Normal file
43
examples/react-vite/package.json
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "react-vite",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"description": "Lightweight SLM React storefront for the Demo Simple API",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"codegen:simple-rest-api": "npx @gromlab/api-codegen@5.1.0 -i ../demo-backend/openapi/simple.json -o src/infra/simple-rest-api/generated",
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "oxlint",
|
||||||
|
"typecheck": "tsc -b",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"check": "npm run lint && npm run typecheck && npm run test && npm run build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
"react-router-dom": "^7.18.2",
|
||||||
|
"swr": "^2.5.0",
|
||||||
|
"zod": "^4.4.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^7.0.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.3",
|
||||||
|
"@types/node": "^24.13.3",
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.4",
|
||||||
|
"jsdom": "^30.0.1",
|
||||||
|
"oxlint": "^1.75.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.2.0",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
examples/react-vite/public/slm-store.svg
Normal file
5
examples/react-vite/public/slm-store.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" rx="18" fill="#192c23"/>
|
||||||
|
<path fill="#e36f4f" d="M16 17h35L39 31h10L25 51V36H13l12-19h-9Z"/>
|
||||||
|
<circle cx="46" cy="17" r="5" fill="#9ee0b9"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 243 B |
151
examples/react-vite/src/app/app.test.tsx
Normal file
151
examples/react-vite/src/app/app.test.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { clearSimpleRestApiTokens } from 'infra/simple-rest-api'
|
||||||
|
import { App } from './app'
|
||||||
|
import { AppProviders } from './providers/app-providers'
|
||||||
|
|
||||||
|
const demoUser = {
|
||||||
|
id: 'user-admin',
|
||||||
|
email: 'admin@demo.local',
|
||||||
|
name: 'Demo Admin',
|
||||||
|
role: 'admin',
|
||||||
|
avatarUrl: 'https://i.pravatar.cc/160?img=12'
|
||||||
|
}
|
||||||
|
|
||||||
|
const demoProduct = {
|
||||||
|
id: 'product-keyboard',
|
||||||
|
name: 'Mechanical Keyboard',
|
||||||
|
slug: 'mechanical-keyboard',
|
||||||
|
description: 'Mechanical Keyboard is a deterministic demo product used by frontend examples.',
|
||||||
|
priceCents: 12990,
|
||||||
|
currency: 'USD',
|
||||||
|
categoryId: 'category-electronics',
|
||||||
|
stock: 24,
|
||||||
|
rating: 4.8,
|
||||||
|
imageUrl: 'https://picsum.photos/seed/mechanical-keyboard/640/480',
|
||||||
|
createdAt: '2026-07-10T09:00:00.000Z',
|
||||||
|
version: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создаёт JSON response, совместимый с generated HttpClient.
|
||||||
|
*/
|
||||||
|
const jsonResponse = (body: unknown, status = 200): Response => {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Эмулирует минимальный Simple API для app-level smoke-сценария.
|
||||||
|
*/
|
||||||
|
const createSimpleApiFetch = () => {
|
||||||
|
return vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||||
|
const url = new URL(String(input))
|
||||||
|
const method = init?.method ?? 'GET'
|
||||||
|
|
||||||
|
if (url.pathname === '/api/v1/auth/login' && method === 'POST') {
|
||||||
|
return jsonResponse({
|
||||||
|
data: {
|
||||||
|
tokens: {
|
||||||
|
accessToken: 'access-token',
|
||||||
|
refreshToken: 'refresh-token',
|
||||||
|
expiresIn: 60,
|
||||||
|
tokenType: 'Bearer'
|
||||||
|
},
|
||||||
|
user: demoUser
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/api/v1/products' && method === 'GET') {
|
||||||
|
return jsonResponse({
|
||||||
|
data: [demoProduct],
|
||||||
|
meta: { page: 1, limit: 6, total: 1, totalPages: 1 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/api/v1/categories' && method === 'GET') {
|
||||||
|
return jsonResponse({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'category-electronics',
|
||||||
|
name: 'Electronics',
|
||||||
|
slug: 'electronics',
|
||||||
|
productCount: 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/api/v1/orders' && method === 'GET') {
|
||||||
|
return jsonResponse({
|
||||||
|
data: [],
|
||||||
|
meta: { page: 1, limit: 20, total: 0, totalPages: 0 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/api/v1/orders' && method === 'POST') {
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
id: 'order-010',
|
||||||
|
userId: 'user-admin',
|
||||||
|
status: 'pending',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
productId: demoProduct.id,
|
||||||
|
productName: demoProduct.name,
|
||||||
|
quantity: 1,
|
||||||
|
unitPriceCents: demoProduct.priceCents
|
||||||
|
}
|
||||||
|
],
|
||||||
|
totalCents: demoProduct.priceCents,
|
||||||
|
currency: 'USD',
|
||||||
|
createdAt: '2026-08-10T10:00:00.000Z'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
201
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({ code: 'NOT_FOUND', message: 'Not found' }, 404)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearSimpleRestApiTokens()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('App', () => {
|
||||||
|
it('authenticates, loads domain data and checks out a product', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const fetchMock = createSimpleApiFetch()
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AppProviders>
|
||||||
|
<App />
|
||||||
|
</AppProviders>
|
||||||
|
)
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Войти в магазин' }))
|
||||||
|
|
||||||
|
expect(await screen.findByRole('heading', { name: 'Mechanical Keyboard' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Demo Admin')).toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'В заказ' }))
|
||||||
|
expect(screen.getByText('Draft order')).toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Оформить заказ' }))
|
||||||
|
|
||||||
|
expect(await screen.findByText('order-010')).toBeInTheDocument()
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'http://localhost:3001/api/v1/orders',
|
||||||
|
expect.objectContaining({ method: 'POST' })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
46
examples/react-vite/src/app/app.tsx
Normal file
46
examples/react-vite/src/app/app.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { SignInScreen } from 'compositions/screens/sign-in'
|
||||||
|
import { StorefrontScreen } from 'compositions/screens/storefront'
|
||||||
|
import { OrdersProvider } from 'domains/orders'
|
||||||
|
import { useSessionState } from 'domains/session'
|
||||||
|
import styles from './styles/app.module.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browser entry, выбирающий экран по session lifecycle.
|
||||||
|
*
|
||||||
|
* Используется для:
|
||||||
|
* - защиты storefront route пользовательской сессией
|
||||||
|
* - завершения bootstrap до первого route render
|
||||||
|
*/
|
||||||
|
export const App = () => {
|
||||||
|
const { status } = useSessionState()
|
||||||
|
|
||||||
|
if (status === 'restoring') {
|
||||||
|
return (
|
||||||
|
<main className={styles.loader} aria-live="polite">
|
||||||
|
<span className={styles.loaderMark}>S</span>
|
||||||
|
<strong>Восстанавливаем сессию</strong>
|
||||||
|
<small>Simple API · JWT rotation</small>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAuthenticated = status === 'authenticated'
|
||||||
|
const signInElement = isAuthenticated ? <Navigate to="/" replace /> : <SignInScreen />
|
||||||
|
const storefrontElement = isAuthenticated ? (
|
||||||
|
<OrdersProvider>
|
||||||
|
<StorefrontScreen />
|
||||||
|
</OrdersProvider>
|
||||||
|
) : (
|
||||||
|
<Navigate to="/login" replace />
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={signInElement} />
|
||||||
|
<Route path="/" element={storefrontElement} />
|
||||||
|
<Route path="*" element={<Navigate to={isAuthenticated ? '/' : '/login'} replace />} />
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
47
examples/react-vite/src/app/providers/app-providers.tsx
Normal file
47
examples/react-vite/src/app/providers/app-providers.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { SWRConfig } from 'swr'
|
||||||
|
|
||||||
|
import { SessionProvider } from 'domains/session'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props глобальной provider-композиции browser-приложения.
|
||||||
|
*/
|
||||||
|
export type AppProvidersProps = {
|
||||||
|
/** Browser-приложение внутри общего cache и session scope. */
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Собирает технические providers application scope.
|
||||||
|
*
|
||||||
|
* Используется для:
|
||||||
|
* - одного SWR cache на browser-приложение
|
||||||
|
* - одного session lifecycle и history router
|
||||||
|
*/
|
||||||
|
export const AppProviders = (props: AppProvidersProps) => {
|
||||||
|
const { children } = props
|
||||||
|
const [swrCache] = useState(() => new Map())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаляет server state предыдущего пользователя при закрытии сессии.
|
||||||
|
*/
|
||||||
|
const handleSessionClosed = (): void => {
|
||||||
|
swrCache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SWRConfig
|
||||||
|
value={{
|
||||||
|
provider: () => swrCache,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
shouldRetryOnError: false
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SessionProvider onSessionClosed={handleSessionClosed}>
|
||||||
|
<BrowserRouter>{children}</BrowserRouter>
|
||||||
|
</SessionProvider>
|
||||||
|
</SWRConfig>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
examples/react-vite/src/app/styles/app.module.css
Normal file
42
examples/react-vite/src/app/styles/app.module.css
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
.loader {
|
||||||
|
display: grid;
|
||||||
|
min-height: 100svh;
|
||||||
|
place-items: center;
|
||||||
|
align-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loaderMark {
|
||||||
|
display: grid;
|
||||||
|
width: 3.4rem;
|
||||||
|
height: 3.4rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 1rem 1rem 1rem 0.25rem;
|
||||||
|
color: #fffaf0;
|
||||||
|
background: var(--color-accent-strong);
|
||||||
|
box-shadow: 0 14px 35px rgb(31 86 62 / 22%);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.7rem;
|
||||||
|
animation: breathe 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader strong {
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader small {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes breathe {
|
||||||
|
50% {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
examples/react-vite/src/app/styles/global.css
Normal file
80
examples/react-vite/src/app/styles/global.css
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
:root {
|
||||||
|
--color-canvas: #f5f0e5;
|
||||||
|
--color-surface: #fffdf7;
|
||||||
|
--color-surface-strong: #ebe6da;
|
||||||
|
--color-ink: #192c23;
|
||||||
|
--color-ink-muted: #647069;
|
||||||
|
--color-ink-faint: #929a95;
|
||||||
|
--color-line: rgb(27 48 38 / 13%);
|
||||||
|
--color-line-strong: rgb(27 48 38 / 25%);
|
||||||
|
--color-accent: #e36f4f;
|
||||||
|
--color-accent-strong: #bd4e35;
|
||||||
|
--color-accent-light: #9ee0b9;
|
||||||
|
--color-accent-soft: #f7e4d9;
|
||||||
|
--color-danger: #aa3d32;
|
||||||
|
--color-danger-soft: #fae2de;
|
||||||
|
--font-body: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
--font-display: Georgia, 'Times New Roman', serif;
|
||||||
|
--font-mono: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
|
||||||
|
--shadow-card: 0 12px 35px rgb(30 45 38 / 7%);
|
||||||
|
--shadow-card-hover: 0 20px 48px rgb(30 45 38 / 12%);
|
||||||
|
|
||||||
|
color: var(--color-ink);
|
||||||
|
background: var(--color-canvas);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100svh;
|
||||||
|
margin: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 10% 10%, rgb(227 111 79 / 9%), transparent 24rem),
|
||||||
|
radial-gradient(circle at 90% 35%, rgb(78 148 107 / 8%), transparent 28rem),
|
||||||
|
var(--color-canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
a {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
color: #fffaf0;
|
||||||
|
background: var(--color-accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import cl from 'clsx'
|
||||||
|
|
||||||
|
import { SessionBadge } from 'domains/session'
|
||||||
|
import type { AppShellLayoutProps } from './types/app-shell-layout-props.type'
|
||||||
|
import styles from './styles/app-shell.module.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Общий каркас авторизованного storefront.
|
||||||
|
*
|
||||||
|
* Используется для:
|
||||||
|
* - единой навигации между каталогом и историей заказов
|
||||||
|
* - отображения публичного session UI в header
|
||||||
|
*/
|
||||||
|
export const AppShellLayout = (props: AppShellLayoutProps) => {
|
||||||
|
const { children, className, ...rootAttrs } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div {...rootAttrs} className={cl(styles.root, className)}>
|
||||||
|
<header className={styles.header}>
|
||||||
|
<a className={styles.brand} href="#catalog" aria-label="SLM Store, к каталогу">
|
||||||
|
<span className={styles.brandMark}>S</span>
|
||||||
|
<span>
|
||||||
|
<strong>SLM Store</strong>
|
||||||
|
<small>simple contract</small>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<nav className={styles.nav} aria-label="Основная навигация">
|
||||||
|
<a href="#catalog">Каталог</a>
|
||||||
|
<a href="#orders">Заказы</a>
|
||||||
|
</nav>
|
||||||
|
<SessionBadge />
|
||||||
|
</header>
|
||||||
|
<div className={styles.content}>{children}</div>
|
||||||
|
<footer className={styles.footer}>
|
||||||
|
<span>React + Vite</span>
|
||||||
|
<span>Scoped Layered Module Design</span>
|
||||||
|
<span>Simple API · :3001</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { AppShellLayout } from './app-shell.layout'
|
||||||
|
export type { AppShellLayoutProps } from './types/app-shell-layout-props.type'
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
.root {
|
||||||
|
width: min(100%, 92rem);
|
||||||
|
min-height: 100svh;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1.2rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
position: sticky;
|
||||||
|
z-index: 20;
|
||||||
|
top: 0.7rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 0.7rem;
|
||||||
|
padding: 0.68rem 0.78rem;
|
||||||
|
border: 1px solid rgb(26 43 35 / 10%);
|
||||||
|
border-radius: 1.1rem;
|
||||||
|
background: rgb(250 246 236 / 80%);
|
||||||
|
box-shadow: 0 12px 35px rgb(31 45 37 / 8%);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
justify-self: start;
|
||||||
|
color: var(--color-ink);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brandMark {
|
||||||
|
display: grid;
|
||||||
|
width: 2.35rem;
|
||||||
|
height: 2.35rem;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 0.75rem 0.75rem 0.75rem 0.2rem;
|
||||||
|
color: #fffaf0;
|
||||||
|
background: var(--color-accent-strong);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand > span:last-child {
|
||||||
|
display: grid;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand strong {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand small {
|
||||||
|
margin-top: 0.22rem;
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.58rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgb(31 46 38 / 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a {
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 750;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a:hover,
|
||||||
|
.nav a:focus-visible {
|
||||||
|
color: var(--color-ink);
|
||||||
|
background: var(--color-surface);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header > :last-child {
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 1.2rem 0 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.4rem 0 2rem;
|
||||||
|
border-top: 1px solid var(--color-line);
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.63rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.header {
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.root {
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand small {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { ComponentPropsWithoutRef } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Собственные параметры AppShellLayout.
|
||||||
|
*/
|
||||||
|
export type AppShellLayoutParams = object
|
||||||
|
|
||||||
|
/** Атрибуты корневого div. */
|
||||||
|
type RootAttrs = ComponentPropsWithoutRef<'div'>
|
||||||
|
|
||||||
|
/** Props общего каркаса авторизованного приложения. */
|
||||||
|
export type AppShellLayoutProps = RootAttrs & AppShellLayoutParams
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { SignInScreen } from './sign-in.screen'
|
||||||
|
export type { SignInScreenProps } from './types/sign-in-screen-props.type'
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import cl from 'clsx'
|
||||||
|
|
||||||
|
import { SignInForm } from 'domains/session'
|
||||||
|
import type { SignInScreenProps } from './types/sign-in-screen-props.type'
|
||||||
|
import styles from './styles/sign-in.module.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Публичный экран входа в демонстрационный Simple Store.
|
||||||
|
*
|
||||||
|
* Используется для:
|
||||||
|
* - выбора admin или customer demo-сессии
|
||||||
|
* - краткого объяснения архитектурного среза приложения
|
||||||
|
*/
|
||||||
|
export const SignInScreen = (props: SignInScreenProps) => {
|
||||||
|
const { className, ...rootAttrs } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main {...rootAttrs} className={cl(styles.root, className)}>
|
||||||
|
<section className={styles.story}>
|
||||||
|
<div className={styles.brand}>
|
||||||
|
<span>S</span>
|
||||||
|
<strong>SLM Store</strong>
|
||||||
|
</div>
|
||||||
|
<div className={styles.storyBody}>
|
||||||
|
<span className={styles.kicker}>Small API · complete boundaries</span>
|
||||||
|
<h1>Меньше кода.<br />Чётче владельцы.</h1>
|
||||||
|
<p>
|
||||||
|
Облегчённый storefront поверх JWT API: каталог, optimistic locking,
|
||||||
|
draft order и защищённая история.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ol className={styles.layers} aria-label="SLM data flow">
|
||||||
|
<li><span>01</span> app · lifecycle</li>
|
||||||
|
<li><span>02</span> compositions · assembly</li>
|
||||||
|
<li><span>03</span> domains · product meaning</li>
|
||||||
|
<li><span>04</span> infra · OpenAPI transport</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.access} aria-labelledby="sign-in-title">
|
||||||
|
<div className={styles.accessCard}>
|
||||||
|
<span className={styles.status}><i /> Simple API · localhost:3001</span>
|
||||||
|
<h2 id="sign-in-title">Войти в demo</h2>
|
||||||
|
<p>Выберите роль. Credentials уже заполнены и сбрасываются вместе с backend.</p>
|
||||||
|
<SignInForm />
|
||||||
|
</div>
|
||||||
|
<p className={styles.note}>Refresh token хранится только в sessionStorage текущей вкладки.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
.root {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.08fr) minmax(25rem, 0.92fr);
|
||||||
|
min-height: 100svh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 100%;
|
||||||
|
padding: clamp(1.5rem, 4vw, 4.5rem);
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: #fdf8ed;
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, rgb(20 42 32 / 98%), rgb(31 71 53 / 94%)),
|
||||||
|
var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.story::before,
|
||||||
|
.story::after {
|
||||||
|
position: absolute;
|
||||||
|
border: 1px solid rgb(255 255 255 / 12%);
|
||||||
|
border-radius: 50%;
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
.story::before {
|
||||||
|
top: -15vw;
|
||||||
|
right: -9vw;
|
||||||
|
width: 40vw;
|
||||||
|
height: 40vw;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 5vw rgb(255 255 255 / 2%),
|
||||||
|
0 0 0 10vw rgb(255 255 255 / 2%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.story::after {
|
||||||
|
right: 8%;
|
||||||
|
bottom: 18%;
|
||||||
|
width: 0.7rem;
|
||||||
|
height: 0.7rem;
|
||||||
|
border-color: var(--color-accent-light);
|
||||||
|
background: var(--color-accent-light);
|
||||||
|
box-shadow:
|
||||||
|
5rem -2rem 0 -0.16rem var(--color-accent-light),
|
||||||
|
-3rem 4rem 0 -0.25rem var(--color-accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand > span {
|
||||||
|
display: grid;
|
||||||
|
width: 2.6rem;
|
||||||
|
height: 2.6rem;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid rgb(255 255 255 / 28%);
|
||||||
|
border-radius: 0.85rem 0.85rem 0.85rem 0.2rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand strong {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storyBody {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
max-width: 43rem;
|
||||||
|
margin: clamp(4rem, 15vh, 10rem) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kicker {
|
||||||
|
color: var(--color-accent-light);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story h1 {
|
||||||
|
margin: 1rem 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(3.3rem, 7.5vw, 7.2rem);
|
||||||
|
font-weight: 560;
|
||||||
|
letter-spacing: -0.065em;
|
||||||
|
line-height: 0.86;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storyBody p {
|
||||||
|
max-width: 34rem;
|
||||||
|
margin: 1.6rem 0 0;
|
||||||
|
color: rgb(253 248 237 / 64%);
|
||||||
|
font-size: clamp(0.9rem, 1.4vw, 1.08rem);
|
||||||
|
line-height: 1.62;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.45rem 1.5rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem 0 0;
|
||||||
|
border-top: 1px solid rgb(255 255 255 / 13%);
|
||||||
|
color: rgb(253 248 237 / 58%);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.67rem;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers li {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers span {
|
||||||
|
color: var(--color-accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.access {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
align-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: clamp(1.2rem, 5vw, 5rem);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 86% 12%, rgb(227 107 74 / 13%), transparent 28%),
|
||||||
|
var(--color-canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accessCard {
|
||||||
|
width: min(100%, 27rem);
|
||||||
|
padding: clamp(1.2rem, 4vw, 2rem);
|
||||||
|
border: 1px solid var(--color-line);
|
||||||
|
border-radius: 1.4rem;
|
||||||
|
background: rgb(255 255 255 / 63%);
|
||||||
|
box-shadow: 0 28px 75px rgb(26 43 35 / 10%);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.64rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status i {
|
||||||
|
width: 0.48rem;
|
||||||
|
height: 0.48rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #4caf79;
|
||||||
|
box-shadow: 0 0 0 0.23rem rgb(76 175 121 / 14%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accessCard h2 {
|
||||||
|
margin: 1rem 0 0.45rem;
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 2.45rem;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accessCard > p {
|
||||||
|
margin: 0 0 1.35rem;
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.52;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
max-width: 24rem;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.root {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story {
|
||||||
|
min-height: 31rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storyBody {
|
||||||
|
margin: 4rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story h1 {
|
||||||
|
font-size: clamp(3rem, 11vw, 5.4rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.access {
|
||||||
|
min-height: 42rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
.layers {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { ComponentPropsWithoutRef } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Собственные параметры экрана SignIn.
|
||||||
|
*/
|
||||||
|
export type SignInScreenParams = object
|
||||||
|
|
||||||
|
/** Атрибуты корневого main без children. */
|
||||||
|
type RootAttrs = Omit<ComponentPropsWithoutRef<'main'>, 'children'>
|
||||||
|
|
||||||
|
/** Props публичного экрана входа. */
|
||||||
|
export type SignInScreenProps = RootAttrs & SignInScreenParams
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { StorefrontScreen } from './storefront.screen'
|
||||||
|
export type { StorefrontScreenProps } from './types/storefront-screen-props.type'
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import cl from 'clsx'
|
||||||
|
|
||||||
|
import { CatalogPanel } from 'domains/catalog'
|
||||||
|
import { CartPanel, OrderHistory, useAddProductToOrder } from 'domains/orders'
|
||||||
|
import { useSessionState } from 'domains/session'
|
||||||
|
import { AppShellLayout } from 'compositions/layouts/app-shell'
|
||||||
|
import type { StorefrontScreenProps } from './types/storefront-screen-props.type'
|
||||||
|
import styles from './styles/storefront.module.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Авторизованный storefront, координирующий независимые доменные UI.
|
||||||
|
*
|
||||||
|
* Используется для:
|
||||||
|
* - передачи product snapshot из catalog в draft order
|
||||||
|
* - совместного отображения каталога, корзины и истории
|
||||||
|
*/
|
||||||
|
export const StorefrontScreen = (props: StorefrontScreenProps) => {
|
||||||
|
const { className, ...rootAttrs } = props
|
||||||
|
const { user } = useSessionState()
|
||||||
|
const addProduct = useAddProductToOrder()
|
||||||
|
const isAdmin = user?.role === 'admin'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShellLayout>
|
||||||
|
<main {...rootAttrs} className={cl(styles.root, className)}>
|
||||||
|
<section className={styles.hero}>
|
||||||
|
<div>
|
||||||
|
<span className={styles.kicker}>One contract · three owners</span>
|
||||||
|
<h1>Simple Store,<br />без простой архитектуры.</h1>
|
||||||
|
</div>
|
||||||
|
<div className={styles.heroAside}>
|
||||||
|
<span className={styles.pulse}><i /> API online</span>
|
||||||
|
<p>
|
||||||
|
JWT refresh, RBAC, pagination, stock validation и optimistic locking
|
||||||
|
проходят через отдельные SLM boundaries.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="catalog" className={styles.workspace}>
|
||||||
|
<CatalogPanel isAdmin={isAdmin} onAddProduct={addProduct} />
|
||||||
|
<CartPanel />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OrderHistory id="orders" />
|
||||||
|
</main>
|
||||||
|
</AppShellLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
.root {
|
||||||
|
display: grid;
|
||||||
|
gap: 2.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.35fr) minmax(17rem, 0.65fr);
|
||||||
|
align-items: end;
|
||||||
|
gap: 2rem;
|
||||||
|
min-height: 22rem;
|
||||||
|
padding: clamp(2rem, 6vw, 5.5rem) clamp(1rem, 4vw, 3.2rem);
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
color: #fffaf0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 86% 18%, rgb(235 127 88 / 36%), transparent 24%),
|
||||||
|
linear-gradient(125deg, #18372a 0%, #265540 58%, #694d35 145%);
|
||||||
|
box-shadow: 0 28px 70px rgb(24 55 42 / 15%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kicker {
|
||||||
|
color: var(--color-accent-light);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
letter-spacing: 0.13em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
margin: 0.9rem 0 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(3rem, 6.5vw, 6.4rem);
|
||||||
|
font-weight: 560;
|
||||||
|
letter-spacing: -0.065em;
|
||||||
|
line-height: 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroAside {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.1rem;
|
||||||
|
border: 1px solid rgb(255 255 255 / 14%);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: rgb(255 255 255 / 6%);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroAside p {
|
||||||
|
margin: 0;
|
||||||
|
color: rgb(255 250 240 / 66%);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.48rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse i {
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-accent-light);
|
||||||
|
box-shadow: 0 0 0 0.3rem rgb(151 219 180 / 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(18rem, 22rem);
|
||||||
|
gap: 1rem;
|
||||||
|
scroll-margin-top: 6.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.workspace {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.hero {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
min-height: 25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { ComponentPropsWithoutRef } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Собственные параметры экрана Storefront.
|
||||||
|
*/
|
||||||
|
export type StorefrontScreenParams = object
|
||||||
|
|
||||||
|
/** Атрибуты корневого main без children. */
|
||||||
|
type RootAttrs = Omit<ComponentPropsWithoutRef<'main'>, 'children'>
|
||||||
|
|
||||||
|
/** Props авторизованного storefront screen. */
|
||||||
|
export type StorefrontScreenProps = RootAttrs & StorefrontScreenParams
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/** Ожидаемый неуспешный исход каталога. */
|
||||||
|
export type CatalogErrorCode =
|
||||||
|
| 'forbidden'
|
||||||
|
| 'not-found'
|
||||||
|
| 'version-conflict'
|
||||||
|
| 'invalid-input'
|
||||||
|
| 'rate-limited'
|
||||||
|
| 'invalid-data'
|
||||||
|
| 'unavailable'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Доменная ошибка чтения или изменения каталога.
|
||||||
|
*/
|
||||||
|
export class CatalogError extends Error {
|
||||||
|
/** Стабильный код ожидаемого исхода. */
|
||||||
|
readonly code: CatalogErrorCode
|
||||||
|
|
||||||
|
constructor(code: CatalogErrorCode, message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'CatalogError'
|
||||||
|
this.code = code
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import {
|
||||||
|
useGetCategoryList,
|
||||||
|
useGetProductList
|
||||||
|
} from 'infra/simple-rest-api'
|
||||||
|
import { CatalogError } from '../errors/catalog.error'
|
||||||
|
import type { CatalogCategory } from '../types/catalog-category.type'
|
||||||
|
import type { CatalogFilters } from '../types/catalog-filters.type'
|
||||||
|
import type { CatalogPage } from '../types/catalog-page.type'
|
||||||
|
import { mapCatalogError } from '../source/map-catalog-error'
|
||||||
|
import {
|
||||||
|
catalogCategoriesSchema,
|
||||||
|
catalogPageSchema
|
||||||
|
} from '../source/catalog.schemas'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Результат чтения каталога для domain UI.
|
||||||
|
*/
|
||||||
|
export type CatalogQuery = {
|
||||||
|
/** Валидированная страница продуктов или null до первого ответа. */
|
||||||
|
page: CatalogPage | null
|
||||||
|
/** Валидированные категории. */
|
||||||
|
categories: CatalogCategory[]
|
||||||
|
/** Выполняется ли первый запрос. */
|
||||||
|
isLoading: boolean
|
||||||
|
/** Ожидаемая ошибка чтения или null. */
|
||||||
|
error: CatalogError | null
|
||||||
|
/** Повторно получает продукты и категории. */
|
||||||
|
refresh: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет product response и отделяет wire envelope от доменной страницы.
|
||||||
|
*/
|
||||||
|
const parseCatalogPage = (response: unknown): CatalogPage => {
|
||||||
|
const parsedResponse = catalogPageSchema.safeParse(response)
|
||||||
|
|
||||||
|
if (!parsedResponse.success) {
|
||||||
|
throw new CatalogError('invalid-data', 'Simple API вернул каталог неизвестного формата.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
products: parsedResponse.data.data,
|
||||||
|
...parsedResponse.data.meta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет category response до передачи данных domain UI.
|
||||||
|
*/
|
||||||
|
const parseCategories = (response: unknown): CatalogCategory[] => {
|
||||||
|
const parsedResponse = catalogCategoriesSchema.safeParse(response)
|
||||||
|
|
||||||
|
if (!parsedResponse.success) {
|
||||||
|
throw new CatalogError('invalid-data', 'Simple API вернул категории неизвестного формата.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedResponse.data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Повторно запускает оба GET-запроса каталога.
|
||||||
|
*/
|
||||||
|
const refreshCatalogQueries = async (
|
||||||
|
mutateProducts: () => Promise<unknown>,
|
||||||
|
mutateCategories: () => Promise<unknown>
|
||||||
|
): Promise<void> => {
|
||||||
|
await Promise.all([mutateProducts(), mutateCategories()])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Предоставляет domain UI валидированные продукты, категории и pagination.
|
||||||
|
*/
|
||||||
|
export const useCatalog = (filters: CatalogFilters): CatalogQuery => {
|
||||||
|
const productQuery = useGetProductList(filters)
|
||||||
|
const categoryQuery = useGetCategoryList()
|
||||||
|
let page: CatalogPage | null = null
|
||||||
|
let categories: CatalogCategory[] = []
|
||||||
|
let error: CatalogError | null = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (productQuery.data) {
|
||||||
|
page = parseCatalogPage(productQuery.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categoryQuery.data) {
|
||||||
|
categories = parseCategories(categoryQuery.data)
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
error = mapCatalogError(parseError)
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryError = productQuery.error ?? categoryQuery.error
|
||||||
|
|
||||||
|
if (queryError) {
|
||||||
|
error = mapCatalogError(queryError)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет все source-данные каталога после mutation.
|
||||||
|
*/
|
||||||
|
const refresh = async (): Promise<void> => {
|
||||||
|
await refreshCatalogQueries(productQuery.mutate, categoryQuery.mutate)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
page,
|
||||||
|
categories,
|
||||||
|
isLoading: !productQuery.data && !productQuery.error,
|
||||||
|
error,
|
||||||
|
refresh
|
||||||
|
}
|
||||||
|
}
|
||||||
3
examples/react-vite/src/domains/catalog/index.ts
Normal file
3
examples/react-vite/src/domains/catalog/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { CatalogPanel } from './ui/catalog-panel'
|
||||||
|
export type { CatalogPanelProps } from './ui/catalog-panel'
|
||||||
|
export type { CatalogCurrency, CatalogProduct } from './types/catalog-product.type'
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const catalogProductSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
slug: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
priceCents: z.number().nonnegative(),
|
||||||
|
currency: z.enum(['USD', 'EUR']),
|
||||||
|
categoryId: z.string(),
|
||||||
|
stock: z.number().int().nonnegative(),
|
||||||
|
rating: z.number().min(0).max(5),
|
||||||
|
imageUrl: z.url(),
|
||||||
|
createdAt: z.iso.datetime(),
|
||||||
|
version: z.number().int().positive()
|
||||||
|
})
|
||||||
|
|
||||||
|
export const catalogCategorySchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
slug: z.string(),
|
||||||
|
productCount: z.number().int().nonnegative()
|
||||||
|
})
|
||||||
|
|
||||||
|
export const catalogPageSchema = z.object({
|
||||||
|
data: z.array(catalogProductSchema),
|
||||||
|
meta: z.object({
|
||||||
|
page: z.number().int().positive(),
|
||||||
|
limit: z.number().int().positive(),
|
||||||
|
total: z.number().int().nonnegative(),
|
||||||
|
totalPages: z.number().int().nonnegative()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
export const catalogCategoriesSchema = z.object({
|
||||||
|
data: z.array(catalogCategorySchema)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const catalogProductResponseSchema = z.object({ data: catalogProductSchema })
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import {
|
||||||
|
simpleRestApi,
|
||||||
|
toSimpleRestApiError
|
||||||
|
} from 'infra/simple-rest-api'
|
||||||
|
import { CatalogError } from '../errors/catalog.error'
|
||||||
|
import type {
|
||||||
|
CatalogProduct,
|
||||||
|
CreateCatalogProduct,
|
||||||
|
UpdateCatalogProduct
|
||||||
|
} from '../types/catalog-product.type'
|
||||||
|
import { mapCatalogError } from './map-catalog-error'
|
||||||
|
import { catalogProductResponseSchema } from './catalog.schemas'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет и адаптирует одиночный product response.
|
||||||
|
*/
|
||||||
|
const parseProductResponse = (response: unknown): CatalogProduct => {
|
||||||
|
const parsedResponse = catalogProductResponseSchema.safeParse(response)
|
||||||
|
|
||||||
|
if (!parsedResponse.success) {
|
||||||
|
throw new CatalogError('invalid-data', 'Simple API вернул продукт неизвестного формата.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedResponse.data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создаёт продукт от имени администратора.
|
||||||
|
*/
|
||||||
|
export const createCatalogProduct = async (
|
||||||
|
product: CreateCatalogProduct
|
||||||
|
): Promise<CatalogProduct> => {
|
||||||
|
try {
|
||||||
|
const response = await simpleRestApi.products.simpleProductsCreate(product)
|
||||||
|
return parseProductResponse(response)
|
||||||
|
} catch (error) {
|
||||||
|
throw mapCatalogError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет продукт с optimistic locking по последней прочитанной версии.
|
||||||
|
*/
|
||||||
|
export const updateCatalogProduct = async (
|
||||||
|
productId: string,
|
||||||
|
product: UpdateCatalogProduct
|
||||||
|
): Promise<CatalogProduct> => {
|
||||||
|
try {
|
||||||
|
const response = await simpleRestApi.products.simpleProductsUpdate(
|
||||||
|
{ id: productId },
|
||||||
|
product
|
||||||
|
)
|
||||||
|
return parseProductResponse(response)
|
||||||
|
} catch (error) {
|
||||||
|
throw mapCatalogError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаляет продукт от имени администратора.
|
||||||
|
*/
|
||||||
|
export const deleteCatalogProduct = async (productId: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await simpleRestApi.products.simpleProductsRemove({ id: productId })
|
||||||
|
} catch (error) {
|
||||||
|
const apiError = toSimpleRestApiError(error)
|
||||||
|
throw mapCatalogError(apiError)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { mapCatalogError } from './map-catalog-error'
|
||||||
|
|
||||||
|
vi.mock('infra/simple-rest-api', () => ({
|
||||||
|
toSimpleRestApiError: () => ({
|
||||||
|
status: 409,
|
||||||
|
code: 'PRODUCT_VERSION_CONFLICT',
|
||||||
|
message: 'Source-specific message',
|
||||||
|
requestId: 'req-test'
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('mapCatalogError', () => {
|
||||||
|
it('turns a source optimistic-lock failure into a catalog outcome', () => {
|
||||||
|
const catalogError = mapCatalogError(new Error('transport failure'))
|
||||||
|
|
||||||
|
expect(catalogError.code).toBe('version-conflict')
|
||||||
|
expect(catalogError.message).not.toContain('Source-specific')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { toSimpleRestApiError } from 'infra/simple-rest-api'
|
||||||
|
import { CatalogError } from '../errors/catalog.error'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Преобразует REST failure в ожидаемый исход каталога.
|
||||||
|
*/
|
||||||
|
export const mapCatalogError = (error: unknown): CatalogError => {
|
||||||
|
if (error instanceof CatalogError) {
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiError = toSimpleRestApiError(error)
|
||||||
|
|
||||||
|
if (apiError.status === 403) {
|
||||||
|
return new CatalogError('forbidden', 'Управление каталогом доступно только администратору.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiError.status === 404) {
|
||||||
|
return new CatalogError('not-found', 'Продукт больше не существует.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiError.code === 'PRODUCT_VERSION_CONFLICT') {
|
||||||
|
return new CatalogError(
|
||||||
|
'version-conflict',
|
||||||
|
'Продукт уже изменён. Каталог обновлён, повторите редактирование.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiError.status === 400 || apiError.status === 422) {
|
||||||
|
return new CatalogError('invalid-input', 'Проверьте поля продукта и выбранную категорию.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiError.status === 429) {
|
||||||
|
return new CatalogError('rate-limited', 'Simple API ограничил частоту запросов.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CatalogError('unavailable', 'Не удалось получить каталог из Simple API.')
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Категория продуктового каталога.
|
||||||
|
*/
|
||||||
|
export type CatalogCategory = {
|
||||||
|
/** Стабильный идентификатор категории. */
|
||||||
|
id: string
|
||||||
|
/** Отображаемое название. */
|
||||||
|
name: string
|
||||||
|
/** URL-safe имя категории. */
|
||||||
|
slug: string
|
||||||
|
/** Число продуктов в категории. */
|
||||||
|
productCount: number
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/** Порядок сортировки продуктов. */
|
||||||
|
export type CatalogSort = 'newest' | 'price-asc' | 'price-desc' | 'name'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Фильтры страницы каталога.
|
||||||
|
*/
|
||||||
|
export type CatalogFilters = {
|
||||||
|
/** Номер текущей страницы. */
|
||||||
|
page: number
|
||||||
|
/** Максимальное число продуктов на странице. */
|
||||||
|
limit: number
|
||||||
|
/** Поисковая строка. */
|
||||||
|
search?: string
|
||||||
|
/** Выбранная категория. */
|
||||||
|
categoryId?: string
|
||||||
|
/** Порядок выдачи. */
|
||||||
|
sort: CatalogSort
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { CatalogProduct } from './catalog-product.type'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Страница продуктов и её pagination metadata.
|
||||||
|
*/
|
||||||
|
export type CatalogPage = {
|
||||||
|
/** Продукты текущей страницы. */
|
||||||
|
products: CatalogProduct[]
|
||||||
|
/** Номер текущей страницы. */
|
||||||
|
page: number
|
||||||
|
/** Лимит элементов страницы. */
|
||||||
|
limit: number
|
||||||
|
/** Общее число продуктов после фильтрации. */
|
||||||
|
total: number
|
||||||
|
/** Общее число страниц. */
|
||||||
|
totalPages: number
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/** Валюта продукта в Simple Store. */
|
||||||
|
export type CatalogCurrency = 'USD' | 'EUR'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Продукт каталога с данными, необходимыми для покупки и управления.
|
||||||
|
*/
|
||||||
|
export type CatalogProduct = {
|
||||||
|
/** Стабильный идентификатор продукта. */
|
||||||
|
id: string
|
||||||
|
/** Отображаемое название. */
|
||||||
|
name: string
|
||||||
|
/** URL-safe имя продукта. */
|
||||||
|
slug: string
|
||||||
|
/** Пользовательское описание. */
|
||||||
|
description: string
|
||||||
|
/** Цена в минимальных единицах валюты. */
|
||||||
|
priceCents: number
|
||||||
|
/** Валюта цены. */
|
||||||
|
currency: CatalogCurrency
|
||||||
|
/** Идентификатор категории. */
|
||||||
|
categoryId: string
|
||||||
|
/** Доступный остаток. */
|
||||||
|
stock: number
|
||||||
|
/** Средняя оценка от нуля до пяти. */
|
||||||
|
rating: number
|
||||||
|
/** URL изображения продукта. */
|
||||||
|
imageUrl: string
|
||||||
|
/** ISO-дата создания. */
|
||||||
|
createdAt: string
|
||||||
|
/** Версия для optimistic locking. */
|
||||||
|
version: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Данные администратора для создания продукта.
|
||||||
|
*/
|
||||||
|
export type CreateCatalogProduct = {
|
||||||
|
/** Название длиной от двух символов. */
|
||||||
|
name: string
|
||||||
|
/** Описание длиной от десяти символов. */
|
||||||
|
description: string
|
||||||
|
/** Цена в минимальных единицах валюты. */
|
||||||
|
priceCents: number
|
||||||
|
/** Валюта цены. */
|
||||||
|
currency: CatalogCurrency
|
||||||
|
/** Идентификатор существующей категории. */
|
||||||
|
categoryId: string
|
||||||
|
/** Начальный доступный остаток. */
|
||||||
|
stock: number
|
||||||
|
/** HTTPS URL изображения на picsum.photos. */
|
||||||
|
imageUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Изменяемые данные продукта вместе с прочитанной версией.
|
||||||
|
*/
|
||||||
|
export type UpdateCatalogProduct = CreateCatalogProduct & {
|
||||||
|
/** Версия, прочитанная перед редактированием. */
|
||||||
|
version: number
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import { useDeferredValue, useState } from 'react'
|
||||||
|
import cl from 'clsx'
|
||||||
|
|
||||||
|
import { formatCurrency } from 'shared/lib/format'
|
||||||
|
import { isEmptyArray, isNonEmptyArray } from 'shared/lib/value-predicates'
|
||||||
|
import { Button } from 'ui/button'
|
||||||
|
import { CatalogError } from '../../errors/catalog.error'
|
||||||
|
import { useCatalog } from '../../hooks/use-catalog.hook'
|
||||||
|
import {
|
||||||
|
createCatalogProduct,
|
||||||
|
deleteCatalogProduct,
|
||||||
|
updateCatalogProduct
|
||||||
|
} from '../../source/catalog.source'
|
||||||
|
import type { CatalogProduct, CreateCatalogProduct } from '../../types/catalog-product.type'
|
||||||
|
import type { CatalogSort } from '../../types/catalog-filters.type'
|
||||||
|
import { ProductForm } from '../product-form/product-form'
|
||||||
|
import type { CatalogPanelProps } from './types/catalog-panel-props.type'
|
||||||
|
import styles from './styles/catalog-panel.module.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Каталог продуктов с поиском, покупкой и административными mutations.
|
||||||
|
*
|
||||||
|
* Используется для:
|
||||||
|
* - выбора актуального product snapshot для draft order
|
||||||
|
* - управления продуктами пользователем с ролью admin
|
||||||
|
*/
|
||||||
|
export const CatalogPanel = (props: CatalogPanelProps) => {
|
||||||
|
const { isAdmin, onAddProduct, className, ...rootAttrs } = props
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const deferredSearch = useDeferredValue(search)
|
||||||
|
const [categoryId, setCategoryId] = useState('')
|
||||||
|
const [sort, setSort] = useState<CatalogSort>('newest')
|
||||||
|
const [pageNumber, setPageNumber] = useState(1)
|
||||||
|
const [editorProduct, setEditorProduct] = useState<CatalogProduct | null | undefined>(undefined)
|
||||||
|
const [mutationError, setMutationError] = useState<string | null>(null)
|
||||||
|
const [isMutating, setIsMutating] = useState(false)
|
||||||
|
const { page, categories, isLoading, error, refresh } = useCatalog({
|
||||||
|
page: pageNumber,
|
||||||
|
limit: 6,
|
||||||
|
search: deferredSearch || undefined,
|
||||||
|
categoryId: categoryId || undefined,
|
||||||
|
sort
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создаёт или обновляет продукт в зависимости от открытого editor state.
|
||||||
|
*/
|
||||||
|
const handleProductSubmit = async (input: CreateCatalogProduct): Promise<void> => {
|
||||||
|
setMutationError(null)
|
||||||
|
setIsMutating(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editorProduct) {
|
||||||
|
await updateCatalogProduct(editorProduct.id, {
|
||||||
|
...input,
|
||||||
|
version: editorProduct.version
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await createCatalogProduct(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditorProduct(undefined)
|
||||||
|
await refresh()
|
||||||
|
} catch (mutationFailure) {
|
||||||
|
const message =
|
||||||
|
mutationFailure instanceof CatalogError
|
||||||
|
? mutationFailure.message
|
||||||
|
: 'Не удалось изменить продукт.'
|
||||||
|
setMutationError(message)
|
||||||
|
|
||||||
|
if (mutationFailure instanceof CatalogError && mutationFailure.code === 'version-conflict') {
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsMutating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подтверждает удаление и обновляет текущую страницу каталога.
|
||||||
|
*/
|
||||||
|
const handleDelete = async (product: CatalogProduct): Promise<void> => {
|
||||||
|
const shouldDelete = window.confirm(`Удалить «${product.name}» из каталога?`)
|
||||||
|
|
||||||
|
if (!shouldDelete) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setMutationError(null)
|
||||||
|
setIsMutating(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteCatalogProduct(product.id)
|
||||||
|
await refresh()
|
||||||
|
} catch (mutationFailure) {
|
||||||
|
const message =
|
||||||
|
mutationFailure instanceof CatalogError
|
||||||
|
? mutationFailure.message
|
||||||
|
: 'Не удалось удалить продукт.'
|
||||||
|
setMutationError(message)
|
||||||
|
} finally {
|
||||||
|
setIsMutating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let productsContent = (
|
||||||
|
<div className={styles.state} aria-live="polite">
|
||||||
|
Загружаем актуальный каталог…
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
productsContent = (
|
||||||
|
<div className={styles.state} role="alert">
|
||||||
|
<strong>Каталог недоступен</strong>
|
||||||
|
<span>{error.message}</span>
|
||||||
|
<Button variant="secondary" size="small" onClick={() => void refresh()}>
|
||||||
|
Повторить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoading && page && isEmptyArray(page.products)) {
|
||||||
|
productsContent = (
|
||||||
|
<div className={styles.state}>
|
||||||
|
<strong>Ничего не найдено</strong>
|
||||||
|
<span>Измените запрос или категорию.</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page && isNonEmptyArray(page.products)) {
|
||||||
|
productsContent = (
|
||||||
|
<div className={styles.grid}>
|
||||||
|
{page.products.map((product) => {
|
||||||
|
const category = categories.find((item) => item.id === product.categoryId)
|
||||||
|
const isOutOfStock = product.stock === 0
|
||||||
|
const addLabel = isOutOfStock ? 'Нет в наличии' : 'В заказ'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article key={product.id} className={styles.card}>
|
||||||
|
<div className={styles.imageFrame}>
|
||||||
|
<img src={product.imageUrl} alt="" loading="lazy" />
|
||||||
|
<span className={styles.rating}>★ {product.rating.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.cardBody}>
|
||||||
|
<div className={styles.cardMeta}>
|
||||||
|
<span>{category?.name ?? 'Без категории'}</span>
|
||||||
|
<span>{product.stock} шт.</span>
|
||||||
|
</div>
|
||||||
|
<h3>{product.name}</h3>
|
||||||
|
<p>{product.description}</p>
|
||||||
|
<div className={styles.cardFooter}>
|
||||||
|
<strong>{formatCurrency(product.priceCents, product.currency)}</strong>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
disabled={isOutOfStock}
|
||||||
|
onClick={() => onAddProduct(product)}
|
||||||
|
>
|
||||||
|
{addLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<div className={styles.adminActions}>
|
||||||
|
<Button variant="ghost" size="small" onClick={() => setEditorProduct(product)}>
|
||||||
|
Изменить v{product.version}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="small"
|
||||||
|
disabled={isMutating}
|
||||||
|
onClick={() => void handleDelete(product)}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPreviousPage = Boolean(page && page.page > 1)
|
||||||
|
const hasNextPage = Boolean(page && page.page < page.totalPages)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section {...rootAttrs} className={cl(styles.root, className)}>
|
||||||
|
<div className={styles.heading}>
|
||||||
|
<div>
|
||||||
|
<span className={styles.eyebrow}>Deterministic catalog</span>
|
||||||
|
<h2>Рабочее место с товарами</h2>
|
||||||
|
<p>Фильтры читают Simple API, корзина фиксирует версию и цену перед checkout.</p>
|
||||||
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<Button variant="secondary" onClick={() => setEditorProduct(null)}>
|
||||||
|
+ Новый продукт
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.filters}>
|
||||||
|
<label className={styles.searchField}>
|
||||||
|
<span>Поиск</span>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={search}
|
||||||
|
placeholder="Клавиатура, книга…"
|
||||||
|
onChange={(event) => {
|
||||||
|
setSearch(event.currentTarget.value)
|
||||||
|
setPageNumber(1)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={styles.selectField}>
|
||||||
|
<span>Категория</span>
|
||||||
|
<select
|
||||||
|
value={categoryId}
|
||||||
|
onChange={(event) => {
|
||||||
|
setCategoryId(event.currentTarget.value)
|
||||||
|
setPageNumber(1)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Все категории</option>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<option key={category.id} value={category.id}>
|
||||||
|
{category.name} · {category.productCount}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className={styles.selectField}>
|
||||||
|
<span>Сортировка</span>
|
||||||
|
<select
|
||||||
|
value={sort}
|
||||||
|
onChange={(event) => {
|
||||||
|
setSort(event.currentTarget.value as CatalogSort)
|
||||||
|
setPageNumber(1)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="newest">Сначала новые</option>
|
||||||
|
<option value="price-asc">Цена по возрастанию</option>
|
||||||
|
<option value="price-desc">Цена по убыванию</option>
|
||||||
|
<option value="name">По названию</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editorProduct !== undefined && (
|
||||||
|
<ProductForm
|
||||||
|
key={editorProduct?.id ?? 'new-product'}
|
||||||
|
product={editorProduct}
|
||||||
|
categories={categories}
|
||||||
|
errorMessage={mutationError}
|
||||||
|
isSubmitting={isMutating}
|
||||||
|
onCancel={() => {
|
||||||
|
setEditorProduct(undefined)
|
||||||
|
setMutationError(null)
|
||||||
|
}}
|
||||||
|
onSubmit={handleProductSubmit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{productsContent}
|
||||||
|
|
||||||
|
{page && page.totalPages > 1 && (
|
||||||
|
<div className={styles.pagination}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
disabled={!hasPreviousPage}
|
||||||
|
onClick={() => setPageNumber((currentPage) => currentPage - 1)}
|
||||||
|
>
|
||||||
|
Назад
|
||||||
|
</Button>
|
||||||
|
<span>
|
||||||
|
{page.page} / {page.totalPages} · {page.total} товаров
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
disabled={!hasNextPage}
|
||||||
|
onClick={() => setPageNumber((currentPage) => currentPage + 1)}
|
||||||
|
>
|
||||||
|
Далее
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { CatalogPanel } from './catalog-panel'
|
||||||
|
export type { CatalogPanelProps } from './types/catalog-panel-props.type'
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
.root {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading h2 {
|
||||||
|
margin: 0.22rem 0 0;
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading h2 {
|
||||||
|
font-size: clamp(1.75rem, 4vw, 2.65rem);
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading p {
|
||||||
|
max-width: 42rem;
|
||||||
|
margin: 0.65rem 0 0;
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
color: var(--color-accent-strong);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.69rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.13em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(14rem, 1fr) repeat(2, minmax(10rem, 0.42fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.85rem;
|
||||||
|
border: 1px solid var(--color-line);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: rgb(255 255 255 / 58%);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchField,
|
||||||
|
.selectField,
|
||||||
|
.textareaField {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.38rem;
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchField input,
|
||||||
|
.selectField select,
|
||||||
|
.textareaField textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0.68rem 0.8rem;
|
||||||
|
border: 1px solid var(--color-line);
|
||||||
|
border-radius: 0.72rem;
|
||||||
|
color: var(--color-ink);
|
||||||
|
background: rgb(255 255 255 / 76%);
|
||||||
|
font: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchField input:focus,
|
||||||
|
.selectField select:focus,
|
||||||
|
.textareaField textarea:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 16%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--color-line);
|
||||||
|
border-radius: 1.2rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
transition:
|
||||||
|
transform 180ms ease,
|
||||||
|
box-shadow 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: var(--shadow-card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageFrame {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 4 / 2.75;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--color-surface-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageFrame img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 350ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover .imageFrame img {
|
||||||
|
transform: scale(1.035);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.7rem;
|
||||||
|
bottom: 0.7rem;
|
||||||
|
padding: 0.36rem 0.58rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--color-ink);
|
||||||
|
background: rgb(255 250 240 / 88%);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardBody {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.8rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardMeta,
|
||||||
|
.cardFooter,
|
||||||
|
.adminActions,
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardMeta {
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.3rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card p {
|
||||||
|
min-height: 3.8rem;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.52;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardFooter strong {
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-size: 1.04rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adminActions {
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px dashed var(--color-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
min-height: 14rem;
|
||||||
|
padding: 2rem;
|
||||||
|
border: 1px dashed var(--color-line-strong);
|
||||||
|
border-radius: 1.2rem;
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state strong {
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 940px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.heading {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid,
|
||||||
|
.filters {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ComponentPropsWithoutRef } from 'react'
|
||||||
|
|
||||||
|
import type { CatalogProduct } from '../../../types/catalog-product.type'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Собственные параметры CatalogPanel.
|
||||||
|
*/
|
||||||
|
export type CatalogPanelParams = {
|
||||||
|
/** Разрешает административные mutations каталога. */
|
||||||
|
isAdmin: boolean
|
||||||
|
/** Передаёт выбранный продукт владельцу draft order. */
|
||||||
|
onAddProduct: (product: CatalogProduct) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Атрибуты корневой section без children. */
|
||||||
|
type RootAttrs = Omit<ComponentPropsWithoutRef<'section'>, 'children'>
|
||||||
|
|
||||||
|
/** Props интерактивной панели каталога. */
|
||||||
|
export type CatalogPanelProps = RootAttrs & CatalogPanelParams
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import type { FormEvent } from 'react'
|
||||||
|
|
||||||
|
import { Button } from 'ui/button'
|
||||||
|
import { Field } from 'ui/field'
|
||||||
|
import type { CatalogCurrency } from '../../types/catalog-product.type'
|
||||||
|
import type { ProductFormProps } from './types/product-form-props.type'
|
||||||
|
import styles from './styles/product-form.module.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Внутренняя форма административного product mutation.
|
||||||
|
*
|
||||||
|
* Используется для:
|
||||||
|
* - создания продукта в выбранной категории
|
||||||
|
* - редактирования последней прочитанной версии продукта
|
||||||
|
*/
|
||||||
|
export const ProductForm = (props: ProductFormProps) => {
|
||||||
|
const { product, categories, errorMessage, isSubmitting, onCancel, onSubmit } = props
|
||||||
|
const [name, setName] = useState(product?.name ?? '')
|
||||||
|
const [description, setDescription] = useState(product?.description ?? '')
|
||||||
|
const [price, setPrice] = useState(product ? String(product.priceCents / 100) : '')
|
||||||
|
const [currency, setCurrency] = useState<CatalogCurrency>(product?.currency ?? 'USD')
|
||||||
|
const [categoryId, setCategoryId] = useState(product?.categoryId ?? categories[0]?.id ?? '')
|
||||||
|
const [stock, setStock] = useState(product ? String(product.stock) : '')
|
||||||
|
const [imageUrl, setImageUrl] = useState(
|
||||||
|
product?.imageUrl ?? 'https://picsum.photos/seed/new-product/640/480'
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Нормализует значения HTML-формы в product contract.
|
||||||
|
*/
|
||||||
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||||
|
event.preventDefault()
|
||||||
|
await onSubmit({
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
priceCents: Math.round(Number(price) * 100),
|
||||||
|
currency,
|
||||||
|
categoryId,
|
||||||
|
stock: Number(stock),
|
||||||
|
imageUrl: imageUrl.trim()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = product ? `Редактирование · v${product.version}` : 'Новый продукт'
|
||||||
|
const submitLabel = product ? 'Сохранить версию' : 'Добавить продукт'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className={styles.editor} onSubmit={handleSubmit}>
|
||||||
|
<div className={styles.editorHeader}>
|
||||||
|
<div>
|
||||||
|
<span className={styles.eyebrow}>Admin workspace</span>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="small" onClick={onCancel}>
|
||||||
|
Закрыть
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.editorGrid}>
|
||||||
|
<Field
|
||||||
|
label="Название"
|
||||||
|
inputProps={{
|
||||||
|
value: name,
|
||||||
|
minLength: 2,
|
||||||
|
maxLength: 120,
|
||||||
|
onChange: (event) => setName(event.currentTarget.value),
|
||||||
|
required: true
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Цена"
|
||||||
|
inputProps={{
|
||||||
|
value: price,
|
||||||
|
type: 'number',
|
||||||
|
min: 0,
|
||||||
|
step: '0.01',
|
||||||
|
onChange: (event) => setPrice(event.currentTarget.value),
|
||||||
|
required: true
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label className={styles.selectField}>
|
||||||
|
<span>Валюта</span>
|
||||||
|
<select value={currency} onChange={(event) => setCurrency(event.currentTarget.value as CatalogCurrency)}>
|
||||||
|
<option value="USD">USD</option>
|
||||||
|
<option value="EUR">EUR</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className={styles.selectField}>
|
||||||
|
<span>Категория</span>
|
||||||
|
<select value={categoryId} onChange={(event) => setCategoryId(event.currentTarget.value)} required>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<option key={category.id} value={category.id}>
|
||||||
|
{category.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<Field
|
||||||
|
label="Остаток"
|
||||||
|
inputProps={{
|
||||||
|
value: stock,
|
||||||
|
type: 'number',
|
||||||
|
min: 0,
|
||||||
|
step: 1,
|
||||||
|
onChange: (event) => setStock(event.currentTarget.value),
|
||||||
|
required: true
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Изображение"
|
||||||
|
inputProps={{
|
||||||
|
value: imageUrl,
|
||||||
|
type: 'url',
|
||||||
|
pattern: 'https://picsum\\.photos/.*',
|
||||||
|
onChange: (event) => setImageUrl(event.currentTarget.value),
|
||||||
|
required: true
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className={styles.textareaField}>
|
||||||
|
<span>Описание</span>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
minLength={10}
|
||||||
|
maxLength={1000}
|
||||||
|
rows={3}
|
||||||
|
onChange={(event) => setDescription(event.currentTarget.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{errorMessage && (
|
||||||
|
<p className={styles.formError} role="alert">
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.editorActions}>
|
||||||
|
<Button type="submit" isLoading={isSubmitting}>
|
||||||
|
{submitLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
.editor {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.1rem;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 32%, var(--color-line));
|
||||||
|
border-radius: 1.1rem;
|
||||||
|
background: var(--color-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorHeader,
|
||||||
|
.editorActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorHeader h3 {
|
||||||
|
margin: 0.22rem 0 0;
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
color: var(--color-accent-strong);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.69rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.13em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectField,
|
||||||
|
.textareaField {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.38rem;
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectField select,
|
||||||
|
.textareaField textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0.68rem 0.8rem;
|
||||||
|
border: 1px solid var(--color-line);
|
||||||
|
border-radius: 0.72rem;
|
||||||
|
color: var(--color-ink);
|
||||||
|
background: rgb(255 255 255 / 76%);
|
||||||
|
font: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectField select:focus,
|
||||||
|
.textareaField textarea:focus {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 16%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textareaField textarea {
|
||||||
|
min-height: 6.5rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorActions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formError {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.7rem;
|
||||||
|
border-radius: 0.7rem;
|
||||||
|
color: var(--color-danger);
|
||||||
|
background: var(--color-danger-soft);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 940px) {
|
||||||
|
.editorGrid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.editorGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { CatalogCategory } from '../../../types/catalog-category.type'
|
||||||
|
import type {
|
||||||
|
CatalogProduct,
|
||||||
|
CreateCatalogProduct
|
||||||
|
} from '../../../types/catalog-product.type'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props внутренней формы создания и редактирования продукта.
|
||||||
|
*/
|
||||||
|
export type ProductFormProps = {
|
||||||
|
/** Редактируемый продукт или null для создания. */
|
||||||
|
product: CatalogProduct | null
|
||||||
|
/** Доступные категории каталога. */
|
||||||
|
categories: CatalogCategory[]
|
||||||
|
/** Ожидаемая ошибка последней mutation. */
|
||||||
|
errorMessage: string | null
|
||||||
|
/** Выполняется ли mutation. */
|
||||||
|
isSubmitting: boolean
|
||||||
|
/** Отменяет редактирование без mutation. */
|
||||||
|
onCancel: () => void
|
||||||
|
/** Передаёт проверенные значения владельцу mutation. */
|
||||||
|
onSubmit: (product: CreateCatalogProduct) => Promise<void>
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { createContext } from 'react'
|
||||||
|
|
||||||
|
import type { OrdersContextValue } from '../types/orders-context-value.type'
|
||||||
|
|
||||||
|
/** React-контекст draft order в storefront scope. */
|
||||||
|
export const OrdersContext = createContext<OrdersContextValue | null>(null)
|
||||||
25
examples/react-vite/src/domains/orders/errors/order.error.ts
Normal file
25
examples/react-vite/src/domains/orders/errors/order.error.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/** Ожидаемый неуспешный исход заказа. */
|
||||||
|
export type OrderErrorCode =
|
||||||
|
| 'product-changed'
|
||||||
|
| 'insufficient-stock'
|
||||||
|
| 'unsupported-currency'
|
||||||
|
| 'cannot-cancel'
|
||||||
|
| 'not-found'
|
||||||
|
| 'invalid-order'
|
||||||
|
| 'rate-limited'
|
||||||
|
| 'invalid-data'
|
||||||
|
| 'unavailable'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Доменная ошибка draft checkout или истории заказов.
|
||||||
|
*/
|
||||||
|
export class OrderError extends Error {
|
||||||
|
/** Стабильный код ожидаемого исхода. */
|
||||||
|
readonly code: OrderErrorCode
|
||||||
|
|
||||||
|
constructor(code: OrderErrorCode, message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'OrderError'
|
||||||
|
this.code = code
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { useOrders } from './use-orders.hook'
|
||||||
|
import type { DraftOrderProduct } from '../types/draft-order.type'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает минимальную capability добавления product snapshot в draft order.
|
||||||
|
*/
|
||||||
|
export const useAddProductToOrder = (): ((product: DraftOrderProduct) => void) => {
|
||||||
|
return useOrders().addProduct
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useGetOrderList } from 'infra/simple-rest-api'
|
||||||
|
import { OrderError } from '../errors/order.error'
|
||||||
|
import { mapOrderError } from '../source/map-order-error'
|
||||||
|
import { orderPageSchema } from '../source/order.schemas'
|
||||||
|
import type { OrderPage } from '../types/order-page.type'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Результат чтения истории заказов.
|
||||||
|
*/
|
||||||
|
export type OrderHistoryQuery = {
|
||||||
|
/** Валидированная страница заказов или null до ответа. */
|
||||||
|
page: OrderPage | null
|
||||||
|
/** Выполняется ли первый запрос. */
|
||||||
|
isLoading: boolean
|
||||||
|
/** Ожидаемая ошибка чтения или null. */
|
||||||
|
error: OrderError | null
|
||||||
|
/** Повторно получает текущую страницу. */
|
||||||
|
refresh: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет wire response истории заказов.
|
||||||
|
*/
|
||||||
|
const parseOrderPage = (response: unknown): OrderPage => {
|
||||||
|
const parsedResponse = orderPageSchema.safeParse(response)
|
||||||
|
|
||||||
|
if (!parsedResponse.success) {
|
||||||
|
throw new OrderError('invalid-data', 'Simple API вернул историю неизвестного формата.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
orders: parsedResponse.data.data,
|
||||||
|
...parsedResponse.data.meta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Повторно запускает GET текущей страницы заказов.
|
||||||
|
*/
|
||||||
|
const refreshOrderQuery = async (mutateOrders: () => Promise<unknown>): Promise<void> => {
|
||||||
|
await mutateOrders()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Предоставляет domain UI валидированную историю заказов.
|
||||||
|
*/
|
||||||
|
export const useOrderHistory = (): OrderHistoryQuery => {
|
||||||
|
const orderQuery = useGetOrderList({ page: 1, limit: 20 })
|
||||||
|
let page: OrderPage | null = null
|
||||||
|
let error: OrderError | null = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (orderQuery.data) {
|
||||||
|
page = parseOrderPage(orderQuery.data)
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
error = mapOrderError(parseError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orderQuery.error) {
|
||||||
|
error = mapOrderError(orderQuery.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет историю после checkout или cancel mutation.
|
||||||
|
*/
|
||||||
|
const refresh = async (): Promise<void> => {
|
||||||
|
await refreshOrderQuery(orderQuery.mutate)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
page,
|
||||||
|
isLoading: !orderQuery.data && !orderQuery.error,
|
||||||
|
error,
|
||||||
|
refresh
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { use } from 'react'
|
||||||
|
|
||||||
|
import { OrdersContext } from '../context/orders.context'
|
||||||
|
import type { OrdersContextValue } from '../types/orders-context-value.type'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает публичные возможности draft order и checkout.
|
||||||
|
*/
|
||||||
|
export const useOrders = (): OrdersContextValue => {
|
||||||
|
const orders = use(OrdersContext)
|
||||||
|
|
||||||
|
if (!orders) {
|
||||||
|
throw new Error('useOrders must be used inside OrdersProvider')
|
||||||
|
}
|
||||||
|
|
||||||
|
return orders
|
||||||
|
}
|
||||||
9
examples/react-vite/src/domains/orders/index.ts
Normal file
9
examples/react-vite/src/domains/orders/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export { useAddProductToOrder } from './hooks/use-add-product-to-order.hook'
|
||||||
|
export { OrdersProvider } from './orders.provider'
|
||||||
|
export { CartPanel } from './ui/cart-panel'
|
||||||
|
export { OrderHistory } from './ui/order-history'
|
||||||
|
export type { CartPanelProps } from './ui/cart-panel'
|
||||||
|
export type { OrderHistoryProps } from './ui/order-history'
|
||||||
|
export type { DraftOrderProduct } from './types/draft-order.type'
|
||||||
|
export type { Order, OrderCurrency, OrderItem, OrderStatus } from './types/order.type'
|
||||||
|
export type { OrdersProviderProps } from './types/orders-provider-props.type'
|
||||||
176
examples/react-vite/src/domains/orders/orders.provider.tsx
Normal file
176
examples/react-vite/src/domains/orders/orders.provider.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useSWRConfig } from 'swr'
|
||||||
|
|
||||||
|
import { getOrderListKey } from 'infra/simple-rest-api'
|
||||||
|
import { isEmptyArray } from 'shared/lib/value-predicates'
|
||||||
|
import { OrderError } from './errors/order.error'
|
||||||
|
import { OrdersContext } from './context/orders.context'
|
||||||
|
import { createOrder } from './source/orders.source'
|
||||||
|
import type { DraftOrderItem, DraftOrderProduct } from './types/draft-order.type'
|
||||||
|
import type { Order } from './types/order.type'
|
||||||
|
import type { OrdersProviderProps } from './types/orders-provider-props.type'
|
||||||
|
|
||||||
|
const ORDER_LIMIT_PER_PRODUCT = 20
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Владелец draft order и checkout lifecycle внутри storefront.
|
||||||
|
*
|
||||||
|
* Используется для:
|
||||||
|
* - фиксации product snapshots до подтверждения заказа
|
||||||
|
* - единственной координации локального draft и server mutation
|
||||||
|
*/
|
||||||
|
export const OrdersProvider = (props: OrdersProviderProps) => {
|
||||||
|
const { children } = props
|
||||||
|
const { mutate } = useSWRConfig()
|
||||||
|
const [items, setItems] = useState<DraftOrderItem[]>([])
|
||||||
|
const [notice, setNotice] = useState<string | null>(null)
|
||||||
|
const [createdOrder, setCreatedOrder] = useState<Order | null>(null)
|
||||||
|
const [isCheckingOut, setIsCheckingOut] = useState(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Добавляет USD product snapshot либо объясняет неподдерживаемый исход.
|
||||||
|
*/
|
||||||
|
const addProduct = (product: DraftOrderProduct): void => {
|
||||||
|
setNotice(null)
|
||||||
|
setCreatedOrder(null)
|
||||||
|
|
||||||
|
if (product.currency !== 'USD') {
|
||||||
|
setNotice('Simple API оформляет только продукты в USD.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (product.stock === 0) {
|
||||||
|
setNotice('Товар закончился на складе.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setItems((currentItems) => {
|
||||||
|
const existingItem = currentItems.find((item) => item.productId === product.id)
|
||||||
|
|
||||||
|
if (!existingItem) {
|
||||||
|
return [
|
||||||
|
...currentItems,
|
||||||
|
{
|
||||||
|
productId: product.id,
|
||||||
|
productName: product.name,
|
||||||
|
quantity: 1,
|
||||||
|
unitPriceCents: product.priceCents,
|
||||||
|
currency: 'USD',
|
||||||
|
expectedVersion: product.version,
|
||||||
|
availableStock: product.stock,
|
||||||
|
imageUrl: product.imageUrl
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxQuantity = Math.min(product.stock, ORDER_LIMIT_PER_PRODUCT)
|
||||||
|
|
||||||
|
if (existingItem.quantity >= maxQuantity) {
|
||||||
|
setNotice(`Для «${product.name}» достигнут доступный лимит.`)
|
||||||
|
return currentItems
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentItems.map((item) => {
|
||||||
|
if (item.productId !== product.id) {
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
quantity: item.quantity + 1,
|
||||||
|
unitPriceCents: product.priceCents,
|
||||||
|
expectedVersion: product.version,
|
||||||
|
availableStock: product.stock
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Изменяет количество строки в допустимом диапазоне.
|
||||||
|
*/
|
||||||
|
const setQuantity = (productId: string, quantity: number): void => {
|
||||||
|
setNotice(null)
|
||||||
|
setItems((currentItems) =>
|
||||||
|
currentItems.map((item) => {
|
||||||
|
if (item.productId !== productId) {
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxQuantity = Math.min(item.availableStock, ORDER_LIMIT_PER_PRODUCT)
|
||||||
|
const safeQuantity = Math.max(1, Math.min(quantity, maxQuantity))
|
||||||
|
|
||||||
|
return { ...item, quantity: safeQuantity }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаляет одну строку из draft order.
|
||||||
|
*/
|
||||||
|
const removeProduct = (productId: string): void => {
|
||||||
|
setNotice(null)
|
||||||
|
setItems((currentItems) => currentItems.filter((item) => item.productId !== productId))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Полностью очищает draft order и checkout feedback.
|
||||||
|
*/
|
||||||
|
const clearDraft = (): void => {
|
||||||
|
setItems([])
|
||||||
|
setNotice(null)
|
||||||
|
setCreatedOrder(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подтверждает product snapshots на backend и создаёт заказ.
|
||||||
|
*/
|
||||||
|
const checkout = async (): Promise<void> => {
|
||||||
|
if (isEmptyArray(items)) {
|
||||||
|
setNotice('Добавьте хотя бы один продукт.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setNotice(null)
|
||||||
|
setCreatedOrder(null)
|
||||||
|
setIsCheckingOut(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const order = await createOrder(items)
|
||||||
|
setItems([])
|
||||||
|
setCreatedOrder(order)
|
||||||
|
await mutate(getOrderListKey({ page: 1, limit: 20 }))
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof OrderError ? error.message : 'Не удалось создать заказ.'
|
||||||
|
setNotice(message)
|
||||||
|
} finally {
|
||||||
|
setIsCheckingOut(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemCount = items.reduce((total, item) => total + item.quantity, 0)
|
||||||
|
const totalCents = items.reduce(
|
||||||
|
(total, item) => total + item.unitPriceCents * item.quantity,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OrdersContext
|
||||||
|
value={{
|
||||||
|
items,
|
||||||
|
itemCount,
|
||||||
|
totalCents,
|
||||||
|
notice,
|
||||||
|
createdOrder,
|
||||||
|
isCheckingOut,
|
||||||
|
addProduct,
|
||||||
|
setQuantity,
|
||||||
|
removeProduct,
|
||||||
|
clearDraft,
|
||||||
|
checkout
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</OrdersContext>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { mapOrderError } from './map-order-error'
|
||||||
|
|
||||||
|
vi.mock('infra/simple-rest-api', () => ({
|
||||||
|
toSimpleRestApiError: () => ({
|
||||||
|
status: 422,
|
||||||
|
code: 'UNSUPPORTED_ORDER_CURRENCY',
|
||||||
|
message: 'Source-specific message',
|
||||||
|
requestId: 'req-test'
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('mapOrderError', () => {
|
||||||
|
it('turns a source currency failure into an orders outcome', () => {
|
||||||
|
const orderError = mapOrderError(new Error('transport failure'))
|
||||||
|
|
||||||
|
expect(orderError.code).toBe('unsupported-currency')
|
||||||
|
expect(orderError.message).not.toContain('Source-specific')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { toSimpleRestApiError } from 'infra/simple-rest-api'
|
||||||
|
import { OrderError } from '../errors/order.error'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Преобразует REST failure в ожидаемый исход домена заказов.
|
||||||
|
*/
|
||||||
|
export const mapOrderError = (error: unknown): OrderError => {
|
||||||
|
if (error instanceof OrderError) {
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiError = toSimpleRestApiError(error)
|
||||||
|
|
||||||
|
if (apiError.code === 'PRODUCT_CHANGED') {
|
||||||
|
return new OrderError('product-changed', 'Цена или версия продукта изменилась. Обновите каталог и корзину.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiError.code === 'INSUFFICIENT_STOCK') {
|
||||||
|
return new OrderError('insufficient-stock', 'Товара уже недостаточно на складе.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiError.code === 'UNSUPPORTED_ORDER_CURRENCY') {
|
||||||
|
return new OrderError('unsupported-currency', 'Checkout Simple API принимает только продукты в USD.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiError.code === 'ORDER_CANNOT_BE_CANCELLED') {
|
||||||
|
return new OrderError('cannot-cancel', 'Заказ в этом статусе уже нельзя отменить.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiError.status === 404) {
|
||||||
|
return new OrderError('not-found', 'Заказ или продукт больше не существует.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiError.status === 400 || apiError.status === 422) {
|
||||||
|
return new OrderError('invalid-order', 'Состав заказа не соответствует правилам checkout.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiError.status === 429) {
|
||||||
|
return new OrderError('rate-limited', 'Simple API ограничил частоту запросов.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OrderError('unavailable', 'Не удалось выполнить операцию с заказом.')
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const orderSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
userId: z.string(),
|
||||||
|
status: z.enum(['pending', 'paid', 'shipped', 'cancelled']),
|
||||||
|
items: z.array(
|
||||||
|
z.object({
|
||||||
|
productId: z.string(),
|
||||||
|
productName: z.string(),
|
||||||
|
quantity: z.number().int().positive(),
|
||||||
|
unitPriceCents: z.number().int().nonnegative()
|
||||||
|
})
|
||||||
|
),
|
||||||
|
totalCents: z.number().int().nonnegative(),
|
||||||
|
currency: z.enum(['USD', 'EUR']),
|
||||||
|
createdAt: z.iso.datetime()
|
||||||
|
})
|
||||||
|
|
||||||
|
export const orderResponseSchema = z.object({ data: orderSchema })
|
||||||
|
|
||||||
|
export const orderPageSchema = z.object({
|
||||||
|
data: z.array(orderSchema),
|
||||||
|
meta: z.object({
|
||||||
|
page: z.number().int().positive(),
|
||||||
|
limit: z.number().int().positive(),
|
||||||
|
total: z.number().int().nonnegative(),
|
||||||
|
totalPages: z.number().int().nonnegative()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { simpleRestApi } from 'infra/simple-rest-api'
|
||||||
|
import { OrderError } from '../errors/order.error'
|
||||||
|
import type { DraftOrderItem } from '../types/draft-order.type'
|
||||||
|
import type { Order } from '../types/order.type'
|
||||||
|
import { mapOrderError } from './map-order-error'
|
||||||
|
import { orderResponseSchema } from './order.schemas'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет wire response одиночного заказа.
|
||||||
|
*/
|
||||||
|
const parseOrderResponse = (response: unknown): Order => {
|
||||||
|
const parsedResponse = orderResponseSchema.safeParse(response)
|
||||||
|
|
||||||
|
if (!parsedResponse.success) {
|
||||||
|
throw new OrderError('invalid-data', 'Simple API вернул заказ неизвестного формата.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedResponse.data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создаёт заказ из зафиксированных строк draft order.
|
||||||
|
*/
|
||||||
|
export const createOrder = async (items: DraftOrderItem[]): Promise<Order> => {
|
||||||
|
const body = {
|
||||||
|
items: items.map((item) => ({
|
||||||
|
productId: item.productId,
|
||||||
|
quantity: item.quantity,
|
||||||
|
expectedVersion: item.expectedVersion,
|
||||||
|
expectedUnitPriceCents: item.unitPriceCents
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await simpleRestApi.orders.simpleOrdersCreate(body)
|
||||||
|
return parseOrderResponse(response)
|
||||||
|
} catch (error) {
|
||||||
|
throw mapOrderError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отменяет доступный пользователю заказ.
|
||||||
|
*/
|
||||||
|
export const cancelOrder = async (orderId: string): Promise<Order> => {
|
||||||
|
try {
|
||||||
|
const response = await simpleRestApi.orders.simpleOrdersCancel({ id: orderId })
|
||||||
|
return parseOrderResponse(response)
|
||||||
|
} catch (error) {
|
||||||
|
throw mapOrderError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Минимальный product snapshot, принимаемый владельцем draft order.
|
||||||
|
*/
|
||||||
|
export type DraftOrderProduct = {
|
||||||
|
/** Идентификатор продукта. */
|
||||||
|
id: string
|
||||||
|
/** Название продукта. */
|
||||||
|
name: string
|
||||||
|
/** Цена в минимальных единицах. */
|
||||||
|
priceCents: number
|
||||||
|
/** Валюта продукта. */
|
||||||
|
currency: 'USD' | 'EUR'
|
||||||
|
/** Версия product snapshot. */
|
||||||
|
version: number
|
||||||
|
/** Доступный остаток. */
|
||||||
|
stock: number
|
||||||
|
/** URL изображения. */
|
||||||
|
imageUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Строка draft order до checkout.
|
||||||
|
*/
|
||||||
|
export type DraftOrderItem = {
|
||||||
|
/** Идентификатор продукта. */
|
||||||
|
productId: string
|
||||||
|
/** Название продукта. */
|
||||||
|
productName: string
|
||||||
|
/** Выбранное количество. */
|
||||||
|
quantity: number
|
||||||
|
/** Цена из product snapshot. */
|
||||||
|
unitPriceCents: number
|
||||||
|
/** Валюта продукта. */
|
||||||
|
currency: 'USD'
|
||||||
|
/** Версия из product snapshot. */
|
||||||
|
expectedVersion: number
|
||||||
|
/** Остаток, ограничивающий количество. */
|
||||||
|
availableStock: number
|
||||||
|
/** URL изображения продукта. */
|
||||||
|
imageUrl: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Order } from './order.type'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Страница доступных пользователю заказов.
|
||||||
|
*/
|
||||||
|
export type OrderPage = {
|
||||||
|
/** Заказы текущей страницы. */
|
||||||
|
orders: Order[]
|
||||||
|
/** Номер текущей страницы. */
|
||||||
|
page: number
|
||||||
|
/** Лимит страницы. */
|
||||||
|
limit: number
|
||||||
|
/** Общее число доступных заказов. */
|
||||||
|
total: number
|
||||||
|
/** Общее число страниц. */
|
||||||
|
totalPages: number
|
||||||
|
}
|
||||||
39
examples/react-vite/src/domains/orders/types/order.type.ts
Normal file
39
examples/react-vite/src/domains/orders/types/order.type.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/** Статус заказа Simple Store. */
|
||||||
|
export type OrderStatus = 'pending' | 'paid' | 'shipped' | 'cancelled'
|
||||||
|
|
||||||
|
/** Валюта заказа. */
|
||||||
|
export type OrderCurrency = 'USD' | 'EUR'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Зафиксированная строка оформленного заказа.
|
||||||
|
*/
|
||||||
|
export type OrderItem = {
|
||||||
|
/** Идентификатор купленного продукта. */
|
||||||
|
productId: string
|
||||||
|
/** Название продукта на момент оформления. */
|
||||||
|
productName: string
|
||||||
|
/** Купленное количество. */
|
||||||
|
quantity: number
|
||||||
|
/** Цена единицы на момент оформления. */
|
||||||
|
unitPriceCents: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Заказ, доступный текущему пользователю.
|
||||||
|
*/
|
||||||
|
export type Order = {
|
||||||
|
/** Стабильный идентификатор заказа. */
|
||||||
|
id: string
|
||||||
|
/** Идентификатор владельца заказа. */
|
||||||
|
userId: string
|
||||||
|
/** Текущее состояние заказа. */
|
||||||
|
status: OrderStatus
|
||||||
|
/** Зафиксированные строки заказа. */
|
||||||
|
items: OrderItem[]
|
||||||
|
/** Итоговая стоимость в минимальных единицах. */
|
||||||
|
totalCents: number
|
||||||
|
/** Валюта всего заказа. */
|
||||||
|
currency: OrderCurrency
|
||||||
|
/** ISO-дата оформления. */
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { DraftOrderItem, DraftOrderProduct } from './draft-order.type'
|
||||||
|
import type { Order } from './order.type'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Публичные возможности draft order и checkout.
|
||||||
|
*/
|
||||||
|
export type OrdersContextValue = {
|
||||||
|
/** Текущие строки draft order. */
|
||||||
|
items: DraftOrderItem[]
|
||||||
|
/** Общее количество единиц в draft order. */
|
||||||
|
itemCount: number
|
||||||
|
/** Итоговая стоимость draft order в USD cents. */
|
||||||
|
totalCents: number
|
||||||
|
/** Последнее пользовательское сообщение checkout. */
|
||||||
|
notice: string | null
|
||||||
|
/** Последний успешно созданный заказ. */
|
||||||
|
createdOrder: Order | null
|
||||||
|
/** Выполняется ли checkout. */
|
||||||
|
isCheckingOut: boolean
|
||||||
|
/** Добавляет актуальный product snapshot в draft order. */
|
||||||
|
addProduct: (product: DraftOrderProduct) => void
|
||||||
|
/** Изменяет количество строки с учётом stock и API-лимита. */
|
||||||
|
setQuantity: (productId: string, quantity: number) => void
|
||||||
|
/** Удаляет продукт из draft order. */
|
||||||
|
removeProduct: (productId: string) => void
|
||||||
|
/** Очищает draft order. */
|
||||||
|
clearDraft: () => void
|
||||||
|
/** Проверяет snapshot на backend и создаёт заказ. */
|
||||||
|
checkout: () => Promise<void>
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props владельца draft order.
|
||||||
|
*/
|
||||||
|
export type OrdersProviderProps = {
|
||||||
|
/** Storefront scope, внутри которого живёт draft order. */
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import cl from 'clsx'
|
||||||
|
|
||||||
|
import { formatCurrency } from 'shared/lib/format'
|
||||||
|
import { isEmptyArray, isNonEmptyArray } from 'shared/lib/value-predicates'
|
||||||
|
import { Button } from 'ui/button'
|
||||||
|
import { useOrders } from '../../hooks/use-orders.hook'
|
||||||
|
import type { CartPanelProps } from './types/cart-panel-props.type'
|
||||||
|
import styles from './styles/cart-panel.module.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Панель draft order с количеством и checkout.
|
||||||
|
*
|
||||||
|
* Используется для:
|
||||||
|
* - проверки выбранных product snapshots перед отправкой
|
||||||
|
* - запуска единственного checkout-сценария домена orders
|
||||||
|
*/
|
||||||
|
export const CartPanel = (props: CartPanelProps) => {
|
||||||
|
const { className, ...rootAttrs } = props
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
itemCount,
|
||||||
|
totalCents,
|
||||||
|
notice,
|
||||||
|
createdOrder,
|
||||||
|
isCheckingOut,
|
||||||
|
setQuantity,
|
||||||
|
removeProduct,
|
||||||
|
clearDraft,
|
||||||
|
checkout
|
||||||
|
} = useOrders()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside {...rootAttrs} className={cl(styles.root, className)}>
|
||||||
|
<div className={styles.heading}>
|
||||||
|
<div>
|
||||||
|
<span className={styles.eyebrow}>Draft order</span>
|
||||||
|
<h2>Корзина</h2>
|
||||||
|
</div>
|
||||||
|
<span className={styles.count}>{itemCount}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEmptyArray(items) && !createdOrder && (
|
||||||
|
<div className={styles.empty}>
|
||||||
|
<span className={styles.emptyMark}>+</span>
|
||||||
|
<p>Выберите продукты в каталоге. Версия и цена сохранятся до checkout.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isNonEmptyArray(items) && (
|
||||||
|
<div className={styles.items}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<article key={item.productId} className={styles.item}>
|
||||||
|
<img src={item.imageUrl} alt="" />
|
||||||
|
<div className={styles.itemInfo}>
|
||||||
|
<strong>{item.productName}</strong>
|
||||||
|
<span>
|
||||||
|
{formatCurrency(item.unitPriceCents, item.currency)} · v{item.expectedVersion}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.quantity}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Уменьшить количество ${item.productName}`}
|
||||||
|
onClick={() => setQuantity(item.productId, item.quantity - 1)}
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<span>{item.quantity}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Увеличить количество ${item.productName}`}
|
||||||
|
onClick={() => setQuantity(item.productId, item.quantity + 1)}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.remove}
|
||||||
|
onClick={() => removeProduct(item.productId)}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{notice && (
|
||||||
|
<p className={styles.notice} role="alert">
|
||||||
|
{notice}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{createdOrder && (
|
||||||
|
<div className={styles.success} role="status">
|
||||||
|
<span>Заказ создан</span>
|
||||||
|
<strong>{createdOrder.id}</strong>
|
||||||
|
<small>{formatCurrency(createdOrder.totalCents, createdOrder.currency)}</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isNonEmptyArray(items) && (
|
||||||
|
<div className={styles.summary}>
|
||||||
|
<div>
|
||||||
|
<span>Итого</span>
|
||||||
|
<strong>{formatCurrency(totalCents, 'USD')}</strong>
|
||||||
|
</div>
|
||||||
|
<Button isLoading={isCheckingOut} onClick={() => void checkout()}>
|
||||||
|
Оформить заказ
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="small" onClick={clearDraft}>
|
||||||
|
Очистить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { CartPanel } from './cart-panel'
|
||||||
|
export type { CartPanelProps } from './types/cart-panel-props.type'
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
.root {
|
||||||
|
position: sticky;
|
||||||
|
top: 5.8rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
align-self: start;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--color-line);
|
||||||
|
border-radius: 1.2rem;
|
||||||
|
background: var(--color-ink);
|
||||||
|
box-shadow: 0 24px 60px rgb(22 37 30 / 16%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading,
|
||||||
|
.summary > div,
|
||||||
|
.item,
|
||||||
|
.quantity {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading,
|
||||||
|
.summary > div {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading h2 {
|
||||||
|
margin: 0.15rem 0 0;
|
||||||
|
color: #fffaf0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
color: var(--color-accent-light);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.63rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
display: grid;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--color-ink);
|
||||||
|
background: var(--color-accent-light);
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
min-height: 10rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px dashed rgb(255 255 255 / 20%);
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
color: rgb(255 250 240 / 62%);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyMark {
|
||||||
|
display: grid;
|
||||||
|
width: 2.6rem;
|
||||||
|
height: 2.6rem;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid rgb(255 255 255 / 20%);
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 1.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
max-height: 22rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2.7rem minmax(0, 1fr) auto;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.65rem;
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
background: rgb(255 255 255 / 7%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item img {
|
||||||
|
width: 2.7rem;
|
||||||
|
height: 2.7rem;
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemInfo {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
color: rgb(255 250 240 / 58%);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemInfo strong {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #fffaf0;
|
||||||
|
font-size: 0.77rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quantity {
|
||||||
|
gap: 0.4rem;
|
||||||
|
color: #fffaf0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quantity button {
|
||||||
|
display: grid;
|
||||||
|
width: 1.45rem;
|
||||||
|
height: 1.45rem;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid rgb(255 255 255 / 18%);
|
||||||
|
border-radius: 50%;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove {
|
||||||
|
grid-column: 2 / -1;
|
||||||
|
justify-self: start;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
color: #f5a9a1;
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.66rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice,
|
||||||
|
.success {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice {
|
||||||
|
color: #ffd5cf;
|
||||||
|
background: rgb(180 61 47 / 24%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
display: grid;
|
||||||
|
color: var(--color-accent-light);
|
||||||
|
background: rgb(92 194 148 / 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success strong {
|
||||||
|
color: #fffaf0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
padding-top: 0.9rem;
|
||||||
|
border-top: 1px solid rgb(255 255 255 / 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary span {
|
||||||
|
color: rgb(255 250 240 / 58%);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary strong {
|
||||||
|
color: #fffaf0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.root {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { ComponentPropsWithoutRef } from 'react'
|
||||||
|
|
||||||
|
/** Собственные параметры CartPanel. */
|
||||||
|
export type CartPanelParams = object
|
||||||
|
|
||||||
|
/** Атрибуты корневого aside без children. */
|
||||||
|
type RootAttrs = Omit<ComponentPropsWithoutRef<'aside'>, 'children'>
|
||||||
|
|
||||||
|
/** Props панели draft order. */
|
||||||
|
export type CartPanelProps = RootAttrs & CartPanelParams
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { OrderHistory } from './order-history'
|
||||||
|
export type { OrderHistoryProps } from './types/order-history-props.type'
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import cl from 'clsx'
|
||||||
|
|
||||||
|
import { formatCurrency, formatDate } from 'shared/lib/format'
|
||||||
|
import { isEmptyArray, isNonEmptyArray } from 'shared/lib/value-predicates'
|
||||||
|
import { Button } from 'ui/button'
|
||||||
|
import { OrderError } from '../../errors/order.error'
|
||||||
|
import { useOrderHistory } from '../../hooks/use-order-history.hook'
|
||||||
|
import { cancelOrder } from '../../source/orders.source'
|
||||||
|
import type { OrderHistoryProps } from './types/order-history-props.type'
|
||||||
|
import styles from './styles/order-history.module.css'
|
||||||
|
|
||||||
|
const STATUS_LABELS = {
|
||||||
|
pending: 'Ожидает оплаты',
|
||||||
|
paid: 'Оплачен',
|
||||||
|
shipped: 'Отправлен',
|
||||||
|
cancelled: 'Отменён'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* История доступных пользователю заказов и допустимая отмена.
|
||||||
|
*
|
||||||
|
* Используется для:
|
||||||
|
* - просмотра customer-owned или всех admin-заказов
|
||||||
|
* - выполнения разрешённого status transition в cancelled
|
||||||
|
*/
|
||||||
|
export const OrderHistory = (props: OrderHistoryProps) => {
|
||||||
|
const { className, ...rootAttrs } = props
|
||||||
|
const { page, isLoading, error, refresh } = useOrderHistory()
|
||||||
|
const [mutationError, setMutationError] = useState<string | null>(null)
|
||||||
|
const [cancellingOrderId, setCancellingOrderId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отменяет заказ и повторно получает серверную историю.
|
||||||
|
*/
|
||||||
|
const handleCancel = async (orderId: string): Promise<void> => {
|
||||||
|
setMutationError(null)
|
||||||
|
setCancellingOrderId(orderId)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await cancelOrder(orderId)
|
||||||
|
await refresh()
|
||||||
|
} catch (cancelError) {
|
||||||
|
const message =
|
||||||
|
cancelError instanceof OrderError ? cancelError.message : 'Не удалось отменить заказ.'
|
||||||
|
setMutationError(message)
|
||||||
|
} finally {
|
||||||
|
setCancellingOrderId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = <div className={styles.state}>Загружаем историю…</div>
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
content = (
|
||||||
|
<div className={styles.state} role="alert">
|
||||||
|
<strong>История недоступна</strong>
|
||||||
|
<span>{error.message}</span>
|
||||||
|
<Button variant="secondary" size="small" onClick={() => void refresh()}>
|
||||||
|
Повторить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoading && page && isEmptyArray(page.orders)) {
|
||||||
|
content = <div className={styles.state}>Заказов пока нет. Соберите первый draft.</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page && isNonEmptyArray(page.orders)) {
|
||||||
|
content = (
|
||||||
|
<div className={styles.list}>
|
||||||
|
{page.orders.map((order) => {
|
||||||
|
const canCancel = order.status === 'pending' || order.status === 'paid'
|
||||||
|
const itemSummary = order.items
|
||||||
|
.map((item) => `${item.productName} × ${item.quantity}`)
|
||||||
|
.join(', ')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article key={order.id} className={styles.order}>
|
||||||
|
<div className={styles.orderTopline}>
|
||||||
|
<div>
|
||||||
|
<span className={styles.orderId}>{order.id}</span>
|
||||||
|
<span className={cl(styles.status, styles[order.status])}>
|
||||||
|
{STATUS_LABELS[order.status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<strong>{formatCurrency(order.totalCents, order.currency)}</strong>
|
||||||
|
</div>
|
||||||
|
<p>{itemSummary}</p>
|
||||||
|
<div className={styles.orderFooter}>
|
||||||
|
<span>{formatDate(order.createdAt)}</span>
|
||||||
|
<span>Владелец: {order.userId}</span>
|
||||||
|
{canCancel && (
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="small"
|
||||||
|
isLoading={cancellingOrderId === order.id}
|
||||||
|
onClick={() => void handleCancel(order.id)}
|
||||||
|
>
|
||||||
|
Отменить
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section {...rootAttrs} className={cl(styles.root, className)}>
|
||||||
|
<div className={styles.heading}>
|
||||||
|
<div>
|
||||||
|
<span className={styles.eyebrow}>Protected resource</span>
|
||||||
|
<h2>История заказов</h2>
|
||||||
|
</div>
|
||||||
|
{page && <span>{page.total} записей</span>}
|
||||||
|
</div>
|
||||||
|
{mutationError && (
|
||||||
|
<p className={styles.error} role="alert">
|
||||||
|
{mutationError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{content}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
.root {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
padding-top: 2rem;
|
||||||
|
border-top: 1px solid var(--color-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading,
|
||||||
|
.orderTopline,
|
||||||
|
.orderFooter,
|
||||||
|
.orderTopline > div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading h2 {
|
||||||
|
margin: 0.15rem 0 0;
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.8rem;
|
||||||
|
letter-spacing: -0.035em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading > span,
|
||||||
|
.eyebrow,
|
||||||
|
.orderId {
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.67rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.8rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--color-line);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: rgb(255 255 255 / 62%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderTopline strong {
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
padding: 0.28rem 0.48rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.66rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending {
|
||||||
|
color: #8a5e10;
|
||||||
|
background: #fff1c7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paid {
|
||||||
|
color: #176042;
|
||||||
|
background: #dff5e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shipped {
|
||||||
|
color: #20598a;
|
||||||
|
background: #e0f0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelled {
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
background: var(--color-surface-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.order p {
|
||||||
|
min-height: 2.5rem;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderFooter {
|
||||||
|
justify-content: flex-start;
|
||||||
|
color: var(--color-ink-faint);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderFooter button {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-height: 8rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px dashed var(--color-line-strong);
|
||||||
|
border-radius: 1rem;
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state strong {
|
||||||
|
color: var(--color-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.7rem;
|
||||||
|
border-radius: 0.7rem;
|
||||||
|
color: var(--color-danger);
|
||||||
|
background: var(--color-danger-soft);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 740px) {
|
||||||
|
.list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderFooter {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderFooter button {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { ComponentPropsWithoutRef } from 'react'
|
||||||
|
|
||||||
|
/** Собственные параметры OrderHistory. */
|
||||||
|
export type OrderHistoryParams = object
|
||||||
|
|
||||||
|
/** Атрибуты корневой section без children. */
|
||||||
|
type RootAttrs = Omit<ComponentPropsWithoutRef<'section'>, 'children'>
|
||||||
|
|
||||||
|
/** Props истории доступных заказов. */
|
||||||
|
export type OrderHistoryProps = RootAttrs & OrderHistoryParams
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { createContext } from 'react'
|
||||||
|
|
||||||
|
import type { SessionContextValue } from '../types/session-context-value.type'
|
||||||
|
|
||||||
|
/** React-контекст application-scoped пользовательской сессии. */
|
||||||
|
export const SessionContext = createContext<SessionContextValue | null>(null)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/** Ожидаемый неуспешный исход сценария сессии. */
|
||||||
|
export type SessionErrorCode =
|
||||||
|
| 'invalid-credentials'
|
||||||
|
| 'rate-limited'
|
||||||
|
| 'expired'
|
||||||
|
| 'unavailable'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Доменная ошибка входа или восстановления сессии.
|
||||||
|
*/
|
||||||
|
export class SessionError extends Error {
|
||||||
|
/** Стабильный код ожидаемого исхода. */
|
||||||
|
readonly code: SessionErrorCode
|
||||||
|
|
||||||
|
constructor(code: SessionErrorCode, message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'SessionError'
|
||||||
|
this.code = code
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { useSession } from './use-session.hook'
|
||||||
|
import type { SessionState } from '../types/session-state.type'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает read-only состояние сессии для app и compositions.
|
||||||
|
*/
|
||||||
|
export const useSessionState = (): SessionState => {
|
||||||
|
const { user, status } = useSession()
|
||||||
|
|
||||||
|
return { user, status }
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { use } from 'react'
|
||||||
|
|
||||||
|
import { SessionContext } from '../context/session.context'
|
||||||
|
import type { SessionContextValue } from '../types/session-context-value.type'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает публичные возможности текущей пользовательской сессии.
|
||||||
|
*/
|
||||||
|
export const useSession = (): SessionContextValue => {
|
||||||
|
const session = use(SessionContext)
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new Error('useSession must be used inside SessionProvider')
|
||||||
|
}
|
||||||
|
|
||||||
|
return session
|
||||||
|
}
|
||||||
7
examples/react-vite/src/domains/session/index.ts
Normal file
7
examples/react-vite/src/domains/session/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export { useSessionState } from './hooks/use-session-state.hook'
|
||||||
|
export { SessionProvider } from './session.provider'
|
||||||
|
export { SessionBadge } from './ui/session-badge'
|
||||||
|
export { SignInForm } from './ui/sign-in-form'
|
||||||
|
export type { SessionProviderProps } from './types/session-provider-props.type'
|
||||||
|
export type { SessionState } from './types/session-state.type'
|
||||||
|
export type { SessionRole, SessionUser } from './types/session-user.type'
|
||||||
87
examples/react-vite/src/domains/session/session.provider.tsx
Normal file
87
examples/react-vite/src/domains/session/session.provider.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { useEffect, useEffectEvent, useState } from 'react'
|
||||||
|
|
||||||
|
import { subscribeSimpleRestApiSessionExpired } from 'infra/simple-rest-api'
|
||||||
|
import { SessionContext } from './context/session.context'
|
||||||
|
import { loginSession, logoutSession, restoreSession } from './source/session.source'
|
||||||
|
import type { SessionCredentials } from './types/session-credentials.type'
|
||||||
|
import type { SessionProviderProps } from './types/session-provider-props.type'
|
||||||
|
import type { SessionStatus } from './types/session-context-value.type'
|
||||||
|
import type { SessionUser } from './types/session-user.type'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Владелец application-scoped пользовательской сессии.
|
||||||
|
*
|
||||||
|
* Используется для:
|
||||||
|
* - восстановления пользователя при загрузке browser-приложения
|
||||||
|
* - синхронизации login, logout и окончательного истечения credentials
|
||||||
|
*/
|
||||||
|
export const SessionProvider = (props: SessionProviderProps) => {
|
||||||
|
const { children, onSessionClosed } = props
|
||||||
|
const [user, setUser] = useState<SessionUser | null>(null)
|
||||||
|
const [status, setStatus] = useState<SessionStatus>('restoring')
|
||||||
|
|
||||||
|
const handleSessionExpired = useEffectEvent(() => {
|
||||||
|
setUser(null)
|
||||||
|
setStatus('anonymous')
|
||||||
|
onSessionClosed?.()
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isActive = true
|
||||||
|
|
||||||
|
void restoreSession()
|
||||||
|
.then((restoredUser) => {
|
||||||
|
if (!isActive) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setUser(restoredUser)
|
||||||
|
setStatus(restoredUser ? 'authenticated' : 'anonymous')
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!isActive) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSessionExpired()
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isActive = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return subscribeSimpleRestApiSessionExpired(handleSessionExpired)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выполняет вход и публикует нового пользователя в session context.
|
||||||
|
*/
|
||||||
|
const login = async (credentials: SessionCredentials): Promise<void> => {
|
||||||
|
const authenticatedUser = await loginSession(credentials)
|
||||||
|
setUser(authenticatedUser)
|
||||||
|
setStatus('authenticated')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Завершает сессию независимо от доступности logout endpoint.
|
||||||
|
*/
|
||||||
|
const logout = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await logoutSession()
|
||||||
|
} catch {
|
||||||
|
// Local session still closes when the idempotent revoke endpoint is unavailable.
|
||||||
|
} finally {
|
||||||
|
setUser(null)
|
||||||
|
setStatus('anonymous')
|
||||||
|
onSessionClosed?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SessionContext value={{ user, status, login, logout }}>
|
||||||
|
{children}
|
||||||
|
</SessionContext>
|
||||||
|
)
|
||||||
|
}
|
||||||
104
examples/react-vite/src/domains/session/source/session.source.ts
Normal file
104
examples/react-vite/src/domains/session/source/session.source.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
import {
|
||||||
|
clearSimpleRestApiTokens,
|
||||||
|
getSimpleRestApiRefreshToken,
|
||||||
|
hasSimpleRestApiRefreshToken,
|
||||||
|
setSimpleRestApiTokens,
|
||||||
|
simpleRestApi,
|
||||||
|
toSimpleRestApiError
|
||||||
|
} from 'infra/simple-rest-api'
|
||||||
|
import type { SessionCredentials } from '../types/session-credentials.type'
|
||||||
|
import type { SessionUser } from '../types/session-user.type'
|
||||||
|
import { SessionError } from '../errors/session.error'
|
||||||
|
|
||||||
|
const sessionUserSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
email: z.email(),
|
||||||
|
name: z.string(),
|
||||||
|
role: z.enum(['admin', 'customer']),
|
||||||
|
avatarUrl: z.url().nullable()
|
||||||
|
})
|
||||||
|
|
||||||
|
const authResponseSchema = z.object({
|
||||||
|
data: z.object({
|
||||||
|
tokens: z.object({
|
||||||
|
accessToken: z.string(),
|
||||||
|
refreshToken: z.string(),
|
||||||
|
expiresIn: z.number(),
|
||||||
|
tokenType: z.literal('Bearer')
|
||||||
|
}),
|
||||||
|
user: sessionUserSchema
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const userResponseSchema = z.object({ data: sessionUserSchema })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Преобразует source failure в ожидаемый исход домена сессии.
|
||||||
|
*/
|
||||||
|
const mapSessionError = (error: unknown): SessionError => {
|
||||||
|
const apiError = toSimpleRestApiError(error)
|
||||||
|
|
||||||
|
if (apiError.code === 'INVALID_CREDENTIALS') {
|
||||||
|
return new SessionError('invalid-credentials', 'Неверная почта или пароль.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiError.status === 429) {
|
||||||
|
return new SessionError('rate-limited', 'Слишком много попыток. Повторите через несколько секунд.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiError.status === 401) {
|
||||||
|
return new SessionError('expired', 'Сессия истекла. Войдите снова.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SessionError('unavailable', 'Simple API недоступен. Проверьте, запущен ли demo-backend.')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Открывает сессию по demo-credentials и сохраняет transport tokens.
|
||||||
|
*/
|
||||||
|
export const loginSession = async (credentials: SessionCredentials): Promise<SessionUser> => {
|
||||||
|
try {
|
||||||
|
const response = await simpleRestApi.auth.simpleAuthLogin(credentials)
|
||||||
|
const parsedResponse = authResponseSchema.parse(response)
|
||||||
|
|
||||||
|
setSimpleRestApiTokens(parsedResponse.data.tokens)
|
||||||
|
|
||||||
|
return parsedResponse.data.user
|
||||||
|
} catch (error) {
|
||||||
|
throw mapSessionError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Восстанавливает пользователя через сохранённый refresh token.
|
||||||
|
*/
|
||||||
|
export const restoreSession = async (): Promise<SessionUser | null> => {
|
||||||
|
if (!hasSimpleRestApiRefreshToken()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await simpleRestApi.users.simpleUsersMe()
|
||||||
|
return userResponseSchema.parse(response).data
|
||||||
|
} catch (error) {
|
||||||
|
clearSimpleRestApiTokens()
|
||||||
|
throw mapSessionError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отзывает refresh token и всегда очищает локальные credentials.
|
||||||
|
*/
|
||||||
|
export const logoutSession = async (): Promise<void> => {
|
||||||
|
const refreshToken = getSimpleRestApiRefreshToken()
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (refreshToken) {
|
||||||
|
await simpleRestApi.auth.simpleAuthLogout({ refreshToken })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearSimpleRestApiTokens()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { SessionCredentials } from './session-credentials.type'
|
||||||
|
import type { SessionUser } from './session-user.type'
|
||||||
|
|
||||||
|
/** Состояние восстановления пользовательской сессии. */
|
||||||
|
export type SessionStatus = 'restoring' | 'authenticated' | 'anonymous'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Публичные возможности домена пользовательской сессии.
|
||||||
|
*/
|
||||||
|
export type SessionContextValue = {
|
||||||
|
/** Текущий пользователь или null вне авторизованной сессии. */
|
||||||
|
user: SessionUser | null
|
||||||
|
/** Текущее состояние lifecycle сессии. */
|
||||||
|
status: SessionStatus
|
||||||
|
/** Выполняет вход и открывает новую пользовательскую сессию. */
|
||||||
|
login: (credentials: SessionCredentials) => Promise<void>
|
||||||
|
/** Завершает пользовательскую сессию и отзывает refresh token. */
|
||||||
|
logout: () => Promise<void>
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Credentials формы входа.
|
||||||
|
*/
|
||||||
|
export type SessionCredentials = {
|
||||||
|
/** Email demo-пользователя. */
|
||||||
|
email: string
|
||||||
|
/** Пароль demo-пользователя. */
|
||||||
|
password: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props application-scoped SessionProvider.
|
||||||
|
*/
|
||||||
|
export type SessionProviderProps = {
|
||||||
|
/** Browser-приложение, использующее одну пользовательскую сессию. */
|
||||||
|
children: ReactNode
|
||||||
|
/** Сообщает app assembly, что session-scoped технические данные нужно очистить. */
|
||||||
|
onSessionClosed?: () => void
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { SessionStatus } from './session-context-value.type'
|
||||||
|
import type { SessionUser } from './session-user.type'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Публичное read-only состояние пользовательской сессии.
|
||||||
|
*/
|
||||||
|
export type SessionState = {
|
||||||
|
/** Текущий пользователь или null вне авторизованной сессии. */
|
||||||
|
user: SessionUser | null
|
||||||
|
/** Текущее состояние session lifecycle. */
|
||||||
|
status: SessionStatus
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/** Роль пользователя в Simple Store. */
|
||||||
|
export type SessionRole = 'admin' | 'customer'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пользователь, с которым связана текущая browser-сессия.
|
||||||
|
*/
|
||||||
|
export type SessionUser = {
|
||||||
|
/** Стабильный идентификатор пользователя. */
|
||||||
|
id: string
|
||||||
|
/** Email для входа и отображения. */
|
||||||
|
email: string
|
||||||
|
/** Отображаемое имя. */
|
||||||
|
name: string
|
||||||
|
/** Роль, определяющая доступные продуктовые действия. */
|
||||||
|
role: SessionRole
|
||||||
|
/** URL аватара или null для текстового fallback. */
|
||||||
|
avatarUrl: string | null
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { SessionBadge } from './session-badge'
|
||||||
|
export type { SessionBadgeProps } from './types/session-badge-props.type'
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import cl from 'clsx'
|
||||||
|
|
||||||
|
import { Button } from 'ui/button'
|
||||||
|
import { useSession } from '../../hooks/use-session.hook'
|
||||||
|
import type { SessionBadgeProps } from './types/session-badge-props.type'
|
||||||
|
import styles from './styles/session-badge.module.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Краткое представление пользователя и действие выхода.
|
||||||
|
*
|
||||||
|
* Используется для:
|
||||||
|
* - отображения активной роли в application shell
|
||||||
|
* - завершения пользовательской сессии
|
||||||
|
*/
|
||||||
|
export const SessionBadge = (props: SessionBadgeProps) => {
|
||||||
|
const { className, ...rootAttrs } = props
|
||||||
|
const { user, logout } = useSession()
|
||||||
|
const [isLoggingOut, setIsLoggingOut] = useState(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Завершает сессию и блокирует повторное действие до очистки контекста.
|
||||||
|
*/
|
||||||
|
const handleLogout = async (): Promise<void> => {
|
||||||
|
setIsLoggingOut(true)
|
||||||
|
await logout()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleLabel = user.role === 'admin' ? 'Администратор' : 'Покупатель'
|
||||||
|
const initials = user.name
|
||||||
|
.split(' ')
|
||||||
|
.map((part) => part.slice(0, 1))
|
||||||
|
.join('')
|
||||||
|
.slice(0, 2)
|
||||||
|
|
||||||
|
let avatar = <span className={styles.avatarFallback}>{initials}</span>
|
||||||
|
|
||||||
|
if (user.avatarUrl) {
|
||||||
|
avatar = <img className={styles.avatar} src={user.avatarUrl} alt="" />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div {...rootAttrs} className={cl(styles.root, className)}>
|
||||||
|
{avatar}
|
||||||
|
<span className={styles.identity}>
|
||||||
|
<strong>{user.name}</strong>
|
||||||
|
<span>{roleLabel}</span>
|
||||||
|
</span>
|
||||||
|
<Button variant="ghost" size="small" isLoading={isLoggingOut} onClick={handleLogout}>
|
||||||
|
Выйти
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar,
|
||||||
|
.avatarFallback {
|
||||||
|
width: 2.35rem;
|
||||||
|
height: 2.35rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarFallback {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--color-accent-strong);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity {
|
||||||
|
display: grid;
|
||||||
|
min-width: 8rem;
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity strong {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.identity {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { ComponentPropsWithoutRef } from 'react'
|
||||||
|
|
||||||
|
/** Собственные параметры SessionBadge. */
|
||||||
|
export type SessionBadgeParams = object
|
||||||
|
|
||||||
|
/** Атрибуты корневого div без children. */
|
||||||
|
type RootAttrs = Omit<ComponentPropsWithoutRef<'div'>, 'children'>
|
||||||
|
|
||||||
|
/** Props краткого представления текущей сессии. */
|
||||||
|
export type SessionBadgeProps = RootAttrs & SessionBadgeParams
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { SignInForm } from './sign-in-form'
|
||||||
|
export type { SignInFormProps } from './types/sign-in-form-props.type'
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import type { FormEvent } from 'react'
|
||||||
|
import cl from 'clsx'
|
||||||
|
|
||||||
|
import { Button } from 'ui/button'
|
||||||
|
import { Field } from 'ui/field'
|
||||||
|
import { SessionError } from '../../errors/session.error'
|
||||||
|
import { useSession } from '../../hooks/use-session.hook'
|
||||||
|
import type { SignInFormProps } from './types/sign-in-form-props.type'
|
||||||
|
import styles from './styles/sign-in-form.module.css'
|
||||||
|
|
||||||
|
const DEMO_PASSWORD = 'demo1234'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Форма входа в Simple Store с быстрым выбором demo-роли.
|
||||||
|
*
|
||||||
|
* Используется для:
|
||||||
|
* - входа администратора для управления каталогом
|
||||||
|
* - входа покупателя для оформления и просмотра заказов
|
||||||
|
*/
|
||||||
|
export const SignInForm = (props: SignInFormProps) => {
|
||||||
|
const { className, ...rootAttrs } = props
|
||||||
|
const { login } = useSession()
|
||||||
|
const [email, setEmail] = useState('admin@demo.local')
|
||||||
|
const [password, setPassword] = useState(DEMO_PASSWORD)
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет credentials через session domain и показывает ожидаемый исход.
|
||||||
|
*/
|
||||||
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||||
|
event.preventDefault()
|
||||||
|
setErrorMessage(null)
|
||||||
|
setIsSubmitting(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login({ email, password })
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof SessionError ? error.message : 'Не удалось выполнить вход.'
|
||||||
|
setErrorMessage(message)
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
{...rootAttrs}
|
||||||
|
className={cl(styles.root, className)}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
<div className={styles.accounts} aria-label="Demo accounts">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cl(styles.account, email === 'admin@demo.local' && styles.accountActive)}
|
||||||
|
onClick={() => setEmail('admin@demo.local')}
|
||||||
|
>
|
||||||
|
<span className={styles.accountRole}>Администратор</span>
|
||||||
|
<span>Каталог и заказы</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cl(styles.account, email === 'customer@demo.local' && styles.accountActive)}
|
||||||
|
onClick={() => setEmail('customer@demo.local')}
|
||||||
|
>
|
||||||
|
<span className={styles.accountRole}>Покупатель</span>
|
||||||
|
<span>Покупки и история</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Email"
|
||||||
|
inputProps={{
|
||||||
|
type: 'email',
|
||||||
|
name: 'email',
|
||||||
|
autoComplete: 'username',
|
||||||
|
value: email,
|
||||||
|
onChange: (event) => setEmail(event.currentTarget.value),
|
||||||
|
required: true
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Пароль"
|
||||||
|
hint="Для обеих demo-учётных записей: demo1234"
|
||||||
|
inputProps={{
|
||||||
|
type: 'password',
|
||||||
|
name: 'password',
|
||||||
|
autoComplete: 'current-password',
|
||||||
|
value: password,
|
||||||
|
minLength: 8,
|
||||||
|
onChange: (event) => setPassword(event.currentTarget.value),
|
||||||
|
required: true
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{errorMessage && (
|
||||||
|
<p className={styles.error} role="alert">
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" isLoading={isSubmitting}>
|
||||||
|
Войти в магазин
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
.root {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accounts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding: 0.85rem;
|
||||||
|
border: 1px solid var(--color-line);
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
color: var(--color-ink-muted);
|
||||||
|
background: rgb(255 255 255 / 55%);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account:hover,
|
||||||
|
.account:focus-visible {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accountActive {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
background: var(--color-accent-soft);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accountRole {
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.8rem 0.9rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
color: var(--color-danger);
|
||||||
|
background: var(--color-danger-soft);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.accounts {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { ComponentPropsWithoutRef } from 'react'
|
||||||
|
|
||||||
|
/** Собственные параметры SignInForm. */
|
||||||
|
export type SignInFormParams = object
|
||||||
|
|
||||||
|
/** Атрибуты корневой form. */
|
||||||
|
type RootAttrs = Omit<ComponentPropsWithoutRef<'form'>, 'children' | 'onSubmit'>
|
||||||
|
|
||||||
|
/** Props формы входа. */
|
||||||
|
export type SignInFormProps = RootAttrs & SignInFormParams
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user