chore: sync

This commit is contained in:
2026-08-10 09:31:49 +03:00
parent 691069af8e
commit af155fff0a
216 changed files with 0 additions and 17893 deletions

View File

@@ -1 +0,0 @@
NEXT_PUBLIC_SIMPLE_API_URL=http://localhost:3001

View File

@@ -1,42 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -1,5 +0,0 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

View File

@@ -1 +0,0 @@
@AGENTS.md

View File

@@ -1,102 +0,0 @@
# Demo frontend
Полноценное Next.js 16 приложение поверх меньшего из двух fixture API: `../demo-backend/openapi/simple.json`.
Пример показывает SLM Level 1 на каталоге: public data, JWT login/refresh/logout, persisted cart, checkout, protected orders, RBAC, admin CRUD, optimistic locking и детерминированные network outcomes.
## Запуск
Требуются Node.js 20+, npm 10+ и современный browser с Web Locks API. Auth и cart transitions работают fail-closed без cross-tab lock; `localhost` считается secure context для локальной разработки.
Сначала запустите Simple API:
```bash
cd ../demo-backend
npm install
npm run dev:simple
```
Затем запустите frontend:
```bash
npm install
npm run dev
```
Откройте `http://localhost:3000`. Backend по умолчанию доступен на `http://localhost:3001`.
Чтобы изменить URL backend, создайте `.env.local` рядом с `package.json`:
```bash
NEXT_PUBLIC_SIMPLE_API_URL=http://localhost:3001
```
## Demo accounts
| Email | Password | Role | Scenarios |
|---|---|---|---|
| `admin@demo.local` | `demo1234` | admin | Product CRUD, all orders |
| `customer@demo.local` | `demo1234` | customer | Cart, checkout, own orders |
## Routes
| Route | Composition responsibility |
|---|---|
| `/` | Product search, category filter, sorting and pagination |
| `/products/[productId]` | Product detail and add-to-cart |
| `/cart` | Multi-domain checkout coordination |
| `/orders` | Protected order history and cancellation |
| `/sign-in` | JWT session lifecycle and account selection |
| `/admin/products` | RBAC-gated CRUD and optimistic locking |
Плавающий `Demo controls` widget переключает `X-Demo-Scenario`: slow, timeout, 500, 429, empty, expired auth, forbidden и conflict. Отдельное действие `Seed 250` загружает настоящий большой seed с интерактивными product IDs; synthetic `large-dataset` header намеренно не используется для mutation UI.
## SLM boundary
`src` является SLM root:
```text
src/
├── app/ Next.js bootstrap and route entries
├── compositions/ Layout, screens and demo widget
├── domains/ Auth, catalog, cart, orders and demo-control
├── infra/ Generated REST client, JWT storage and browser storage
├── ui/ Product-agnostic button, form field and feedback panel
└── shared/ Deterministic predicates, formatting and Result type
```
Пример использует Level 1 осознанно: у каждого домена одна browser runtime integration. Дополнительные factories, adapters и assemblies Level 2 не окупили бы стоимость. Checkout остаётся в composition, поэтому `orders` не импортирует `cart`, а module graph остаётся ацикличным.
State и lifecycle распределены по владельцам:
- `auth` владеет пользовательской сессией; `infra/simple-auth-session` хранит technical JWT pair, стабильный `sessionId` и CAS revision.
- `cart` владеет строками, totals и persisted snapshot validation; monotonic revision и conditional clear сериализованы между вкладками.
- SWR cache принадлежит REST infra module, создаётся отдельным provider и remount-ится при смене logical auth session.
- React providers создаются один раз в application scope и очищают свои subscriptions при unmount.
- Route entries только адаптируют Next.js params и подключают public APIs compositions.
- Reset, seed, role и request-scenario transitions синхронизируют cache, auth, cart и route-local pagination между вкладками.
## REST client
Split SDK генерируется из committed OpenAPI:
```bash
npm run codegen:simple-rest-api
```
Generated-код живёт только в `src/infra/simple-rest-api/generated` и не редактируется вручную. GET hooks вызывают точечные operations через `simpleHttpClient`; submit-сценарии используют полный `simpleRestApi`. Внешний код импортирует REST capability только через `@/infra/simple-rest-api`.
OpenAPI fixture описывает числовые `page` и `limit` как `object`. Исправление generated type изолировано в `types/to-generated-query.ts`; runtime query остаётся числовым.
JWT refresh выполняется в transport `onError`, ограничен одним retry и дедуплицирует конкурентные refresh requests одной revision. CAS не позволяет позднему refresh воскресить logout или перезаписать новый login. Хранение refresh token в `localStorage` допустимо только для этой архитектурной fixture; production-приложение должно выбрать threat model и более безопасную session strategy.
Checkout привязан к captured auth session и persisted cart revision. Backend атомарно проверяет product version, unit price, USD currency, aggregate stock и уникальность product lines. Timeout-сценарий откладывает mutation handler и отменяет его при disconnect, поэтому frontend timeout не скрывает завершившийся POST.
## Проверка
```bash
npm run check
npm run build
```
`npm run check` запускает architecture constraints, ESLint, TypeScript и Vitest. Architecture script строит import graph через TypeScript AST и явный module manifest, затем проверяет направление слоёв, public module imports, side-effect/dynamic imports, запрет утечки generated SDK и циклы.

View File

@@ -1,37 +0,0 @@
import js from '@eslint/js'
import nextPlugin from '@next/eslint-plugin-next'
import { defineConfig, globalIgnores } from 'eslint/config'
import reactHooks from 'eslint-plugin-react-hooks'
import globals from 'globals'
import tseslint from 'typescript-eslint'
export default defineConfig([
globalIgnores([
'.next/**',
'out/**',
'build/**',
'coverage/**',
'next-env.d.ts',
'src/infra/simple-rest-api/generated/**'
]),
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.{js,mjs,mts,ts,tsx}'],
plugins: {
'@next/next': nextPlugin,
'react-hooks': reactHooks
},
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
},
rules: {
...nextPlugin.configs.recommended.rules,
...nextPlugin.configs['core-web-vitals'].rules,
...reactHooks.configs.flat.recommended.rules
}
}
])

View File

@@ -1,24 +0,0 @@
import { fileURLToPath } from 'node:url'
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
turbopack: {
root: fileURLToPath(new URL('.', import.meta.url))
},
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'picsum.photos',
pathname: '/**'
},
{
protocol: 'https',
hostname: 'i.pravatar.cc',
pathname: '/**'
}
]
}
}
export default nextConfig

File diff suppressed because it is too large Load Diff

View File

@@ -1,40 +0,0 @@
{
"name": "demo-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"check:architecture": "node scripts/check-architecture.mjs",
"check": "npm run check:architecture && npm run lint && npm run typecheck && npm run test",
"codegen:simple-rest-api": "npx @gromlab/api-codegen@5.1.1 -i ../demo-backend/openapi/simple.json -o src/infra/simple-rest-api/generated"
},
"dependencies": {
"clsx": "2.1.1",
"next": "16.2.12",
"react": "19.2.8",
"react-dom": "19.2.8",
"swr": "2.4.2"
},
"devDependencies": {
"@eslint/js": "10.0.1",
"@next/eslint-plugin-next": "16.2.12",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "10.8.0",
"eslint-plugin-react-hooks": "7.1.1",
"globals": "17.8.0",
"typescript": "^5",
"typescript-eslint": "8.65.0",
"vitest": "4.1.10"
},
"overrides": {
"postcss": "8.5.25",
"sharp": "0.35.3"
}
}

View File

@@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480" role="img" aria-labelledby="title description">
<title id="title">Product image unavailable</title>
<desc id="description">Neutral geometric placeholder for a missing product image.</desc>
<rect width="640" height="480" fill="#ebe9e2"/>
<path d="M0 390 180 210l105 105 78-78 277 243H0Z" fill="#b7d72c"/>
<circle cx="470" cy="130" r="58" fill="#3057ff"/>
<path d="M72 74h214v24H72zm0 48h142v16H72z" fill="#171813"/>
</svg>

Before

Width:  |  Height:  |  Size: 496 B

View File

@@ -1,360 +0,0 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { dirname, extname, join, relative, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import ts from 'typescript'
const SCRIPT_DIRECTORY = dirname(fileURLToPath(import.meta.url))
const PROJECT_ROOT = resolve(SCRIPT_DIRECTORY, '..')
const SOURCE_ROOT = join(PROJECT_ROOT, 'src')
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx'])
const MODULE_ROOTS = [
'compositions/layouts/store-shell',
'compositions/screens/cart',
'compositions/screens/catalog',
'compositions/screens/orders',
'compositions/screens/product-admin',
'compositions/screens/product-detail',
'compositions/screens/sign-in',
'compositions/widgets/demo-toolbar',
'domains/auth',
'domains/cart',
'domains/catalog',
'domains/demo-control',
'domains/orders',
'infra/browser-storage',
'infra/simple-auth-session',
'infra/simple-rest-api',
'shared/lib/value-predicates',
'ui/button',
'ui/feedback-panel',
'ui/form-field'
].sort((left, right) => right.length - left.length)
const MODULAR_LAYERS = new Set(['compositions', 'domains', 'infra', 'ui'])
const ALLOWED_TARGETS = {
app: new Set(['app', 'compositions', 'domains', 'infra', 'ui', 'shared']),
compositions: new Set(['compositions', 'domains', 'infra', 'ui', 'shared']),
domains: new Set(['domains', 'infra', 'ui', 'shared']),
infra: new Set(['infra', 'shared']),
ui: new Set(['ui', 'shared']),
shared: new Set(['shared'])
}
/**
* Рекурсивно собирает TypeScript-файлы SLM root.
*/
const collectSourceFiles = (directory) => {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const entryPath = join(directory, entry.name)
if (entry.isDirectory()) {
return collectSourceFiles(entryPath)
}
return SOURCE_EXTENSIONS.has(extname(entry.name)) ? [entryPath] : []
})
}
/**
* Нормализует filesystem path к формату architecture manifest.
*/
const normalizePath = (filePath) => {
return filePath.split(sep).join('/')
}
/**
* Возвращает путь файла относительно SLM root.
*/
const getSourcePath = (filePath) => {
return normalizePath(relative(SOURCE_ROOT, filePath))
}
/**
* Определяет слой относительного source path.
*/
const getLayer = (sourcePath) => {
return sourcePath.split('/')[0]
}
/**
* Находит явно объявленного module-owner по longest-prefix rule.
*/
const getModuleId = (sourcePath) => {
return MODULE_ROOTS.find(
(moduleRoot) => sourcePath === moduleRoot || sourcePath.startsWith(`${moduleRoot}/`)
) ?? null
}
/**
* Проверяет deep import относительно явно объявленного public entry.
*/
const isDeepModuleImport = (targetPath, sourceModuleId) => {
const targetModuleId = getModuleId(targetPath)
return targetModuleId !== null &&
targetModuleId !== sourceModuleId &&
targetPath !== targetModuleId
}
/**
* Проверяет направление слоя независимо от синтаксиса import path.
*/
const isLayerImportAllowed = (sourcePath, targetPath) => {
const sourceLayer = getLayer(sourcePath)
const targetLayer = getLayer(targetPath)
return ALLOWED_TARGETS[sourceLayer]?.has(targetLayer) === true
}
/**
* Извлекает static, side-effect, reexport и dynamic module specifiers через TypeScript AST.
*/
const getModuleSpecifiers = (filePath, sourceCode) => {
const scriptKind = extname(filePath) === '.tsx' ? ts.ScriptKind.TSX : ts.ScriptKind.TS
const sourceFile = ts.createSourceFile(
filePath,
sourceCode,
ts.ScriptTarget.Latest,
true,
scriptKind
)
const specifiers = []
/**
* Обходит один AST node и собирает строковые module references.
*/
const visit = (node) => {
if (
(ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
node.moduleSpecifier &&
ts.isStringLiteral(node.moduleSpecifier)
) {
specifiers.push(node.moduleSpecifier.text)
}
if (
ts.isImportEqualsDeclaration(node) &&
ts.isExternalModuleReference(node.moduleReference) &&
node.moduleReference.expression &&
ts.isStringLiteral(node.moduleReference.expression)
) {
specifiers.push(node.moduleReference.expression.text)
}
if (
ts.isCallExpression(node) &&
node.arguments.length === 1 &&
ts.isStringLiteral(node.arguments[0]) &&
(node.expression.kind === ts.SyntaxKind.ImportKeyword ||
(ts.isIdentifier(node.expression) && node.expression.text === 'require'))
) {
specifiers.push(node.arguments[0].text)
}
if (
ts.isImportTypeNode(node) &&
ts.isLiteralTypeNode(node.argument) &&
ts.isStringLiteral(node.argument.literal)
) {
specifiers.push(node.argument.literal.text)
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return specifiers
}
/**
* Разрешает относительный TypeScript import.
*/
const resolveRelativeImport = (sourceFile, specifier) => {
const basePath = resolve(dirname(sourceFile), specifier)
const candidates = [
basePath,
`${basePath}.ts`,
`${basePath}.tsx`,
join(basePath, 'index.ts'),
join(basePath, 'index.tsx')
]
return candidates.find((candidate) => existsSync(candidate)) ?? null
}
/**
* Находит первый cycle в module graph.
*/
const findCycle = (graph) => {
const visited = new Set()
const active = new Set()
const path = []
/**
* Обходит один module node depth-first.
*/
const visit = (moduleId) => {
if (active.has(moduleId)) {
const cycleStart = path.indexOf(moduleId)
return [...path.slice(cycleStart), moduleId]
}
if (visited.has(moduleId)) {
return null
}
visited.add(moduleId)
active.add(moduleId)
path.push(moduleId)
for (const dependency of graph.get(moduleId) ?? []) {
const cycle = visit(dependency)
if (cycle !== null) {
return cycle
}
}
path.pop()
active.delete(moduleId)
return null
}
for (const moduleId of graph.keys()) {
const cycle = visit(moduleId)
if (cycle !== null) {
return cycle
}
}
return null
}
const violations = []
const graph = new Map()
const sourceFiles = collectSourceFiles(SOURCE_ROOT)
const parserProbe = getModuleSpecifiers(
'architecture-probe.ts',
`
import '@/infra/simple-rest-api'
export { useAuth } from '@/domains/auth'
type Auth = import('@/domains/auth').AuthContextValue
const lazy = import('@/compositions/screens/catalog')
`
)
const expectedProbeSpecifiers = [
'@/infra/simple-rest-api',
'@/domains/auth',
'@/domains/auth',
'@/compositions/screens/catalog'
]
expectedProbeSpecifiers.forEach((specifier) => {
if (!parserProbe.includes(specifier)) {
violations.push(`architecture parser fixture missed ${specifier}`)
}
})
if (!isDeepModuleImport('shared/lib/value-predicates/value-predicates', 'domains/cart')) {
violations.push('architecture boundary fixture did not detect shared module deep import')
}
if (isLayerImportAllowed('shared/bridge.ts', 'domains/auth/index.ts')) {
violations.push('architecture boundary fixture allowed shared to import domains relatively')
}
MODULE_ROOTS.forEach((moduleRoot) => {
const moduleDirectory = join(SOURCE_ROOT, moduleRoot)
const publicEntry = join(moduleDirectory, 'index.ts')
if (!existsSync(moduleDirectory) || !existsSync(publicEntry)) {
violations.push(`${moduleRoot}: declared module must be a folder with index.ts public API`)
}
})
sourceFiles.forEach((sourceFile) => {
const sourcePath = getSourcePath(sourceFile)
const sourceLayer = getLayer(sourcePath)
const sourceModuleId = getModuleId(sourcePath)
const relativeSource = relative(PROJECT_ROOT, sourceFile)
const sourceCode = readFileSync(sourceFile, 'utf8')
if (MODULAR_LAYERS.has(sourceLayer) && sourceModuleId === null) {
violations.push(`${relativeSource}: implementation file has no declared module-owner`)
}
getModuleSpecifiers(sourceFile, sourceCode).forEach((specifier) => {
if (specifier.startsWith('@/')) {
const targetPath = specifier.slice(2)
const targetLayer = getLayer(targetPath)
const targetModuleId = getModuleId(targetPath)
if (!isLayerImportAllowed(sourcePath, targetPath)) {
violations.push(`${relativeSource}: ${sourceLayer} cannot import ${targetLayer} via ${specifier}`)
}
if (isDeepModuleImport(targetPath, sourceModuleId)) {
violations.push(`${relativeSource}: deep import into ${targetModuleId} via ${specifier}`)
}
if (sourceModuleId !== null && targetModuleId !== null && sourceModuleId !== targetModuleId) {
const dependencies = graph.get(sourceModuleId) ?? new Set()
dependencies.add(targetModuleId)
graph.set(sourceModuleId, dependencies)
}
return
}
if (specifier.startsWith('.')) {
const targetFile = resolveRelativeImport(sourceFile, specifier)
if (targetFile === null) {
return
}
const targetModuleId = getModuleId(getSourcePath(targetFile))
const targetPath = getSourcePath(targetFile)
const targetLayer = getLayer(targetPath)
if (!isLayerImportAllowed(sourcePath, targetPath)) {
violations.push(
`${relativeSource}: ${sourceLayer} cannot import ${targetLayer} via ${specifier}`
)
}
if (
sourceModuleId !== null &&
targetModuleId !== null &&
sourceModuleId !== targetModuleId
) {
violations.push(`${relativeSource}: relative import crosses into ${targetModuleId}`)
}
}
})
const isRestModuleFile = sourceModuleId === 'infra/simple-rest-api'
if (sourceCode.includes('/generated') && !isRestModuleFile) {
violations.push(`${relativeSource}: generated SDK import escaped infra/simple-rest-api`)
}
})
const cycle = findCycle(graph)
if (cycle !== null) {
violations.push(`module dependency cycle: ${cycle.join(' -> ')}`)
}
if (violations.length > 0) {
console.error('SLM architecture check failed:')
violations.forEach((violation) => console.error(`- ${violation}`))
process.exitCode = 1
} else {
console.log(
`SLM architecture check passed for ${sourceFiles.length} files and ${MODULE_ROOTS.length} declared modules.`
)
}

View File

@@ -1,17 +0,0 @@
import type { Metadata } from 'next'
import { ProductAdminScreen } from '@/compositions/screens/product-admin'
/**
* Metadata catalog administration route.
*/
export const metadata: Metadata = {
title: 'Product admin'
}
/**
* Подключает admin composition к framework route.
*/
export default function ProductAdminPage() {
return <ProductAdminScreen />
}

View File

@@ -1,17 +0,0 @@
import type { Metadata } from 'next'
import { CartScreen } from '@/compositions/screens/cart'
/**
* Metadata cart route.
*/
export const metadata: Metadata = {
title: 'Cart'
}
/**
* Подключает multi-domain checkout composition к cart route.
*/
export default function CartPage() {
return <CartScreen />
}

View File

@@ -1,12 +0,0 @@
import { StoreShellLayout } from '@/compositions/layouts/store-shell'
import type { AppLayoutProps } from '../types/app-layout-props.type'
/**
* Подключает общий storefront layout ко всем leaf routes группы.
*/
export default function StoreLayout(props: AppLayoutProps) {
const { children } = props
return <StoreShellLayout>{children}</StoreShellLayout>
}

View File

@@ -1,17 +0,0 @@
import type { Metadata } from 'next'
import { OrdersScreen } from '@/compositions/screens/orders'
/**
* Metadata protected orders route.
*/
export const metadata: Metadata = {
title: 'Orders'
}
/**
* Подключает protected orders composition к framework route.
*/
export default function OrdersPage() {
return <OrdersScreen />
}

View File

@@ -1,8 +0,0 @@
import { CatalogScreen } from '@/compositions/screens/catalog'
/**
* Подключает catalog composition к корневому route.
*/
export default function CatalogPage() {
return <CatalogScreen />
}

View File

@@ -1,21 +0,0 @@
import type { Metadata } from 'next'
import { ProductDetailScreen } from '@/compositions/screens/product-detail'
import type { ProductPageProps } from '../../../types/product-page-props.type'
/**
* Metadata dynamic product route без запроса к runtime fixture во время build.
*/
export const metadata: Metadata = {
title: 'Product detail'
}
/**
* Адаптирует async dynamic segment к product detail composition.
*/
export default async function ProductPage(props: ProductPageProps) {
const { productId } = await props.params
return <ProductDetailScreen productId={productId} />
}

View File

@@ -1,17 +0,0 @@
import type { Metadata } from 'next'
import { SignInScreen } from '@/compositions/screens/sign-in'
/**
* Metadata sign-in route.
*/
export const metadata: Metadata = {
title: 'Sign in'
}
/**
* Подключает auth composition к framework route.
*/
export default function SignInPage() {
return <SignInScreen />
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -1,85 +0,0 @@
:root {
--color-paper: #f3efe4;
--color-surface: #faf7ef;
--color-surface-strong: #e6e0d4;
--color-ink: #191918;
--color-ink-muted: #65615a;
--color-ink-faint: #67635c;
--color-line: #d7d0c3;
--color-line-strong: #aaa297;
--color-blue: #2847f4;
--color-acid: #c9ff50;
--color-acid-dark: #6f9c00;
--color-orange: #ff8a3d;
--color-error: #b83228;
--color-focus: #8dacff;
--font-body: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-display: 'Arial Black', 'Helvetica Neue', Arial, sans-serif;
--font-mono: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
--shadow-card: 0 10px 40px rgb(37 31 22 / 7%);
--shadow-card-hover: 0 20px 55px rgb(37 31 22 / 14%);
}
html {
color: var(--color-ink);
background: var(--color-paper);
scroll-behavior: smooth;
height: 100%;
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
body {
min-height: 100%;
display: flex;
flex-direction: column;
color: var(--color-ink);
background:
radial-gradient(circle at 15% 0%, rgb(255 255 255 / 58%), transparent 25%),
var(--color-paper);
font-family: var(--font-body);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
a {
color: inherit;
text-decoration: none;
}
button,
input,
select,
textarea {
font: inherit;
}
::selection {
color: var(--color-ink);
background: var(--color-acid);
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}

View File

@@ -1,31 +0,0 @@
import type { Metadata } from 'next'
import { AppProviders } from './providers'
import type { AppLayoutProps } from './types/app-layout-props.type'
import './globals.css'
/**
* Статические metadata архитектурного demo-приложения.
*/
export const metadata: Metadata = {
title: {
default: 'Layer Supply',
template: '%s / Layer Supply'
},
description: 'A complete Next.js storefront demonstrating Scoped Layered Module Design.'
}
/**
* Корневая Next.js entry point с application-scoped providers.
*/
export default function RootLayout(props: AppLayoutProps) {
const { children } = props
return (
<html lang="en">
<body>
<AppProviders>{children}</AppProviders>
</body>
</html>
)
}

View File

@@ -1,49 +0,0 @@
'use client'
import { AuthProvider, useAuth } from '@/domains/auth'
import { CartProvider } from '@/domains/cart'
import { SimpleRestApiProvider } from '@/infra/simple-rest-api'
import type { AppLayoutProps } from './types/app-layout-props.type'
/**
* Создаёт новый REST cache при каждой смене logical auth session.
*
* Используется для:
* - исключения DTO предыдущего пользователя из нового session scope
*/
const AuthScopedRestApiProvider = (props: AppLayoutProps) => {
const { children } = props
const auth = useAuth()
const cacheScopeKey = auth.status === 'checking'
? `checking:${auth.sessionKey ?? 'anonymous'}`
: auth.sessionKey ?? auth.status
return (
<SimpleRestApiProvider
key={cacheScopeKey}
isPaused={auth.status === 'checking'}
>
{children}
</SimpleRestApiProvider>
)
}
/**
* Подключает application-scoped domain state и auth-scoped REST cache.
*
* Используется для:
* - сохранения auth/cart state между route transitions
* - пересоздания technical cache на границе пользовательской сессии
*/
export const AppProviders = (props: AppLayoutProps) => {
const { children } = props
return (
<AuthProvider>
<CartProvider>
<AuthScopedRestApiProvider>{children}</AuthScopedRestApiProvider>
</CartProvider>
</AuthProvider>
)
}

View File

@@ -1,9 +0,0 @@
import type { ReactNode } from 'react'
/**
* Props framework layouts, передающих готовое route subtree.
*/
export type AppLayoutProps = {
/** Вложенное route subtree. */
children: ReactNode
}

View File

@@ -1,10 +0,0 @@
/**
* Props dynamic product route в Next.js 16.
*/
export type ProductPageProps = {
/** Асинхронные параметры dynamic segment. */
params: Promise<{
/** Идентификатор продукта из URL. */
productId: string
}>
}

View File

@@ -1,2 +0,0 @@
export { StoreShellLayout } from './store-shell.layout'
export type { StoreShellLayoutProps } from './types/store-shell-layout-props.type'

View File

@@ -1,134 +0,0 @@
'use client'
import cl from 'clsx'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useAuth } from '@/domains/auth'
import { useCart } from '@/domains/cart'
import { DemoToolbarWidget } from '@/compositions/widgets/demo-toolbar'
import { Button } from '@/ui/button'
import type { StoreShellLayoutProps } from './types/store-shell-layout-props.type'
import styles from './styles/store-shell.module.css'
/**
* Общий каркас навигации и application-scoped controls магазина.
*
* Используется для:
* - сохранения auth/cart контекста между App Router страницами
* - доступа к deterministic demo controls
*/
export const StoreShellLayout = (props: StoreShellLayoutProps) => {
const { children, className, ...rootAttrs } = props
const pathname = usePathname()
const auth = useAuth()
const cart = useCart()
const isAdmin = auth.status === 'authenticated' && auth.user?.role === 'admin'
const hasSession = auth.sessionKey !== null
const accountLabel = auth.user?.name ?? 'Sign in'
/**
* Определяет активный верхнеуровневый navigation item.
*/
const isActivePath = (path: string): boolean => {
return path === '/' ? pathname === '/' : pathname.startsWith(path)
}
const isCatalogActive = isActivePath('/')
const isCartActive = isActivePath('/cart')
const isOrdersActive = isActivePath('/orders')
const isAdminActive = isActivePath('/admin')
/**
* Завершает текущую сессию из общего header.
*/
const handleSignOut = (): void => {
void auth.signOut()
}
/**
* Пытается восстановить unreadable cart явным empty snapshot.
*/
const handleCartReset = (): void => {
void cart.clearCart()
}
return (
<div {...rootAttrs} className={cl(styles.root, className)}>
<header className={styles.header}>
<Link className={styles.brand} href="/" aria-label="Layer Supply home">
<span className={styles.brandMark}>LS</span>
<span className={styles.brandText}>
Layer
<br />
Supply
</span>
</Link>
<nav className={styles.nav} aria-label="Primary navigation">
<Link
className={cl(styles.navLink, isCatalogActive && styles.active)}
href="/"
aria-current={isCatalogActive ? 'page' : undefined}
>
Catalog
</Link>
<Link
className={cl(styles.navLink, isCartActive && styles.active)}
href="/cart"
aria-current={isCartActive ? 'page' : undefined}
>
Cart <span className={styles.count}>{cart.itemCount}</span>
</Link>
<Link
className={cl(styles.navLink, isOrdersActive && styles.active)}
href="/orders"
aria-current={isOrdersActive ? 'page' : undefined}
>
Orders
</Link>
{isAdmin && (
<Link
className={cl(styles.navLink, isAdminActive && styles.active)}
href="/admin/products"
aria-current={isAdminActive ? 'page' : undefined}
>
Admin
</Link>
)}
</nav>
<div className={styles.account}>
<span className={styles.accountMeta}>{auth.status}</span>
<Link className={styles.accountLink} href="/sign-in">
{accountLabel}
</Link>
{hasSession && (
<Button variant="ghost" size="small" onClick={handleSignOut}>
Exit
</Button>
)}
</div>
{cart.error && (
<div className={styles.cartError} role="alert">
<span>{cart.error.message}</span>
<Button variant="ghost" size="small" onClick={handleCartReset}>
Reset cart
</Button>
</div>
)}
</header>
<div className={styles.page}>{children}</div>
<footer className={styles.footer}>
<span>Next.js 16 + SLM Level 1</span>
<span>Simple API / localhost:3001</span>
</footer>
<DemoToolbarWidget />
</div>
)
}

View File

@@ -1,186 +0,0 @@
.root {
min-height: 100vh;
}
.header {
position: sticky;
top: 0;
z-index: 30;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: clamp(18px, 4vw, 56px);
min-height: 82px;
padding: 12px clamp(18px, 5vw, 72px);
border-bottom: 1px solid var(--color-line);
background: color-mix(in srgb, var(--color-paper) 88%, transparent);
backdrop-filter: blur(18px);
}
.brand {
display: inline-flex;
align-items: center;
gap: 10px;
}
.brandMark {
display: grid;
place-items: center;
width: 42px;
height: 42px;
color: var(--color-paper);
border-radius: 50%;
background: var(--color-blue);
font-family: var(--font-display);
font-size: 0.9rem;
}
.brandText {
font-family: var(--font-display);
font-size: 0.86rem;
font-weight: 820;
letter-spacing: 0.04em;
line-height: 0.82;
text-transform: uppercase;
}
.nav {
display: flex;
align-items: center;
gap: clamp(14px, 3vw, 34px);
}
.navLink {
position: relative;
padding: 10px 0;
color: var(--color-ink-muted);
font-size: 0.76rem;
font-weight: 760;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.navLink::after {
position: absolute;
right: 0;
bottom: 3px;
left: 0;
height: 3px;
border-radius: 3px;
background: var(--color-orange);
transform: scaleX(0);
transition: transform 160ms ease;
content: '';
}
.navLink:hover,
.active {
color: var(--color-ink);
}
.active::after {
transform: scaleX(1);
}
.count {
display: inline-grid;
place-items: center;
min-width: 20px;
height: 20px;
margin-left: 3px;
padding: 0 5px;
color: var(--color-ink);
border-radius: 999px;
background: var(--color-acid);
font-family: var(--font-mono);
font-size: 0.66rem;
}
.account {
display: grid;
grid-template-columns: auto auto;
align-items: center;
justify-items: end;
gap: 2px 10px;
}
.accountMeta {
grid-column: 1 / -1;
color: var(--color-ink-faint);
font-family: var(--font-mono);
font-size: 0.6rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.accountLink {
max-width: 160px;
overflow: hidden;
font-size: 0.82rem;
font-weight: 720;
text-overflow: ellipsis;
white-space: nowrap;
}
.cartError {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
padding: 8px 12px;
color: var(--color-error);
border-left: 3px solid var(--color-error);
background: color-mix(in srgb, var(--color-error) 8%, transparent);
font-size: 0.75rem;
}
.page {
min-height: calc(100vh - 160px);
}
.footer {
display: flex;
justify-content: space-between;
gap: 24px;
padding: 28px clamp(18px, 5vw, 72px) 90px;
color: var(--color-ink-faint);
border-top: 1px solid var(--color-line);
font-family: var(--font-mono);
font-size: 0.68rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
@media (max-width: 850px) {
.header {
grid-template-columns: 1fr auto;
}
.nav {
grid-column: 1 / -1;
grid-row: 2;
overflow-x: auto;
}
}
@media (max-width: 540px) {
.header {
padding-inline: 14px;
}
.brandText,
.accountMeta {
display: none;
}
.nav {
gap: 18px;
}
.footer {
flex-direction: column;
padding-inline: 18px;
}
}

View File

@@ -1,16 +0,0 @@
import type { ComponentPropsWithoutRef } from 'react'
/**
* Параметры общего storefront layout.
*/
export type StoreShellLayoutParams = object
/**
* Атрибуты корневого контейнера.
*/
type RootAttrs = ComponentPropsWithoutRef<'div'>
/**
* Props общего storefront layout.
*/
export type StoreShellLayoutProps = RootAttrs & StoreShellLayoutParams

View File

@@ -1,374 +0,0 @@
'use client'
import type { ChangeEvent, FocusEvent } from 'react'
import { useEffect, useRef, useState } from 'react'
import cl from 'clsx'
import Image from 'next/image'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useAuth } from '@/domains/auth'
import { getCartProductQuantityLimit, useCart } from '@/domains/cart'
import { PRODUCT_IMAGE_PLACEHOLDER, useCatalogCommands } from '@/domains/catalog'
import { useOrderCommands, validateOrderDraft } from '@/domains/orders'
import type { OrderDraftLine } from '@/domains/orders'
import { formatMoney } from '@/shared/lib/format-money'
import { isEmptyArray, isNonEmptyArray } from '@/shared/lib/value-predicates'
import { Button } from '@/ui/button'
import { FeedbackPanel } from '@/ui/feedback-panel'
import type { CartScreenProps } from './types/cart-screen-props.type'
import styles from './styles/cart.module.css'
/**
* Multi-domain cart and checkout composition.
*
* Используется для:
* - координации cart, auth и orders без междоменного цикла
* - отображения stock и checkout errors
*/
export const CartScreen = (props: CartScreenProps) => {
const { className, ...rootAttrs } = props
const cart = useCart()
const auth = useAuth()
const catalogCommands = useCatalogCommands()
const orderCommands = useOrderCommands()
const router = useRouter()
const [isSubmitting, setIsSubmitting] = useState(false)
const [checkoutError, setCheckoutError] = useState<string | null>(null)
const [quantityDraftsById, setQuantityDraftsById] = useState<Record<string, string>>({})
const isMountedRef = useRef(false)
const checkoutAttemptRef = useRef(0)
const hasLines = isNonEmptyArray(cart.lines)
const orderDraft: OrderDraftLine[] = cart.lines.map((line) => ({
productId: line.product.id,
quantity: line.quantity,
availableStock: line.product.stock,
currency: line.product.currency,
expectedVersion: line.product.version,
expectedUnitPriceCents: line.product.priceCents
}))
const draftError = validateOrderDraft(orderDraft)
const canCheckout = draftError === null
const totalLabel = cart.currency === null
? 'Mixed currencies'
: formatMoney(cart.subtotalCents, cart.currency)
const checkoutLabel = auth.status === 'authenticated'
? 'Place order'
: auth.status === 'unavailable'
? 'Retry session first'
: 'Sign in to checkout'
useEffect(() => {
isMountedRef.current = true
return () => {
isMountedRef.current = false
checkoutAttemptRef.current += 1
}
}, [])
/**
* Сохраняет локальный quantity draft до commit на blur.
*/
const handleQuantityChange = (
event: ChangeEvent<HTMLInputElement>,
productId: string
): void => {
const value = event.currentTarget.value
setQuantityDraftsById((currentDrafts) => ({
...currentDrafts,
[productId]: value
}))
}
/**
* Применяет завершённое редактирование quantity без удаления на пустом draft.
*/
const handleQuantityCommit = (
event: FocusEvent<HTMLInputElement>,
productId: string
): void => {
const value = event.currentTarget.value.trim()
if (value === '') {
setQuantityDraftsById((currentDrafts) => {
const nextDrafts = { ...currentDrafts }
delete nextDrafts[productId]
return nextDrafts
})
return
}
const quantity = Number(value)
if (Number.isFinite(quantity)) {
void cart.setQuantity(productId, quantity)
}
setQuantityDraftsById((currentDrafts) => {
const nextDrafts = { ...currentDrafts }
delete nextDrafts[productId]
return nextDrafts
})
}
/**
* Проверяет lifecycle текущей checkout-попытки после async boundary.
*/
const isCheckoutAttemptActive = (attemptId: number): boolean => {
return isMountedRef.current && checkoutAttemptRef.current === attemptId
}
/**
* Создаёт order из cart snapshot или направляет гостя к auth route.
*/
const handleCheckout = async (): Promise<void> => {
if (auth.status === 'unavailable') {
const result = await auth.refreshCurrentUser()
if (!result.isSuccess) {
setCheckoutError(result.error.message)
}
return
}
if (auth.status !== 'authenticated') {
router.push('/sign-in')
return
}
const sessionKey = auth.sessionKey
if (sessionKey === null || isSubmitting) {
return
}
const cartRevision = cart.revision
const attemptId = checkoutAttemptRef.current + 1
checkoutAttemptRef.current = attemptId
setIsSubmitting(true)
setCheckoutError(null)
const productsResult = await catalogCommands.loadProducts(
orderDraft.map((line) => line.productId)
)
if (!isCheckoutAttemptActive(attemptId)) {
return
}
if (!productsResult.isSuccess) {
setIsSubmitting(false)
setCheckoutError(productsResult.error.message)
return
}
if (!auth.isCurrentSession(sessionKey)) {
setIsSubmitting(false)
setCheckoutError('The active session changed. Start checkout again.')
return
}
const reconciliationStatus = await cart.reconcileProducts(
productsResult.data,
cartRevision
)
if (!isCheckoutAttemptActive(attemptId)) {
return
}
if (reconciliationStatus === 'stale') {
setIsSubmitting(false)
setCheckoutError('The cart changed during checkout. Review it and try again.')
return
}
if (reconciliationStatus === 'unavailable') {
setIsSubmitting(false)
setCheckoutError('Cart storage is unavailable. Checkout was not started.')
return
}
if (reconciliationStatus === 'updated') {
setIsSubmitting(false)
setCheckoutError('Inventory changed. Review the refreshed totals before checkout.')
return
}
const cartRevisionStatus = await cart.isCurrentRevision(cartRevision)
if (!isCheckoutAttemptActive(attemptId)) {
return
}
if (cartRevisionStatus === 'unavailable') {
setIsSubmitting(false)
setCheckoutError('Cart storage is unavailable. Checkout was not started.')
return
}
if (cartRevisionStatus === 'stale' || !auth.isCurrentSession(sessionKey)) {
setIsSubmitting(false)
setCheckoutError('The checkout scope changed. Review the cart and try again.')
return
}
const result = await orderCommands.createOrder(orderDraft, sessionKey)
if (result.isSuccess) {
const clearStatus = await cart.clearCart(cartRevision)
if (!isCheckoutAttemptActive(attemptId)) {
return
}
setIsSubmitting(false)
if (clearStatus === 'unavailable') {
setCheckoutError('The order was created, but cart storage could not be updated.')
return
}
if (clearStatus === 'stale') {
setCheckoutError('The order was created, but the cart changed in another tab.')
return
}
if (auth.isCurrentSession(sessionKey)) {
router.push('/orders')
}
return
}
if (!isCheckoutAttemptActive(attemptId)) {
return
}
setIsSubmitting(false)
setCheckoutError(result.error.message)
}
return (
<main {...rootAttrs} className={cl(styles.root, className)}>
<header className={styles.header}>
<div>
<p className={styles.kicker}>Cart / composition-owned checkout</p>
<h1>Selected objects</h1>
</div>
<span className={styles.count}>{cart.itemCount} units</span>
</header>
{isEmptyArray(cart.lines) && cart.isHydrated && (
<FeedbackPanel
variant="empty"
title="Nothing crosses this boundary yet"
description="Add an object from the catalog. The cart snapshot stays owned by its domain and persists locally."
>
<Link className={styles.catalogLink} href="/">
Open catalog
</Link>
</FeedbackPanel>
)}
{hasLines && (
<div className={styles.layout}>
<section className={styles.lines} aria-label="Cart products">
{cart.lines.map((line) => (
<article key={line.product.id} className={styles.line}>
<Link
className={styles.imageWrap}
href={`/products/${line.product.id}`}
aria-label={`Open ${line.product.name}`}
>
<Image
className={styles.image}
src={line.product.imageUrl}
alt=""
fill
sizes="150px"
onError={(event) => {
event.currentTarget.srcset = ''
event.currentTarget.src = PRODUCT_IMAGE_PLACEHOLDER
}}
/>
</Link>
<div className={styles.lineInfo}>
<span>{line.product.categoryId.replace('category-', '')}</span>
<Link href={`/products/${line.product.id}`}>
<h2>{line.product.name}</h2>
</Link>
<strong>{formatMoney(line.product.priceCents, line.product.currency)}</strong>
</div>
<label className={styles.quantity}>
<span>Quantity</span>
<input
type="number"
min={0}
max={getCartProductQuantityLimit(line.product)}
step={1}
value={quantityDraftsById[line.product.id] ?? String(line.quantity)}
disabled={isSubmitting}
onChange={(event) => handleQuantityChange(event, line.product.id)}
onBlur={(event) => handleQuantityCommit(event, line.product.id)}
/>
</label>
<Button
variant="danger"
size="small"
disabled={isSubmitting}
onClick={() => void cart.removeProduct(line.product.id)}
>
Remove
</Button>
</article>
))}
</section>
<aside className={styles.summary}>
<p className={styles.sequence}>03 / CHECKOUT</p>
<h2>Order summary</h2>
<dl>
<div>
<dt>Units</dt>
<dd>{cart.itemCount}</dd>
</div>
<div>
<dt>Subtotal</dt>
<dd>{totalLabel}</dd>
</div>
<div>
<dt>Session</dt>
<dd>{auth.status}</dd>
</div>
</dl>
{draftError && (
<p className={styles.warning} role="status">
{draftError.message}
</p>
)}
{checkoutError && <p className={styles.error} role="alert">{checkoutError}</p>}
<Button
isLoading={isSubmitting}
disabled={!canCheckout || isSubmitting}
onClick={handleCheckout}
>
{checkoutLabel}
</Button>
</aside>
</div>
)}
</main>
)
}

View File

@@ -1,2 +0,0 @@
export { CartScreen } from './cart.screen'
export type { CartScreenProps } from './types/cart-screen-props.type'

View File

@@ -1,200 +0,0 @@
.root {
display: grid;
gap: 34px;
padding: clamp(42px, 7vw, 100px) clamp(18px, 6vw, 90px) 100px;
}
.header {
display: flex;
align-items: end;
justify-content: space-between;
gap: 20px;
padding-bottom: 28px;
border-bottom: 1px solid var(--color-line);
}
.kicker,
.sequence,
.lineInfo > span,
.quantity span {
color: var(--color-blue);
font-family: var(--font-mono);
font-size: 0.66rem;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.header h1 {
margin-top: 13px;
font-family: var(--font-display);
font-size: clamp(3rem, 8vw, 7.5rem);
letter-spacing: -0.055em;
line-height: 0.82;
}
.count {
padding: 10px 14px;
border: 1px solid var(--color-line-strong);
border-radius: 999px;
font-family: var(--font-mono);
font-size: 0.72rem;
text-transform: uppercase;
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(300px, 0.38fr);
gap: clamp(30px, 6vw, 90px);
align-items: start;
}
.lines {
display: grid;
}
.line {
display: grid;
grid-template-columns: 132px minmax(0, 1fr) 100px auto;
align-items: center;
gap: 22px;
padding: 18px 0;
border-bottom: 1px solid var(--color-line);
}
.imageWrap {
position: relative;
overflow: hidden;
aspect-ratio: 1;
border-radius: 15px;
background: var(--color-surface-strong);
}
.image {
object-fit: cover;
}
.lineInfo {
display: grid;
gap: 7px;
}
.lineInfo h2 {
font-family: var(--font-display);
font-size: clamp(1.4rem, 3vw, 2.25rem);
line-height: 0.95;
}
.lineInfo strong {
font-family: var(--font-mono);
font-size: 0.76rem;
}
.quantity {
display: grid;
gap: 7px;
}
.quantity input {
width: 80px;
min-height: 42px;
padding: 0 10px;
border: 1px solid var(--color-line-strong);
border-radius: 10px;
background: var(--color-surface);
font: inherit;
}
.summary {
position: sticky;
top: 110px;
display: grid;
gap: 24px;
padding: clamp(24px, 4vw, 42px);
border-radius: 24px;
background: var(--color-acid);
}
.sequence {
color: color-mix(in srgb, var(--color-ink) 58%, transparent);
}
.summary h2 {
font-family: var(--font-display);
font-size: clamp(2rem, 4vw, 3.6rem);
line-height: 0.9;
}
.summary dl {
display: grid;
}
.summary dl div {
display: flex;
justify-content: space-between;
gap: 20px;
padding: 13px 0;
border-bottom: 1px solid color-mix(in srgb, var(--color-ink) 18%, transparent);
}
.summary dt {
color: color-mix(in srgb, var(--color-ink) 60%, transparent);
font-size: 0.8rem;
}
.summary dd {
font-family: var(--font-mono);
font-size: 0.76rem;
}
.warning,
.error {
padding: 11px 13px;
border-radius: 10px;
font-size: 0.78rem;
line-height: 1.45;
}
.warning {
background: rgb(255 255 255 / 38%);
}
.error {
color: var(--color-error);
background: rgb(255 255 255 / 55%);
}
.catalogLink {
padding: 14px 20px;
color: var(--color-paper);
border-radius: 999px;
background: var(--color-ink);
font-size: 0.72rem;
font-weight: 760;
letter-spacing: 0.08em;
text-transform: uppercase;
}
@media (max-width: 980px) {
.layout {
grid-template-columns: 1fr;
}
.summary {
position: static;
}
}
@media (max-width: 700px) {
.line {
grid-template-columns: 92px 1fr;
}
.quantity,
.line > button {
grid-column: 2;
}
.quantity input {
width: 100%;
}
}

View File

@@ -1,16 +0,0 @@
import type { ComponentPropsWithoutRef } from 'react'
/**
* Параметры cart and checkout screen.
*/
export type CartScreenParams = object
/**
* Атрибуты корневого main без внешнего содержимого.
*/
type RootAttrs = Omit<ComponentPropsWithoutRef<'main'>, 'children'>
/**
* Props cart and checkout screen.
*/
export type CartScreenProps = RootAttrs & CartScreenParams

View File

@@ -1,217 +0,0 @@
'use client'
import type { ChangeEvent } from 'react'
import { useDeferredValue, useState } from 'react'
import cl from 'clsx'
import { useCategories, useProductCatalog } from '@/domains/catalog'
import type { CatalogFilters } from '@/domains/catalog'
import { useOnDemoFixtureChange } from '@/domains/demo-control'
import { isEmptyArray, isNonEmptyArray, isOneOf } from '@/shared/lib/value-predicates'
import { Button } from '@/ui/button'
import { FeedbackPanel } from '@/ui/feedback-panel'
import { ProductCard } from './ui/product-card'
import type { CatalogScreenProps } from './types/catalog-screen-props.type'
import styles from './styles/catalog.module.css'
const CATALOG_SORTS = ['newest', 'price-asc', 'price-desc', 'name'] as const
const SKELETON_IDS = ['one', 'two', 'three', 'four', 'five', 'six'] as const
/**
* Публичная витрина с фильтрами и offset pagination.
*
* Используется для:
* - поиска и сравнения fixture-продуктов
* - демонстрации loading, empty и transport error outcomes
*/
export const CatalogScreen = (props: CatalogScreenProps) => {
const { className, ...rootAttrs } = props
const [filters, setFilters] = useState<CatalogFilters>({
page: 1,
limit: 12,
search: '',
categoryId: '',
sort: 'newest'
})
const deferredSearch = useDeferredValue(filters.search)
const catalog = useProductCatalog({ ...filters, search: deferredSearch })
const categoriesState = useCategories()
const hasProducts = isNonEmptyArray(catalog.products)
const shouldShowEmpty =
!catalog.isLoading && catalog.error === null && isEmptyArray(catalog.products)
const resultSummary = catalog.pagination
? `${catalog.pagination.total} objects / page ${catalog.pagination.page}`
: 'Connecting to inventory'
const canGoBack = (catalog.pagination?.page ?? 1) > 1
const canGoForward =
catalog.pagination !== null && catalog.pagination.page < catalog.pagination.totalPages
useOnDemoFixtureChange((kind) => {
if (kind === 'data') {
setFilters((current) => ({ ...current, page: 1 }))
}
})
/**
* Обновляет search-фильтр, сохраняя ввод отзывчивым через deferred value.
*/
const handleSearchChange = (event: ChangeEvent<HTMLInputElement>): void => {
setFilters((current) => ({ ...current, search: event.target.value, page: 1 }))
}
/**
* Применяет category-фильтр и возвращает выдачу на первую страницу.
*/
const handleCategoryChange = (event: ChangeEvent<HTMLSelectElement>): void => {
setFilters((current) => ({ ...current, categoryId: event.target.value, page: 1 }))
}
/**
* Применяет только поддерживаемую доменом сортировку.
*/
const handleSortChange = (event: ChangeEvent<HTMLSelectElement>): void => {
const value = event.target.value
if (isOneOf(value, CATALOG_SORTS)) {
setFilters((current) => ({ ...current, sort: value, page: 1 }))
}
}
/**
* Загружает предыдущую страницу выдачи.
*/
const handlePreviousPage = (): void => {
setFilters((current) => ({ ...current, page: Math.max(1, current.page - 1) }))
}
/**
* Загружает следующую страницу выдачи.
*/
const handleNextPage = (): void => {
setFilters((current) => ({ ...current, page: current.page + 1 }))
}
/**
* Повторяет неуспешный catalog query.
*/
const handleReload = (): void => {
void catalog.reload()
}
return (
<main {...rootAttrs} className={cl(styles.root, className)}>
<section className={styles.hero} aria-labelledby="catalog-title">
<p className={styles.kicker}>Deterministic objects for frontend systems</p>
<h1 className={styles.title} id="catalog-title">
Useful things,
<br />
visible boundaries.
</h1>
<p className={styles.lede}>
A storefront where every loading state, cache edge, auth transition and conflict can be
reproduced on demand.
</p>
<span className={styles.sequence}>01 / CATALOG</span>
</section>
<section
className={styles.catalog}
aria-label="Product catalog"
aria-busy={catalog.isLoading || catalog.isRefreshing}
>
<div className={styles.filters}>
<label className={styles.searchField}>
<span>Search inventory</span>
<input
type="search"
value={filters.search}
placeholder="Keyboard, desk, systems..."
onChange={handleSearchChange}
/>
</label>
<label className={styles.selectField}>
<span>Category</span>
<select value={filters.categoryId} onChange={handleCategoryChange}>
<option value="">All categories</option>
{categoriesState.categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name} ({category.productCount})
</option>
))}
</select>
</label>
<label className={styles.selectField}>
<span>Order</span>
<select value={filters.sort} onChange={handleSortChange}>
<option value="newest">Newest first</option>
<option value="price-asc">Price, low to high</option>
<option value="price-desc">Price, high to low</option>
<option value="name">Name</option>
</select>
</label>
</div>
<div className={styles.resultBar}>
<span>{resultSummary}</span>
{catalog.isRefreshing && <span className={styles.refreshing}>Refreshing</span>}
</div>
{catalog.isLoading && (
<div
className={styles.productGrid}
role="status"
aria-live="polite"
aria-label="Loading products"
>
{SKELETON_IDS.map((id) => (
<div key={id} className={styles.skeleton} />
))}
</div>
)}
{catalog.error && (
<FeedbackPanel
variant="error"
title="The catalog boundary held"
description={catalog.error.message}
>
<Button onClick={handleReload}>Retry request</Button>
</FeedbackPanel>
)}
{shouldShowEmpty && (
<FeedbackPanel
variant="empty"
title="A valid empty state"
description="No objects match this request. Try another filter or select the normal demo scenario."
/>
)}
{hasProducts && (
<div className={styles.productGrid}>
{catalog.products.map((product, index) => (
<ProductCard key={product.id} product={product} index={index} />
))}
</div>
)}
{catalog.pagination && (catalog.pagination.totalPages > 1 || filters.page > 1) && (
<div className={styles.pagination}>
<Button variant="ghost" disabled={!canGoBack} onClick={handlePreviousPage}>
Previous
</Button>
<span>
{catalog.pagination.page} / {catalog.pagination.totalPages}
</span>
<Button variant="ghost" disabled={!canGoForward} onClick={handleNextPage}>
Next
</Button>
</div>
)}
</section>
</main>
)
}

View File

@@ -1,2 +0,0 @@
export { CatalogScreen } from './catalog.screen'
export type { CatalogScreenProps } from './types/catalog-screen-props.type'

View File

@@ -1,212 +0,0 @@
.root {
padding-bottom: 80px;
}
.hero {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(250px, 0.34fr);
gap: 20px 60px;
min-height: 520px;
padding: clamp(72px, 11vw, 150px) clamp(20px, 7vw, 110px) 80px;
overflow: hidden;
border-bottom: 1px solid var(--color-line);
}
.hero::before {
position: absolute;
top: 18%;
right: -8vw;
width: clamp(230px, 34vw, 520px);
aspect-ratio: 1;
border: 1px solid var(--color-line-strong);
border-radius: 50%;
box-shadow:
inset 0 0 0 38px var(--color-paper),
inset 0 0 0 39px var(--color-line);
content: '';
}
.hero::after {
position: absolute;
right: 15%;
bottom: -140px;
width: 240px;
height: 300px;
background: var(--color-acid);
transform: rotate(24deg);
content: '';
}
.kicker,
.sequence {
position: relative;
z-index: 1;
font-family: var(--font-mono);
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.kicker {
grid-column: 1 / -1;
color: var(--color-blue);
}
.title {
position: relative;
z-index: 1;
max-width: 10ch;
font-family: var(--font-display);
font-size: clamp(3.3rem, 9vw, 8.8rem);
letter-spacing: -0.065em;
line-height: 0.78;
}
.lede {
position: relative;
z-index: 1;
align-self: end;
max-width: 33ch;
color: var(--color-ink-muted);
font-size: clamp(1rem, 1.6vw, 1.3rem);
line-height: 1.58;
}
.sequence {
position: absolute;
bottom: 30px;
left: clamp(20px, 7vw, 110px);
color: var(--color-ink-faint);
}
.catalog {
display: grid;
gap: 26px;
padding: 54px clamp(18px, 5vw, 72px) 0;
}
.filters {
display: grid;
grid-template-columns: minmax(240px, 1fr) repeat(2, minmax(180px, 0.35fr));
gap: 14px;
}
.searchField,
.selectField {
display: grid;
gap: 8px;
}
.filters label > span {
color: var(--color-ink-faint);
font-size: 0.66rem;
font-weight: 760;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.filters :is(input, select) {
width: 100%;
min-height: 50px;
padding: 0 15px;
color: var(--color-ink);
border: 1px solid var(--color-line-strong);
border-radius: 13px;
outline: none;
background: var(--color-surface);
font: inherit;
}
.filters :is(input, select):focus {
border-color: var(--color-blue);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-blue) 14%, transparent);
}
.resultBar {
display: flex;
justify-content: space-between;
min-height: 24px;
color: var(--color-ink-faint);
font-family: var(--font-mono);
font-size: 0.68rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.refreshing {
color: var(--color-blue);
}
.productGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: clamp(14px, 2vw, 26px);
}
.skeleton {
min-height: 410px;
border: 1px solid var(--color-line);
border-radius: 22px;
background:
linear-gradient(105deg, transparent 30%, rgb(255 255 255 / 48%) 45%, transparent 60%)
0 0 / 220% 100%,
var(--color-surface-strong);
animation: sweep 1.4s linear infinite;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
padding-top: 18px;
font-family: var(--font-mono);
font-size: 0.72rem;
}
@keyframes sweep {
to {
background-position: -220% 0;
}
}
@media (max-width: 980px) {
.hero {
grid-template-columns: 1fr;
}
.lede {
max-width: 50ch;
}
.filters,
.productGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.searchField {
grid-column: 1 / -1;
}
}
@media (max-width: 640px) {
.hero {
min-height: 440px;
padding-top: 70px;
}
.hero::after {
right: -40px;
width: 130px;
}
.filters,
.productGrid {
grid-template-columns: 1fr;
}
.searchField {
grid-column: auto;
}
}

View File

@@ -1,16 +0,0 @@
import type { ComponentPropsWithoutRef } from 'react'
/**
* Параметры storefront catalog screen.
*/
export type CatalogScreenParams = object
/**
* Атрибуты корневого main без внешнего содержимого.
*/
type RootAttrs = Omit<ComponentPropsWithoutRef<'main'>, 'children'>
/**
* Props storefront catalog screen.
*/
export type CatalogScreenProps = RootAttrs & CatalogScreenParams

View File

@@ -1,2 +0,0 @@
export { ProductCard } from './product-card'
export type { ProductCardProps } from './types/product-card-props.type'

View File

@@ -1,74 +0,0 @@
'use client'
import cl from 'clsx'
import Image from 'next/image'
import Link from 'next/link'
import { useCart } from '@/domains/cart'
import { PRODUCT_IMAGE_PLACEHOLDER } from '@/domains/catalog'
import { formatMoney } from '@/shared/lib/format-money'
import { Button } from '@/ui/button'
import type { ProductCardProps } from './types/product-card-props.type'
import styles from './styles/product-card.module.css'
/**
* Карточка продукта для grid-выдачи catalog screen.
*
* Используется для:
* - перехода к detail route
* - добавления доступного продукта в application cart
*/
export const ProductCard = (props: ProductCardProps) => {
const { product, index, className, ...rootAttrs } = props
const cart = useCart()
const isSoldOut = product.stock === 0
const stockLabel = isSoldOut ? 'Out of stock' : `${product.stock} ready`
/**
* Добавляет продукт в cart domain без transport-зависимости компонента.
*/
const handleAdd = (): void => {
void cart.addProduct(product)
}
return (
<article {...rootAttrs} className={cl(styles.root, className)}>
<Link
className={styles.imageLink}
href={`/products/${product.id}`}
aria-label={`Open ${product.name}`}
>
<Image
className={styles.image}
src={product.imageUrl}
alt=""
fill
sizes="(max-width: 720px) 100vw, (max-width: 1100px) 50vw, 33vw"
onError={(event) => {
event.currentTarget.srcset = ''
event.currentTarget.src = PRODUCT_IMAGE_PLACEHOLDER
}}
/>
<span className={styles.index}>{String(index + 1).padStart(2, '0')}</span>
</Link>
<div className={styles.body}>
<div className={styles.meta}>
<span>{stockLabel}</span>
<span>{product.rating.toFixed(1)} / 5</span>
</div>
<Link href={`/products/${product.id}`}>
<h2 className={styles.name}>{product.name}</h2>
</Link>
<p className={styles.description}>{product.description}</p>
<div className={styles.footer}>
<strong className={styles.price}>{formatMoney(product.priceCents, product.currency)}</strong>
<Button size="small" disabled={isSoldOut} onClick={handleAdd}>
Add
</Button>
</div>
</div>
</article>
)
}

View File

@@ -1,98 +0,0 @@
.root {
display: grid;
overflow: hidden;
min-width: 0;
border: 1px solid var(--color-line);
border-radius: 22px;
background: var(--color-surface);
box-shadow: var(--shadow-card);
transition:
transform 200ms ease,
box-shadow 200ms ease;
}
.root:hover {
box-shadow: var(--shadow-card-hover);
transform: translateY(-5px);
}
.imageLink {
position: relative;
display: block;
overflow: hidden;
aspect-ratio: 4 / 3;
background: var(--color-surface-strong);
}
.image {
object-fit: cover;
filter: saturate(0.78) contrast(1.04);
transition: transform 420ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.root:hover .image {
transform: scale(1.035);
}
.index {
position: absolute;
top: 12px;
left: 12px;
display: grid;
place-items: center;
width: 38px;
height: 38px;
color: var(--color-paper);
border-radius: 50%;
background: var(--color-blue);
font-family: var(--font-mono);
font-size: 0.7rem;
}
.body {
display: grid;
grid-template-rows: auto auto 1fr auto;
gap: 13px;
padding: 20px;
}
.meta,
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.meta {
color: var(--color-ink-faint);
font-family: var(--font-mono);
font-size: 0.64rem;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.name {
font-family: var(--font-display);
font-size: clamp(1.35rem, 2.5vw, 2rem);
line-height: 0.98;
}
.description {
display: -webkit-box;
overflow: hidden;
color: var(--color-ink-muted);
font-size: 0.9rem;
line-height: 1.55;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.footer {
padding-top: 4px;
}
.price {
font-family: var(--font-mono);
font-size: 0.94rem;
}

View File

@@ -1,23 +0,0 @@
import type { ComponentPropsWithoutRef } from 'react'
import type { Product } from '@/domains/catalog'
/**
* Собственные параметры product card внутри catalog screen.
*/
export type ProductCardParams = {
/** Catalog-модель продукта. */
product: Product
/** Позиция карточки для editorial numbering. */
index: number
}
/**
* Атрибуты корневой article-карточки без внешнего содержимого.
*/
type RootAttrs = Omit<ComponentPropsWithoutRef<'article'>, 'children'>
/**
* Props product card внутри catalog screen.
*/
export type ProductCardProps = RootAttrs & ProductCardParams

View File

@@ -1,2 +0,0 @@
export { OrdersScreen } from './orders.screen'
export type { OrdersScreenProps } from './types/orders-screen-props.type'

View File

@@ -1,212 +0,0 @@
'use client'
import { useState } from 'react'
import cl from 'clsx'
import Link from 'next/link'
import { useAuth } from '@/domains/auth'
import { canCancelOrder, useOrderCommands, useOrders } from '@/domains/orders'
import type { OrderError } from '@/domains/orders'
import { formatDate } from '@/shared/lib/format-date'
import { formatMoney } from '@/shared/lib/format-money'
import { isEmptyArray, isNonEmptyArray } from '@/shared/lib/value-predicates'
import { Button } from '@/ui/button'
import { FeedbackPanel } from '@/ui/feedback-panel'
import type { OrdersScreenProps } from './types/orders-screen-props.type'
import styles from './styles/orders.module.css'
/**
* Protected история заказов с допустимыми state transitions.
*
* Используется для:
* - проверки auth-aware deferred query
* - демонстрации успешной и конфликтной отмены заказа
*/
export const OrdersScreen = (props: OrdersScreenProps) => {
const { className, ...rootAttrs } = props
const auth = useAuth()
const isAuthenticated = auth.status === 'authenticated'
const ordersState = useOrders(isAuthenticated)
const orderCommands = useOrderCommands()
const [cancellingOrderId, setCancellingOrderId] = useState<string | null>(null)
const [actionError, setActionError] = useState<OrderError | null>(null)
const hasOrders = isNonEmptyArray(ordersState.orders)
/**
* Повторяет неуспешный protected orders query.
*/
const handleReload = (): void => {
void ordersState.reload()
}
/**
* Повторяет recoverable auth check перед protected query.
*/
const handleSessionRetry = (): void => {
void auth.refreshCurrentUser()
}
/**
* Запрашивает допустимый переход заказа в cancelled.
*/
const handleCancel = async (orderId: string): Promise<void> => {
const sessionKey = auth.sessionKey
if (sessionKey === null) {
return
}
setCancellingOrderId(orderId)
setActionError(null)
const result = await orderCommands.cancelOrder(orderId, sessionKey)
setCancellingOrderId(null)
if (!result.isSuccess) {
setActionError(result.error)
}
}
if (auth.status === 'checking') {
return (
<main {...rootAttrs} className={cl(styles.root, styles.outcome, className)}>
<FeedbackPanel
title="Restoring protected scope"
description="The orders query stays disabled until auth resolves its application-scoped session."
/>
</main>
)
}
if (!isAuthenticated) {
if (auth.status === 'unavailable') {
return (
<main {...rootAttrs} className={cl(styles.root, styles.outcome, className)}>
<FeedbackPanel
variant="error"
title="Session authority is unavailable"
description={auth.sessionError?.message ?? 'The session was preserved for retry.'}
>
<Button onClick={handleSessionRetry}>Retry session</Button>
</FeedbackPanel>
</main>
)
}
return (
<main {...rootAttrs} className={cl(styles.root, styles.outcome, className)}>
<FeedbackPanel
variant="empty"
title="This route needs a session"
description="Sign in as customer to see own orders, or as admin to see every fixture order."
>
<Link className={styles.primaryLink} href="/sign-in">
Choose account
</Link>
</FeedbackPanel>
</main>
)
}
return (
<main
{...rootAttrs}
className={cl(styles.root, className)}
aria-busy={ordersState.isLoading}
>
<header className={styles.header}>
<div>
<p className={styles.kicker}>Orders / {auth.user?.role}</p>
<h1>State in motion</h1>
</div>
<p className={styles.lede}>
Backend transitions remain authoritative. The UI only offers cancellation where the
current domain model allows it.
</p>
</header>
{ordersState.isLoading && (
<div
className={styles.loading}
role="status"
aria-live="polite"
aria-label="Loading orders"
/>
)}
{ordersState.error && (
<FeedbackPanel
variant="error"
title="Orders stayed inside their boundary"
description={ordersState.error.message}
>
<Button onClick={handleReload}>Retry orders</Button>
</FeedbackPanel>
)}
{actionError && <p className={styles.actionError} role="alert">{actionError.message}</p>}
{isEmptyArray(ordersState.orders) && !ordersState.isLoading && ordersState.error === null && (
<FeedbackPanel
variant="empty"
title="No visible orders"
description="Create one from the cart or switch the demo scenario back to normal."
>
<Link className={styles.primaryLink} href="/">
Browse catalog
</Link>
</FeedbackPanel>
)}
{hasOrders && (
<section className={styles.orders} aria-label="Order history">
{ordersState.orders.map((order, index) => {
const canCancel = canCancelOrder(order)
const isCancelling = cancellingOrderId === order.id
return (
<article key={order.id} className={styles.order}>
<div className={styles.orderIndex}>{String(index + 1).padStart(2, '0')}</div>
<div className={styles.orderHeader}>
<div>
<span>{formatDate(order.createdAt)}</span>
<h2>{order.id}</h2>
</div>
<span className={styles.status} data-status={order.status}>
{order.status}
</span>
</div>
<div className={styles.lines}>
{order.lines.map((line) => (
<div key={line.productId} className={styles.line}>
<span>{line.quantity} x</span>
<strong>{line.productName}</strong>
<span>{formatMoney(line.unitPriceCents, order.currency)}</span>
</div>
))}
</div>
<div className={styles.orderFooter}>
<strong>{formatMoney(order.totalCents, order.currency)}</strong>
{canCancel && (
<Button
variant="danger"
size="small"
isLoading={isCancelling}
onClick={() => handleCancel(order.id)}
>
Cancel order
</Button>
)}
</div>
</article>
)
})}
</section>
)}
</main>
)
}

View File

@@ -1,187 +0,0 @@
.root {
display: grid;
gap: 38px;
padding: clamp(48px, 8vw, 112px) clamp(18px, 7vw, 110px) 110px;
}
.header {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(260px, 0.42fr);
gap: 40px;
align-items: end;
padding-bottom: 34px;
border-bottom: 1px solid var(--color-line);
}
.kicker {
color: var(--color-blue);
font-family: var(--font-mono);
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.header h1 {
max-width: 9ch;
margin-top: 14px;
font-family: var(--font-display);
font-size: clamp(3rem, 8vw, 7.4rem);
letter-spacing: -0.055em;
line-height: 0.8;
}
.lede {
color: var(--color-ink-muted);
font-size: 1rem;
line-height: 1.7;
}
.orders {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.order {
position: relative;
display: grid;
gap: 26px;
overflow: hidden;
padding: clamp(24px, 4vw, 38px);
border: 1px solid var(--color-line);
border-radius: 24px;
background: var(--color-surface);
box-shadow: var(--shadow-card);
}
.orderIndex {
position: absolute;
right: -8px;
bottom: -22px;
color: color-mix(in srgb, var(--color-ink) 5%, transparent);
font-family: var(--font-display);
font-size: 9rem;
line-height: 1;
pointer-events: none;
}
.orderHeader,
.orderFooter,
.line {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
.orderHeader span,
.line span {
color: var(--color-ink-faint);
font-family: var(--font-mono);
font-size: 0.66rem;
}
.orderHeader h2 {
margin-top: 6px;
font-family: var(--font-display);
font-size: 1.7rem;
}
.status {
padding: 8px 11px;
border-radius: 999px;
background: var(--color-surface-strong);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.status[data-status='pending'] {
color: #845300;
background: #ffe5ad;
}
.status[data-status='paid'] {
color: #215a00;
background: #dfffa0;
}
.status[data-status='shipped'] {
color: #123ec0;
background: #dae4ff;
}
.status[data-status='cancelled'] {
color: #8b2920;
background: #ffd9d5;
}
.lines {
position: relative;
z-index: 1;
display: grid;
}
.line {
display: grid;
grid-template-columns: auto 1fr auto;
padding: 11px 0;
border-bottom: 1px solid var(--color-line);
}
.line strong {
font-size: 0.84rem;
}
.orderFooter {
padding-top: 4px;
}
.orderFooter > strong {
font-family: var(--font-mono);
font-size: 1rem;
}
.actionError {
padding: 14px 18px;
color: var(--color-error);
border-left: 5px solid var(--color-error);
background: color-mix(in srgb, var(--color-error) 8%, transparent);
}
.loading {
min-height: 380px;
border-radius: 24px;
background: var(--color-surface-strong);
animation: pulse 1s ease-in-out infinite alternate;
}
.outcome {
min-height: calc(100vh - 82px);
place-items: center;
}
.primaryLink {
padding: 14px 20px;
color: var(--color-paper);
border-radius: 999px;
background: var(--color-ink);
font-size: 0.72rem;
font-weight: 760;
letter-spacing: 0.08em;
text-transform: uppercase;
}
@keyframes pulse {
to {
opacity: 0.45;
}
}
@media (max-width: 850px) {
.header,
.orders {
grid-template-columns: 1fr;
}
}

View File

@@ -1,16 +0,0 @@
import type { ComponentPropsWithoutRef } from 'react'
/**
* Параметры protected orders screen.
*/
export type OrdersScreenParams = object
/**
* Атрибуты корневого main без внешнего содержимого.
*/
type RootAttrs = Omit<ComponentPropsWithoutRef<'main'>, 'children'>
/**
* Props protected orders screen.
*/
export type OrdersScreenProps = RootAttrs & OrdersScreenParams

View File

@@ -1,2 +0,0 @@
export { ProductAdminScreen } from './product-admin.screen'
export type { ProductAdminScreenProps } from './types/product-admin-screen-props.type'

View File

@@ -1,351 +0,0 @@
'use client'
import { useState } from 'react'
import cl from 'clsx'
import Link from 'next/link'
import { useAuth } from '@/domains/auth'
import {
useCategories,
useCatalogCommands,
useProductCatalog
} from '@/domains/catalog'
import type {
CatalogError,
CreateProductInput,
Product,
UpdateProductInput
} from '@/domains/catalog'
import { useOnDemoFixtureChange } from '@/domains/demo-control'
import { formatMoney } from '@/shared/lib/format-money'
import { isNonEmptyArray } from '@/shared/lib/value-predicates'
import { Button } from '@/ui/button'
import { FeedbackPanel } from '@/ui/feedback-panel'
import { ProductForm } from './ui/product-form'
import type { ProductAdminScreenProps } from './types/product-admin-screen-props.type'
import styles from './styles/product-admin.module.css'
/**
* Admin-only catalog mutation workspace.
*
* Используется для:
* - create/update/delete RBAC-сценариев
* - проверки optimistic-lock conflict outcome
*/
export const ProductAdminScreen = (props: ProductAdminScreenProps) => {
const { className, ...rootAttrs } = props
const auth = useAuth()
const catalogCommands = useCatalogCommands()
const [adminPage, setAdminPage] = useState(1)
const catalog = useProductCatalog({
page: adminPage,
limit: 100,
search: '',
categoryId: '',
sort: 'newest'
})
const categoriesState = useCategories()
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null)
const [formGeneration, setFormGeneration] = useState(0)
const [isSubmitting, setIsSubmitting] = useState(false)
const [deletingProductId, setDeletingProductId] = useState<string | null>(null)
const [mutationError, setMutationError] = useState<CatalogError | null>(null)
const [successMessage, setSuccessMessage] = useState<string | null>(null)
const isAdmin = auth.status === 'authenticated' && auth.user?.role === 'admin'
const hasProducts = isNonEmptyArray(catalog.products)
const canGoBack = (catalog.pagination?.page ?? 1) > 1
const canGoForward =
catalog.pagination !== null && catalog.pagination.page < catalog.pagination.totalPages
const isSelectedProductAvailable =
selectedProduct !== null &&
(
catalog.isLoading ||
catalog.error !== null ||
catalog.products.some((product) => product.id === selectedProduct.id)
)
const formProduct = isSelectedProductAvailable ? selectedProduct : null
const formKey = `${formProduct?.id ?? 'new'}-${formProduct?.version ?? 0}-${categoriesState.categories.length}-${formGeneration}`
useOnDemoFixtureChange((kind) => {
if (kind === 'data') {
setAdminPage(1)
setSelectedProduct(null)
setMutationError(null)
setFormGeneration((value) => value + 1)
}
})
/**
* Переключает product form в edit mode с последним catalog snapshot.
*/
const handleEdit = (product: Product): void => {
setSelectedProduct(product)
setMutationError(null)
setSuccessMessage(null)
}
/**
* Возвращает product form в create mode.
*/
const handleCancelEdit = (): void => {
setSelectedProduct(null)
setMutationError(null)
setFormGeneration((value) => value + 1)
}
/**
* Выбирает create или update domain scenario по форме input.
*/
const handleSubmit = async (
input: CreateProductInput | UpdateProductInput
): Promise<void> => {
const sessionKey = auth.sessionKey
if (sessionKey === null) {
return
}
setIsSubmitting(true)
setMutationError(null)
setSuccessMessage(null)
const result = 'id' in input
? await catalogCommands.updateProduct(input, sessionKey)
: await catalogCommands.createProduct(input, sessionKey)
setIsSubmitting(false)
if (!result.isSuccess) {
setMutationError(result.error)
return
}
setSuccessMessage(`${result.data.name} saved at version ${result.data.version}.`)
setSelectedProduct(null)
setFormGeneration((value) => value + 1)
}
/**
* Удаляет продукт после явного browser confirmation.
*/
const handleRemove = async (product: Product): Promise<void> => {
const sessionKey = auth.sessionKey
if (sessionKey === null) {
return
}
const shouldRemove = window.confirm(`Remove ${product.name}?`)
if (!shouldRemove) {
return
}
setDeletingProductId(product.id)
setMutationError(null)
setSuccessMessage(null)
const result = await catalogCommands.removeProduct(product.id, sessionKey)
setDeletingProductId(null)
if (!result.isSuccess) {
setMutationError(result.error)
return
}
setSuccessMessage(`${product.name} removed.`)
if (selectedProduct?.id === product.id) {
setSelectedProduct(null)
setFormGeneration((value) => value + 1)
}
}
/**
* Повторяет catalog query после transport failure.
*/
const handleReload = (): void => {
void catalog.reload()
}
/**
* Открывает предыдущую admin inventory page.
*/
const handlePreviousPage = (): void => {
setAdminPage((page) => Math.max(1, page - 1))
}
/**
* Открывает следующую admin inventory page.
*/
const handleNextPage = (): void => {
setAdminPage((page) => page + 1)
}
/**
* Повторяет recoverable auth check перед admin workspace.
*/
const handleSessionRetry = (): void => {
void auth.refreshCurrentUser()
}
/**
* Загружает authority snapshot после optimistic-lock conflict.
*/
const handleLoadLatest = async (): Promise<void> => {
if (formProduct === null) {
return
}
setIsSubmitting(true)
const result = await catalogCommands.loadProducts([formProduct.id])
setIsSubmitting(false)
if (!result.isSuccess) {
setMutationError(result.error)
return
}
const latestProduct = result.data[0]
if (latestProduct !== undefined) {
setSelectedProduct(latestProduct)
setMutationError(null)
setSuccessMessage('Loaded the latest product version. Review changes before saving.')
}
}
if (auth.status === 'checking') {
return (
<main {...rootAttrs} className={cl(styles.root, styles.outcome, className)}>
<FeedbackPanel
title="Resolving admin scope"
description="Catalog mutation UI waits for the auth owner before rendering protected controls."
/>
</main>
)
}
if (!isAdmin) {
if (auth.status === 'unavailable') {
return (
<main {...rootAttrs} className={cl(styles.root, styles.outcome, className)}>
<FeedbackPanel
variant="error"
title="Admin session is temporarily unavailable"
description={auth.sessionError?.message ?? 'The session was preserved for retry.'}
>
<Button onClick={handleSessionRetry}>Retry session</Button>
</FeedbackPanel>
</main>
)
}
return (
<main {...rootAttrs} className={cl(styles.root, styles.outcome, className)}>
<FeedbackPanel
variant="error"
title="Admin boundary"
description="This composition does not infer permissions from the route. It reads the role owned by auth."
>
<Link className={styles.primaryLink} href="/sign-in">
Sign in as admin
</Link>
</FeedbackPanel>
</main>
)
}
return (
<main {...rootAttrs} className={cl(styles.root, className)}>
<header className={styles.header}>
<div>
<p className={styles.kicker}>Catalog / protected mutations</p>
<h1>Versioned inventory</h1>
</div>
<p>
Every update carries the last-read version. Use the conflict demo scenario to inspect
the domain error without exposing transport payloads.
</p>
</header>
{catalog.error && (
<FeedbackPanel variant="error" title="Catalog query failed" description={catalog.error.message}>
<Button onClick={handleReload}>Retry inventory</Button>
</FeedbackPanel>
)}
<div className={styles.workspace}>
<div className={styles.formColumn}>
<ProductForm
key={formKey}
categories={categoriesState.categories}
product={formProduct}
isSubmitting={isSubmitting}
error={mutationError?.message ?? null}
onSubmit={handleSubmit}
onCancel={handleCancelEdit}
/>
{mutationError?.code === 'conflict' && formProduct !== null && (
<Button variant="secondary" onClick={handleLoadLatest}>
Load latest version
</Button>
)}
</div>
<section className={styles.inventory} aria-labelledby="inventory-title">
<div className={styles.inventoryHeader}>
<div>
<span>Live snapshot</span>
<h2 id="inventory-title">Inventory</h2>
</div>
<strong>{catalog.pagination?.total ?? 0} products</strong>
</div>
{successMessage && <p className={styles.success} role="status">{successMessage}</p>}
{hasProducts && (
<div className={styles.productList}>
{catalog.products.map((product) => (
<article key={product.id} className={styles.productRow}>
<div>
<span>v{product.version} / {product.stock} stock</span>
<h3>{product.name}</h3>
<small>{formatMoney(product.priceCents, product.currency)}</small>
</div>
<div className={styles.rowActions}>
<Button variant="ghost" size="small" onClick={() => handleEdit(product)}>
Edit
</Button>
<Button
variant="danger"
size="small"
isLoading={deletingProductId === product.id}
onClick={() => handleRemove(product)}
>
Delete
</Button>
</div>
</article>
))}
</div>
)}
{catalog.pagination && (catalog.pagination.totalPages > 1 || adminPage > 1) && (
<div className={styles.pagination}>
<Button variant="ghost" size="small" disabled={!canGoBack} onClick={handlePreviousPage}>
Previous 100
</Button>
<span>{catalog.pagination.page} / {catalog.pagination.totalPages}</span>
<Button variant="ghost" size="small" disabled={!canGoForward} onClick={handleNextPage}>
Next 100
</Button>
</div>
)}
</section>
</div>
</main>
)
}

View File

@@ -1,162 +0,0 @@
.root {
display: grid;
gap: 42px;
padding: clamp(46px, 7vw, 100px) clamp(18px, 6vw, 90px) 110px;
}
.header {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 0.42fr);
gap: 42px;
align-items: end;
padding-bottom: 34px;
border-bottom: 1px solid var(--color-line);
}
.kicker,
.inventoryHeader span,
.productRow span {
color: var(--color-blue);
font-family: var(--font-mono);
font-size: 0.65rem;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.header h1 {
max-width: 10ch;
margin-top: 14px;
font-family: var(--font-display);
font-size: clamp(3rem, 7.5vw, 7.2rem);
letter-spacing: -0.055em;
line-height: 0.8;
}
.header > p {
color: var(--color-ink-muted);
line-height: 1.7;
}
.workspace {
display: grid;
grid-template-columns: minmax(360px, 0.78fr) minmax(0, 1.22fr);
gap: clamp(24px, 5vw, 70px);
align-items: start;
}
.formColumn {
display: grid;
gap: 12px;
}
.inventory {
display: grid;
gap: 18px;
}
.inventoryHeader {
display: flex;
align-items: end;
justify-content: space-between;
gap: 20px;
padding-bottom: 18px;
border-bottom: 1px solid var(--color-line);
}
.inventoryHeader h2 {
margin-top: 7px;
font-family: var(--font-display);
font-size: clamp(2rem, 4vw, 3.6rem);
line-height: 0.9;
}
.inventoryHeader strong {
color: var(--color-ink-faint);
font-family: var(--font-mono);
font-size: 0.68rem;
text-transform: uppercase;
}
.productList {
display: grid;
}
.productRow {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 18px;
padding: 17px 0;
border-bottom: 1px solid var(--color-line);
}
.productRow h3 {
margin: 6px 0;
font-family: var(--font-display);
font-size: 1.45rem;
line-height: 1;
}
.productRow small {
color: var(--color-ink-muted);
font-family: var(--font-mono);
font-size: 0.72rem;
}
.rowActions {
display: flex;
flex-wrap: wrap;
justify-content: end;
gap: 8px;
}
.success {
padding: 12px 14px;
color: #315c00;
border-left: 4px solid var(--color-acid-dark);
background: color-mix(in srgb, var(--color-acid) 28%, transparent);
font-size: 0.82rem;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
padding-top: 12px;
font-family: var(--font-mono);
font-size: 0.68rem;
}
.outcome {
min-height: calc(100vh - 82px);
place-items: center;
}
.primaryLink {
padding: 14px 20px;
color: var(--color-paper);
border-radius: 999px;
background: var(--color-ink);
font-size: 0.72rem;
font-weight: 760;
letter-spacing: 0.08em;
text-transform: uppercase;
}
@media (max-width: 980px) {
.header,
.workspace {
grid-template-columns: 1fr;
}
}
@media (max-width: 560px) {
.productRow {
grid-template-columns: 1fr;
}
.rowActions {
justify-content: start;
}
}

View File

@@ -1,16 +0,0 @@
import type { ComponentPropsWithoutRef } from 'react'
/**
* Параметры product administration screen.
*/
export type ProductAdminScreenParams = object
/**
* Атрибуты корневого main без внешнего содержимого.
*/
type RootAttrs = Omit<ComponentPropsWithoutRef<'main'>, 'children'>
/**
* Props product administration screen.
*/
export type ProductAdminScreenProps = RootAttrs & ProductAdminScreenParams

View File

@@ -1,2 +0,0 @@
export { ProductForm } from './product-form'
export type { ProductFormProps } from './types/product-form-props.type'

View File

@@ -1,272 +0,0 @@
'use client'
import type { ChangeEvent, FormEvent } from 'react'
import { useState } from 'react'
import cl from 'clsx'
import type { CatalogCurrency, CreateProductInput, UpdateProductInput } from '@/domains/catalog'
import { isOneOf } from '@/shared/lib/value-predicates'
import { Button } from '@/ui/button'
import { FormField } from '@/ui/form-field'
import type { ProductFormProps } from './types/product-form-props.type'
import styles from './styles/product-form.module.css'
const CURRENCIES = ['USD', 'EUR'] as const
/**
* Локальное редактируемое состояние admin-формы.
*/
type ProductFormValues = {
/** Название продукта. */
name: string
/** Полное описание. */
description: string
/** Цена в cents как form string. */
priceCents: string
/** Валюта. */
currency: CatalogCurrency
/** Выбранная категория. */
categoryId: string
/** Stock как form string. */
stock: string
/** URL удалённого изображения. */
imageUrl: string
}
/**
* Создаёт initial form state из create mode или catalog snapshot.
*/
const createInitialValues = (props: ProductFormProps): ProductFormValues => {
const { product, categories } = props
if (product !== null) {
return {
name: product.name,
description: product.description,
priceCents: String(product.priceCents),
currency: product.currency,
categoryId: product.categoryId,
stock: String(product.stock),
imageUrl: product.imageUrl
}
}
return {
name: '',
description: '',
priceCents: '9900',
currency: 'USD',
categoryId: categories[0]?.id ?? '',
stock: '10',
imageUrl: 'https://picsum.photos/seed/new-object/640/480'
}
}
/**
* Форма create/update с сохранением optimistic-lock версии продукта.
*
* Используется для:
* - создания fixture-продукта
* - редактирования последнего catalog snapshot
*/
export const ProductForm = (props: ProductFormProps) => {
const {
categories,
product,
isSubmitting,
error,
onSubmit,
onCancel,
className,
...rootAttrs
} = props
const [values, setValues] = useState<ProductFormValues>(() => createInitialValues(props))
const isEditMode = product !== null
const title = isEditMode ? `Edit ${product.name}` : 'Create object'
const submitLabel = isEditMode ? 'Save version' : 'Create product'
/**
* Обновляет текстовое поле формы по имени control.
*/
const handleTextChange = (
event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
): void => {
const field = event.target.name
const value = event.target.value
if (field === 'name' || field === 'description' || field === 'imageUrl') {
setValues((current) => ({ ...current, [field]: value }))
}
}
/**
* Обновляет числовое поле, сохраняя browser form representation.
*/
const handleNumberChange = (event: ChangeEvent<HTMLInputElement>): void => {
const field = event.target.name
const value = event.target.value
if (field === 'priceCents' || field === 'stock') {
setValues((current) => ({ ...current, [field]: value }))
}
}
/**
* Обновляет выбранную category.
*/
const handleCategoryChange = (event: ChangeEvent<HTMLSelectElement>): void => {
setValues((current) => ({ ...current, categoryId: event.target.value }))
}
/**
* Обновляет только поддерживаемую backend currency.
*/
const handleCurrencyChange = (event: ChangeEvent<HTMLSelectElement>): void => {
const value = event.target.value
if (isOneOf(value, CURRENCIES)) {
setValues((current) => ({ ...current, currency: value }))
}
}
/**
* Передаёт нормализованный domain input screen-владельцу.
*/
const handleSubmit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault()
const baseInput: CreateProductInput = {
name: values.name.trim(),
description: values.description.trim(),
priceCents: Number(values.priceCents),
currency: values.currency,
categoryId: values.categoryId,
stock: Number(values.stock),
imageUrl: values.imageUrl.trim()
}
if (product !== null) {
const updateInput: UpdateProductInput = {
...baseInput,
id: product.id,
version: product.version
}
await onSubmit(updateInput)
return
}
await onSubmit(baseInput)
}
return (
<form {...rootAttrs} className={cl(styles.root, className)} onSubmit={handleSubmit}>
<div className={styles.heading}>
<div>
<span>{isEditMode ? `Version ${product.version}` : 'New catalog entry'}</span>
<h2>{title}</h2>
</div>
{isEditMode && (
<Button variant="ghost" size="small" onClick={onCancel}>
Cancel edit
</Button>
)}
</div>
<FormField label="Name" htmlFor="product-name">
<input
id="product-name"
name="name"
value={values.name}
minLength={2}
maxLength={120}
required
onChange={handleTextChange}
/>
</FormField>
<FormField label="Description" htmlFor="product-description">
<textarea
id="product-description"
name="description"
value={values.description}
minLength={10}
maxLength={1000}
required
onChange={handleTextChange}
/>
</FormField>
<div className={styles.columns}>
<FormField label="Price, cents" htmlFor="product-price">
<input
id="product-price"
name="priceCents"
type="number"
min={0}
value={values.priceCents}
required
onChange={handleNumberChange}
/>
</FormField>
<FormField label="Currency" htmlFor="product-currency">
<select id="product-currency" value={values.currency} onChange={handleCurrencyChange}>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
</select>
</FormField>
</div>
<div className={styles.columns}>
<FormField label="Category" htmlFor="product-category">
<select
id="product-category"
value={values.categoryId}
required
onChange={handleCategoryChange}
>
<option value="" disabled>
Select category
</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
</FormField>
<FormField label="Stock" htmlFor="product-stock">
<input
id="product-stock"
name="stock"
type="number"
min={0}
value={values.stock}
required
onChange={handleNumberChange}
/>
</FormField>
</div>
<FormField label="Image URL" htmlFor="product-image" hint="Allowed demo host: picsum.photos">
<input
id="product-image"
name="imageUrl"
type="url"
value={values.imageUrl}
required
onChange={handleTextChange}
/>
</FormField>
{error && <p className={styles.error} role="alert">{error}</p>}
<Button type="submit" isLoading={isSubmitting}>
{submitLabel}
</Button>
</form>
)
}

View File

@@ -1,54 +0,0 @@
.root {
display: grid;
gap: 18px;
padding: clamp(22px, 4vw, 38px);
border: 1px solid var(--color-line);
border-radius: 24px;
background: var(--color-surface);
box-shadow: var(--shadow-card);
}
.heading {
display: flex;
align-items: start;
justify-content: space-between;
gap: 18px;
padding-bottom: 18px;
border-bottom: 1px solid var(--color-line);
}
.heading span {
color: var(--color-blue);
font-family: var(--font-mono);
font-size: 0.64rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.heading h2 {
margin-top: 8px;
font-family: var(--font-display);
font-size: clamp(1.8rem, 4vw, 3rem);
line-height: 0.92;
}
.columns {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.error {
padding: 12px 14px;
color: var(--color-error);
border-left: 4px solid var(--color-error);
background: color-mix(in srgb, var(--color-error) 8%, transparent);
font-size: 0.8rem;
line-height: 1.45;
}
@media (max-width: 560px) {
.columns {
grid-template-columns: 1fr;
}
}

View File

@@ -1,36 +0,0 @@
import type { ComponentPropsWithoutRef } from 'react'
import type {
Category,
CreateProductInput,
Product,
UpdateProductInput
} from '@/domains/catalog'
/**
* Собственные параметры admin product form.
*/
export type ProductFormParams = {
/** Справочник допустимых категорий. */
categories: Category[]
/** Редактируемый продукт или null для create mode. */
product: Product | null
/** Выполняется ли catalog mutation. */
isSubmitting: boolean
/** Ошибка последней catalog mutation. */
error: string | null
/** Передаёт валидный create/update input screen-владельцу. */
onSubmit: (input: CreateProductInput | UpdateProductInput) => Promise<void>
/** Возвращает форму в create mode. */
onCancel: () => void
}
/**
* Атрибуты корневой form без собственного submit callback.
*/
type RootAttrs = Omit<ComponentPropsWithoutRef<'form'>, 'children' | 'onSubmit'>
/**
* Props admin product form.
*/
export type ProductFormProps = RootAttrs & ProductFormParams

View File

@@ -1,2 +0,0 @@
export { ProductDetailScreen } from './product-detail.screen'
export type { ProductDetailScreenProps } from './types/product-detail-screen-props.type'

View File

@@ -1,127 +0,0 @@
'use client'
import cl from 'clsx'
import Image from 'next/image'
import Link from 'next/link'
import { PRODUCT_IMAGE_PLACEHOLDER, useProduct } from '@/domains/catalog'
import { useCart } from '@/domains/cart'
import { formatMoney } from '@/shared/lib/format-money'
import { Button } from '@/ui/button'
import { FeedbackPanel } from '@/ui/feedback-panel'
import type { ProductDetailScreenProps } from './types/product-detail-screen-props.type'
import styles from './styles/product-detail.module.css'
/**
* Детальная карточка выбранного catalog-продукта.
*
* Используется для:
* - проверки dynamic route и detail query
* - добавления продукта в application cart
*/
export const ProductDetailScreen = (props: ProductDetailScreenProps) => {
const { productId, className, ...rootAttrs } = props
const productState = useProduct(productId)
const cart = useCart()
/**
* Повторяет неуспешный product detail query.
*/
const handleReload = (): void => {
void productState.reload()
}
if (productState.isLoading) {
return (
<main {...rootAttrs} className={cl(styles.root, className)} aria-busy>
<div
className={styles.loading}
role="status"
aria-live="polite"
aria-label="Loading product"
/>
</main>
)
}
if (productState.error !== null || productState.product === null) {
const message = productState.error?.message ?? 'The product returned no usable data.'
return (
<main {...rootAttrs} className={cl(styles.root, styles.outcome, className)}>
<FeedbackPanel variant="error" title="Product boundary stopped here" description={message}>
<Button onClick={handleReload}>Retry detail</Button>
<Link className={styles.textLink} href="/">
Back to catalog
</Link>
</FeedbackPanel>
</main>
)
}
const product = productState.product
const isSoldOut = product.stock === 0
const stockLabel = isSoldOut ? 'Unavailable' : `${product.stock} units in deterministic stock`
/**
* Добавляет текущий detail-продукт в cart domain.
*/
const handleAdd = (): void => {
void cart.addProduct(product)
}
return (
<main {...rootAttrs} className={cl(styles.root, className)}>
<Link className={styles.back} href="/">
Back / catalog
</Link>
<article className={styles.product}>
<div className={styles.visual}>
<Image
className={styles.image}
src={product.imageUrl}
alt=""
fill
priority
sizes="(max-width: 820px) 100vw, 55vw"
onError={(event) => {
event.currentTarget.srcset = ''
event.currentTarget.src = PRODUCT_IMAGE_PLACEHOLDER
}}
/>
<span className={styles.version}>v{product.version}</span>
</div>
<div className={styles.content}>
<p className={styles.eyebrow}>Object / {product.categoryId.replace('category-', '')}</p>
<h1 className={styles.title}>{product.name}</h1>
<p className={styles.description}>{product.description}</p>
<dl className={styles.facts}>
<div>
<dt>Rating</dt>
<dd>{product.rating.toFixed(1)} / 5</dd>
</div>
<div>
<dt>Stock</dt>
<dd>{stockLabel}</dd>
</div>
<div>
<dt>Lock</dt>
<dd>Version {product.version}</dd>
</div>
</dl>
<div className={styles.purchase}>
<strong>{formatMoney(product.priceCents, product.currency)}</strong>
<Button disabled={isSoldOut} onClick={handleAdd}>
Add to cart
</Button>
</div>
</div>
</article>
</main>
)
}

View File

@@ -1,149 +0,0 @@
.root {
min-height: calc(100vh - 82px);
padding: clamp(28px, 5vw, 72px);
}
.back,
.textLink {
display: inline-flex;
color: var(--color-blue);
font-size: 0.7rem;
font-weight: 760;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.product {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(340px, 0.85fr);
gap: clamp(32px, 7vw, 100px);
margin-top: 34px;
}
.visual {
position: relative;
min-height: min(66vw, 720px);
overflow: hidden;
border-radius: 28px;
background: var(--color-surface-strong);
}
.image {
object-fit: cover;
}
.version {
position: absolute;
right: 18px;
bottom: 18px;
display: grid;
place-items: center;
min-width: 54px;
height: 54px;
color: var(--color-paper);
border-radius: 50%;
background: var(--color-blue);
font-family: var(--font-mono);
font-size: 0.72rem;
}
.content {
align-self: center;
display: grid;
gap: 28px;
}
.eyebrow {
color: var(--color-blue);
font-family: var(--font-mono);
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.title {
max-width: 9ch;
font-family: var(--font-display);
font-size: clamp(3.2rem, 7vw, 7rem);
letter-spacing: -0.055em;
line-height: 0.82;
}
.description {
max-width: 48ch;
color: var(--color-ink-muted);
font-size: 1.05rem;
line-height: 1.7;
}
.facts {
display: grid;
border-block: 1px solid var(--color-line);
}
.facts div {
display: grid;
grid-template-columns: 90px 1fr;
gap: 16px;
padding: 13px 0;
border-bottom: 1px solid var(--color-line);
}
.facts div:last-child {
border-bottom: 0;
}
.facts dt {
color: var(--color-ink-faint);
font-family: var(--font-mono);
font-size: 0.66rem;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.facts dd {
font-size: 0.88rem;
}
.purchase {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.purchase strong {
font-family: var(--font-mono);
font-size: clamp(1.35rem, 3vw, 2rem);
}
.outcome {
display: grid;
place-items: center;
}
.loading {
min-height: 70vh;
border-radius: 28px;
background:
linear-gradient(105deg, transparent 30%, rgb(255 255 255 / 55%) 45%, transparent 60%)
0 0 / 220% 100%,
var(--color-surface-strong);
animation: sweep 1.4s linear infinite;
}
@keyframes sweep {
to {
background-position: -220% 0;
}
}
@media (max-width: 820px) {
.product {
grid-template-columns: 1fr;
}
.visual {
min-height: 70vw;
}
}

View File

@@ -1,19 +0,0 @@
import type { ComponentPropsWithoutRef } from 'react'
/**
* Параметры product detail screen.
*/
export type ProductDetailScreenParams = {
/** Product id, адаптированный route entry. */
productId: string
}
/**
* Атрибуты корневого main без внешнего содержимого.
*/
type RootAttrs = Omit<ComponentPropsWithoutRef<'main'>, 'children'>
/**
* Props product detail screen.
*/
export type ProductDetailScreenProps = RootAttrs & ProductDetailScreenParams

View File

@@ -1,2 +0,0 @@
export { SignInScreen } from './sign-in.screen'
export type { SignInScreenProps } from './types/sign-in-screen-props.type'

View File

@@ -1,205 +0,0 @@
'use client'
import type { ChangeEvent, FormEvent } from 'react'
import { useState } from 'react'
import cl from 'clsx'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { AUTH_DEMO_ACCOUNTS, useAuth } from '@/domains/auth'
import type { AuthError, DemoAccount, SignInCredentials } from '@/domains/auth'
import { Button } from '@/ui/button'
import { FeedbackPanel } from '@/ui/feedback-panel'
import { FormField } from '@/ui/form-field'
import type { SignInScreenProps } from './types/sign-in-screen-props.type'
import styles from './styles/sign-in.module.css'
/**
* Auth route с двумя воспроизводимыми fixture-ролями.
*
* Используется для:
* - запуска JWT login/refresh lifecycle
* - переключения customer и admin RBAC outcomes
*/
export const SignInScreen = (props: SignInScreenProps) => {
const { className, ...rootAttrs } = props
const auth = useAuth()
const router = useRouter()
const [credentials, setCredentials] = useState<SignInCredentials>({
email: AUTH_DEMO_ACCOUNTS[0].email,
password: AUTH_DEMO_ACCOUNTS[0].password
})
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<AuthError | null>(null)
/**
* Подставляет выбранную deterministic account в форму.
*/
const handleAccountSelect = (account: DemoAccount): void => {
setCredentials({ email: account.email, password: account.password })
setError(null)
}
/**
* Обновляет email login-формы.
*/
const handleEmailChange = (event: ChangeEvent<HTMLInputElement>): void => {
setCredentials((current) => ({ ...current, email: event.target.value }))
}
/**
* Обновляет password login-формы.
*/
const handlePasswordChange = (event: ChangeEvent<HTMLInputElement>): void => {
setCredentials((current) => ({ ...current, password: event.target.value }))
}
/**
* Выполняет auth-domain login и переводит пользователя в каталог.
*/
const handleSubmit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault()
setIsSubmitting(true)
setError(null)
const result = await auth.signIn(credentials)
setIsSubmitting(false)
if (result.isSuccess) {
router.push('/')
return
}
setError(result.error)
}
/**
* Повторяет recoverable проверку сохранённой session.
*/
const handleSessionRetry = (): void => {
void auth.refreshCurrentUser()
}
/**
* Явно завершает сохранённую session после recoverable failure.
*/
const handleSignOut = (): void => {
void auth.signOut()
}
if (auth.status === 'checking') {
return (
<main {...rootAttrs} className={cl(styles.root, styles.outcome, className)}>
<FeedbackPanel
title="Restoring session"
description="The auth owner is validating persisted JWT data before exposing a route outcome."
/>
</main>
)
}
if (auth.status === 'authenticated' && auth.user !== null) {
return (
<main {...rootAttrs} className={cl(styles.root, styles.outcome, className)}>
<FeedbackPanel
variant="success"
title={`Signed in as ${auth.user.name}`}
description={`The active ${auth.user.role} session is available to every composition through the auth public API.`}
>
<div className={styles.outcomeActions}>
<Link className={styles.primaryLink} href="/">
Browse catalog
</Link>
<Link className={styles.textLink} href="/orders">
Open orders
</Link>
</div>
</FeedbackPanel>
</main>
)
}
if (auth.status === 'unavailable') {
return (
<main {...rootAttrs} className={cl(styles.root, styles.outcome, className)}>
<FeedbackPanel
variant="error"
title="Session check is temporarily unavailable"
description={auth.sessionError?.message ?? 'The saved session was kept for a safe retry.'}
>
<div className={styles.outcomeActions}>
<Button onClick={handleSessionRetry}>Retry session</Button>
<Button variant="ghost" onClick={handleSignOut}>End session</Button>
</div>
</FeedbackPanel>
</main>
)
}
return (
<main {...rootAttrs} className={cl(styles.root, className)}>
<section className={styles.intro}>
<p className={styles.kicker}>Auth / JWT transport</p>
<h1 className={styles.title}>Choose a boundary to cross.</h1>
<p className={styles.description}>
Both accounts use the same password, but their domain permissions produce different
navigation, mutations and order visibility.
</p>
<div className={styles.accounts}>
{AUTH_DEMO_ACCOUNTS.map((account) => (
<button
key={account.email}
type="button"
className={styles.accountCard}
onClick={() => handleAccountSelect(account)}
>
<span>{account.role}</span>
<strong>{account.email}</strong>
<small>{account.description}</small>
</button>
))}
</div>
</section>
<section className={styles.formPanel} aria-labelledby="sign-in-title">
<span className={styles.sequence}>02 / SESSION</span>
<h2 id="sign-in-title">Enter the fixture</h2>
<form className={styles.form} onSubmit={handleSubmit}>
<FormField label="Email" htmlFor="email" hint="Try either demo account">
<input
id="email"
name="email"
type="email"
autoComplete="username"
value={credentials.email}
required
onChange={handleEmailChange}
/>
</FormField>
<FormField label="Password" htmlFor="password" hint="Fixture default: demo1234">
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
minLength={8}
value={credentials.password}
required
onChange={handlePasswordChange}
/>
</FormField>
{error && <p className={styles.error} role="alert">{error.message}</p>}
<Button type="submit" isLoading={isSubmitting}>
Start session
</Button>
</form>
</section>
</main>
)
}

View File

@@ -1,162 +0,0 @@
.root {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(360px, 0.72fr);
min-height: calc(100vh - 82px);
}
.intro,
.formPanel {
padding: clamp(44px, 8vw, 110px);
}
.intro {
display: grid;
align-content: center;
gap: 30px;
color: var(--color-paper);
background: var(--color-ink);
}
.kicker,
.sequence {
font-family: var(--font-mono);
font-size: 0.68rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.kicker {
color: var(--color-acid);
}
.title {
max-width: 9ch;
font-family: var(--font-display);
font-size: clamp(3.2rem, 8vw, 7.5rem);
letter-spacing: -0.055em;
line-height: 0.82;
}
.description {
max-width: 48ch;
color: rgb(247 242 232 / 68%);
font-size: 1rem;
line-height: 1.7;
}
.accounts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.accountCard {
display: grid;
gap: 8px;
padding: 18px;
color: inherit;
border: 1px solid rgb(255 255 255 / 18%);
border-radius: 16px;
background: rgb(255 255 255 / 5%);
text-align: left;
cursor: pointer;
transition:
border-color 160ms ease,
background 160ms ease;
}
.accountCard:hover,
.accountCard:focus-visible {
border-color: var(--color-acid);
outline: none;
background: rgb(200 255 77 / 8%);
}
.accountCard span,
.accountCard small {
color: rgb(247 242 232 / 54%);
font-size: 0.66rem;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.accountCard strong {
overflow: hidden;
font-family: var(--font-mono);
font-size: 0.75rem;
text-overflow: ellipsis;
}
.formPanel {
position: relative;
display: grid;
align-content: center;
gap: 26px;
background:
radial-gradient(circle at 90% 10%, color-mix(in srgb, var(--color-orange) 22%, transparent), transparent 32%),
var(--color-paper);
}
.sequence {
color: var(--color-blue);
}
.formPanel h2 {
font-family: var(--font-display);
font-size: clamp(2rem, 5vw, 4rem);
line-height: 0.9;
}
.form {
display: grid;
gap: 20px;
max-width: 440px;
}
.error {
padding: 12px 14px;
color: var(--color-error);
border-left: 4px solid var(--color-error);
background: color-mix(in srgb, var(--color-error) 8%, transparent);
font-size: 0.84rem;
line-height: 1.45;
}
.outcome {
place-items: center;
padding: clamp(30px, 8vw, 120px);
}
.outcomeActions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 18px;
}
.primaryLink {
padding: 14px 20px;
color: var(--color-paper);
border-radius: 999px;
background: var(--color-ink);
font-size: 0.72rem;
font-weight: 760;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.textLink {
color: var(--color-blue);
font-size: 0.76rem;
font-weight: 760;
}
@media (max-width: 880px) {
.root {
grid-template-columns: 1fr;
}
.accounts {
grid-template-columns: 1fr;
}
}

View File

@@ -1,16 +0,0 @@
import type { ComponentPropsWithoutRef } from 'react'
/**
* Параметры sign-in screen.
*/
export type SignInScreenParams = object
/**
* Атрибуты корневого main без внешнего содержимого.
*/
type RootAttrs = Omit<ComponentPropsWithoutRef<'main'>, 'children'>
/**
* Props sign-in screen.
*/
export type SignInScreenProps = RootAttrs & SignInScreenParams

View File

@@ -1,140 +0,0 @@
'use client'
import type { ChangeEvent } from 'react'
import { useEffect, useEffectEvent, useRef } from 'react'
import cl from 'clsx'
import { useAuth } from '@/domains/auth'
import { useCart } from '@/domains/cart'
import { useDemoControl } from '@/domains/demo-control'
import { Button } from '@/ui/button'
import type { DemoToolbarWidgetProps } from './types/demo-toolbar-widget-props.type'
import styles from './styles/demo-toolbar.module.css'
/**
* Управляет детерминированными сбоями и seed fixture-бэкенда.
*
* Используется для:
* - проверки loading, empty, error и auth outcomes
* - сброса данных между архитектурными демонстрациями
*/
export const DemoToolbarWidget = (props: DemoToolbarWidgetProps) => {
const { className, ...rootAttrs } = props
const demo = useDemoControl()
const auth = useAuth()
const cart = useCart()
const selectedOption = demo.scenarios.find((option) => option.value === demo.scenario)
const healthLabel = demo.health === 'online' ? `API ${demo.apiVersion ?? ''}`.trim() : demo.health
const canChangeRole = auth.user !== null
const nextRole = auth.user?.role === 'admin' ? 'customer' : 'admin'
const roleActionLabel = `Become ${nextRole}`
const previousFixtureChangeRef = useRef(demo.fixtureChange)
const synchronizeFixtureOwners = useEffectEvent((change: string) => {
if (change.startsWith('data:')) {
void cart.clearCart()
}
if (auth.sessionKey !== null) {
void auth.refreshCurrentUser()
}
})
useEffect(() => {
if (previousFixtureChangeRef.current !== demo.fixtureChange) {
previousFixtureChangeRef.current = demo.fixtureChange
synchronizeFixtureOwners(demo.fixtureChange)
}
}, [demo.fixtureChange])
/**
* Применяет выбранный request-local сценарий.
*/
const handleScenarioChange = (event: ChangeEvent<HTMLSelectElement>): void => {
const selectedScenario = demo.scenarios.find((option) => option.value === event.target.value)
if (selectedScenario !== undefined) {
void demo.selectScenario(selectedScenario.value)
}
}
/**
* Восстанавливает исходный малый seed.
*/
const handleReset = async (): Promise<void> => {
await demo.resetData()
}
/**
* Загружает большой deterministic dataset.
*/
const handleLargeSeed = async (): Promise<void> => {
await demo.seedData('large')
}
/**
* Переключает роль текущего fixture-пользователя и обновляет профиль.
*/
const handleRoleChange = async (): Promise<void> => {
if (auth.user === null) {
return
}
await demo.changeRole(auth.user.id, nextRole)
}
return (
<aside {...rootAttrs} className={cl(styles.root, className)}>
<details className={styles.panel}>
<summary className={styles.summary}>
<span className={styles.pulse} data-health={demo.health} />
<span>Demo controls</span>
<span className={styles.current}>{demo.scenario}</span>
</summary>
<div className={styles.body}>
<div className={styles.intro}>
<p className={styles.eyebrow}>Fixture runtime</p>
<p className={styles.description}>
{selectedOption?.description ?? 'Choose a deterministic API behavior.'}
</p>
<span className={styles.health}>{healthLabel}</span>
</div>
<label className={styles.control}>
<span>Request scenario</span>
<select value={demo.scenario} onChange={handleScenarioChange}>
{demo.scenarios.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<div className={styles.actions}>
<Button variant="ghost" size="small" isLoading={demo.isMutating} onClick={handleReset}>
Reset data
</Button>
<Button variant="ghost" size="small" isLoading={demo.isMutating} onClick={handleLargeSeed}>
Seed 250
</Button>
{canChangeRole && (
<Button
variant="ghost"
size="small"
isLoading={demo.isMutating}
onClick={handleRoleChange}
>
{roleActionLabel}
</Button>
)}
</div>
{demo.message && <p className={styles.message} role="status">{demo.message}</p>}
{demo.error && <p className={styles.error} role="alert">{demo.error.message}</p>}
</div>
</details>
</aside>
)
}

View File

@@ -1,2 +0,0 @@
export { DemoToolbarWidget } from './demo-toolbar.widget'
export type { DemoToolbarWidgetProps } from './types/demo-toolbar-widget-props.type'

View File

@@ -1,159 +0,0 @@
.root {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 40;
width: min(390px, calc(100vw - 36px));
}
.panel {
overflow: hidden;
color: #f7f2e8;
border: 1px solid rgb(255 255 255 / 18%);
border-radius: 18px;
background: rgb(20 20 19 / 94%);
box-shadow: 0 24px 70px rgb(0 0 0 / 28%);
backdrop-filter: blur(18px);
}
.summary {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 10px;
min-height: 48px;
padding: 0 16px;
font-size: 0.74rem;
font-weight: 760;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
list-style: none;
}
.summary::-webkit-details-marker {
display: none;
}
.pulse {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ffac33;
box-shadow: 0 0 0 5px rgb(255 172 51 / 12%);
}
.pulse[data-health='online'] {
background: #c8ff4d;
box-shadow: 0 0 0 5px rgb(200 255 77 / 12%);
}
.pulse[data-health='offline'] {
background: #ff6b5f;
box-shadow: 0 0 0 5px rgb(255 107 95 / 14%);
}
.current {
max-width: 130px;
overflow: hidden;
color: #c8ff4d;
text-overflow: ellipsis;
white-space: nowrap;
}
.body {
display: grid;
gap: 16px;
padding: 4px 16px 16px;
border-top: 1px solid rgb(255 255 255 / 12%);
}
.intro {
display: grid;
grid-template-columns: 1fr auto;
gap: 5px 12px;
padding-top: 14px;
}
.eyebrow,
.control span {
color: rgb(247 242 232 / 56%);
font-size: 0.64rem;
font-weight: 760;
letter-spacing: 0.11em;
text-transform: uppercase;
}
.description {
grid-column: 1 / -1;
color: rgb(247 242 232 / 78%);
font-size: 0.82rem;
line-height: 1.45;
}
.health {
color: #c8ff4d;
font-family: var(--font-mono);
font-size: 0.68rem;
text-transform: uppercase;
}
.control {
display: grid;
gap: 7px;
}
.control select {
width: 100%;
min-height: 42px;
padding: 0 12px;
color: #f7f2e8;
border: 1px solid rgb(255 255 255 / 20%);
border-radius: 10px;
background: #292927;
font: inherit;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.actions button {
color: #f7f2e8;
border-color: rgb(255 255 255 / 20%);
background: transparent;
}
.message,
.error {
padding: 10px 12px;
border-radius: 10px;
font-size: 0.78rem;
line-height: 1.4;
}
.message {
color: #dfffa0;
background: rgb(200 255 77 / 10%);
}
.error {
color: #ffd1cc;
background: rgb(255 107 95 / 13%);
}
@media (max-width: 640px) {
.root {
right: 10px;
bottom: 10px;
width: calc(100vw - 20px);
}
.body {
max-height: calc(100dvh - 78px);
overflow-y: auto;
overscroll-behavior: contain;
}
}

View File

@@ -1,16 +0,0 @@
import type { ComponentPropsWithoutRef } from 'react'
/**
* Параметры виджета архитектурных demo-сценариев.
*/
export type DemoToolbarWidgetParams = object
/**
* Атрибуты корневого aside.
*/
type RootAttrs = ComponentPropsWithoutRef<'aside'>
/**
* Props demo toolbar widget.
*/
export type DemoToolbarWidgetProps = RootAttrs & DemoToolbarWidgetParams

View File

@@ -1,19 +0,0 @@
import type { DemoAccount } from './types/demo-account.type'
/**
* Учётные записи Simple API для воспроизводимых auth/RBAC-сценариев.
*/
export const AUTH_DEMO_ACCOUNTS: readonly DemoAccount[] = [
{
email: 'admin@demo.local',
password: 'demo1234',
role: 'admin',
description: 'Catalog CRUD and every order'
},
{
email: 'customer@demo.local',
password: 'demo1234',
role: 'customer',
description: 'Checkout and own orders'
}
]

View File

@@ -1,8 +0,0 @@
import { createContext } from 'react'
import type { AuthContextValue } from './types/auth-context.type'
/**
* Внутренний React context application-scoped auth API.
*/
export const AuthContext = createContext<AuthContextValue | null>(null)

View File

@@ -1,393 +0,0 @@
'use client'
import { startTransition, useEffect, useRef, useState } from 'react'
import {
clearSimpleAuthSessionScope,
discardSimpleAuthSession,
readSimpleAuthSession,
replaceSimpleAuthSession,
subscribeSimpleAuthSession
} from '@/infra/simple-auth-session'
import type { SimpleAuthSession } from '@/infra/simple-auth-session'
import { simpleRestApi } from '@/infra/simple-rest-api'
import type { Result } from '@/shared/types/result.type'
import { AuthContext } from './auth.context'
import { mapAuthError } from './errors/auth-error.mapper'
import { mapAuthUser } from './mappers/auth-user.mapper'
import type { AuthError } from './types/auth-error.type'
import type { AuthContextValue, SignInCredentials } from './types/auth-context.type'
import type { AuthProviderProps } from './types/auth-provider-props.type'
import type { AuthUser } from './types/auth-user.type'
const SESSION_STORAGE_ERROR: AuthError = {
code: 'service-unavailable',
message: 'Browser storage is unavailable. The session was not changed.'
}
const SESSION_CHANGED_ERROR: AuthError = {
code: 'session-expired',
message: 'The active session changed before the request completed.'
}
/**
* Владеет пользовательской сессией на всём времени жизни приложения.
*
* Используется для:
* - восстановления профиля из CAS-protected JWT session
* - предоставления login/logout API всем composition-модулям
*/
export const AuthProvider = (props: AuthProviderProps) => {
const { children } = props
const [status, setStatus] = useState<AuthContextValue['status']>('checking')
const [user, setUser] = useState<AuthUser | null>(null)
const [sessionKey, setSessionKey] = useState<string | null>(null)
const [sessionError, setSessionError] = useState<AuthError | null>(null)
const sessionKeyRef = useRef<string | null>(null)
const profileRequestIdRef = useRef(0)
useEffect(() => {
let isActive = true
/**
* Загружает authority-профиль только для всё ещё актуальной session scope.
*/
const loadSessionUser = async (session: SimpleAuthSession): Promise<void> => {
const requestId = ++profileRequestIdRef.current
try {
const response = await simpleRestApi.users.simpleUsersMe()
const currentResult = readSimpleAuthSession()
if (
!isActive ||
requestId !== profileRequestIdRef.current
) {
return
}
if (currentResult.status === 'unavailable') {
setSessionError(SESSION_STORAGE_ERROR)
setStatus('unavailable')
return
}
if (currentResult.session?.sessionId !== session.sessionId) {
return
}
startTransition(() => {
sessionKeyRef.current = session.sessionId
setSessionKey(session.sessionId)
setUser(mapAuthUser(response.data))
setSessionError(null)
setStatus('authenticated')
})
} catch (error) {
const currentResult = readSimpleAuthSession()
if (!isActive || requestId !== profileRequestIdRef.current) {
return
}
if (currentResult.status === 'unavailable') {
setSessionError(SESSION_STORAGE_ERROR)
setStatus('unavailable')
return
}
const currentSession = currentResult.session
if (currentSession === null) {
sessionKeyRef.current = null
setSessionKey(null)
setUser(null)
setSessionError(null)
setStatus('guest')
return
}
if (currentSession.sessionId === session.sessionId) {
startTransition(() => {
sessionKeyRef.current = session.sessionId
setSessionKey(session.sessionId)
setSessionError(mapAuthError(error))
setStatus('unavailable')
})
}
}
}
const unsubscribe = subscribeSimpleAuthSession((result) => {
if (!isActive) {
return
}
if (result.status === 'unavailable') {
profileRequestIdRef.current += 1
setSessionError(SESSION_STORAGE_ERROR)
setStatus('unavailable')
return
}
const session = result.session
if (session === null) {
profileRequestIdRef.current += 1
sessionKeyRef.current = null
setSessionKey(null)
setUser(null)
setSessionError(null)
setStatus('guest')
return
}
if (session.sessionId !== sessionKeyRef.current) {
profileRequestIdRef.current += 1
sessionKeyRef.current = session.sessionId
setSessionKey(session.sessionId)
setUser(null)
setSessionError(null)
setStatus('checking')
void loadSessionUser(session)
}
})
const initialResult = readSimpleAuthSession()
if (initialResult.status === 'unavailable') {
queueMicrotask(() => {
if (isActive) {
setSessionError(SESSION_STORAGE_ERROR)
setStatus('unavailable')
}
})
} else if (initialResult.session === null) {
queueMicrotask(() => {
if (isActive) {
setStatus('guest')
}
})
} else {
const session = initialResult.session
sessionKeyRef.current = session.sessionId
void loadSessionUser(session)
}
return () => {
isActive = false
unsubscribe()
}
}, [])
/**
* Выполняет login и создаёт новую logical session scope.
*/
const signIn = async (
credentials: SignInCredentials
): Promise<Result<AuthUser, AuthError>> => {
try {
const response = await simpleRestApi.auth.simpleAuthLogin(credentials)
const nextUser = mapAuthUser(response.data.user)
const session = await replaceSimpleAuthSession(response.data.tokens)
if (session === null) {
return { isSuccess: false, error: SESSION_STORAGE_ERROR }
}
startTransition(() => {
profileRequestIdRef.current += 1
sessionKeyRef.current = session.sessionId
setSessionKey(session.sessionId)
setUser(nextUser)
setSessionError(null)
setStatus('authenticated')
})
return { isSuccess: true, data: nextUser }
} catch (error) {
return { isSuccess: false, error: mapAuthError(error) }
}
}
/**
* Локально завершает session до best-effort backend logout.
*/
const signOut = async (): Promise<void> => {
const currentResult = readSimpleAuthSession()
if (currentResult.status === 'unavailable') {
if (currentResult.observedValue === null) {
setSessionError(SESSION_STORAGE_ERROR)
setStatus('unavailable')
return
}
const isDiscarded = await discardSimpleAuthSession(currentResult.observedValue)
if (!isDiscarded) {
const latestResult = readSimpleAuthSession()
if (latestResult.status === 'unavailable') {
setSessionError(SESSION_STORAGE_ERROR)
setStatus('unavailable')
}
return
}
profileRequestIdRef.current += 1
sessionKeyRef.current = null
setSessionKey(null)
setUser(null)
setSessionError(null)
setStatus('guest')
return
}
const session = currentResult.session
if (session === null) {
profileRequestIdRef.current += 1
sessionKeyRef.current = null
setSessionKey(null)
setUser(null)
setSessionError(null)
setStatus('guest')
return
}
const isCleared = await clearSimpleAuthSessionScope(session.sessionId)
if (!isCleared) {
setSessionError(SESSION_STORAGE_ERROR)
setStatus('unavailable')
return
}
profileRequestIdRef.current += 1
sessionKeyRef.current = null
setSessionKey(null)
setUser(null)
setSessionError(null)
setStatus('guest')
try {
await simpleRestApi.auth.simpleAuthLogout({
refreshToken: session.tokens.refreshToken
})
} catch {
// Remote revocation is best-effort after the local scope is already closed.
}
}
/**
* Обновляет authority-профиль или публикует recoverable session failure.
*/
const refreshCurrentUser = async (): Promise<Result<AuthUser, AuthError>> => {
const expectedResult = readSimpleAuthSession()
if (expectedResult.status === 'unavailable') {
setSessionError(SESSION_STORAGE_ERROR)
setStatus('unavailable')
return { isSuccess: false, error: SESSION_STORAGE_ERROR }
}
const expectedSession = expectedResult.session
if (expectedSession === null) {
profileRequestIdRef.current += 1
sessionKeyRef.current = null
setSessionKey(null)
setUser(null)
setSessionError(null)
setStatus('guest')
return { isSuccess: false, error: SESSION_CHANGED_ERROR }
}
const requestId = ++profileRequestIdRef.current
try {
const response = await simpleRestApi.users.simpleUsersMe()
const nextUser = mapAuthUser(response.data)
const currentResult = readSimpleAuthSession()
if (currentResult.status === 'unavailable') {
setSessionError(SESSION_STORAGE_ERROR)
setStatus('unavailable')
return { isSuccess: false, error: SESSION_STORAGE_ERROR }
}
const currentSession = currentResult.session
if (
requestId !== profileRequestIdRef.current ||
currentSession?.sessionId !== expectedSession.sessionId
) {
return { isSuccess: false, error: SESSION_CHANGED_ERROR }
}
startTransition(() => {
sessionKeyRef.current = expectedSession.sessionId
setSessionKey(expectedSession.sessionId)
setUser(nextUser)
setSessionError(null)
setStatus('authenticated')
})
return { isSuccess: true, data: nextUser }
} catch (error) {
const authError = mapAuthError(error)
const currentResult = readSimpleAuthSession()
if (currentResult.status === 'unavailable') {
setSessionError(SESSION_STORAGE_ERROR)
setStatus('unavailable')
return { isSuccess: false, error: SESSION_STORAGE_ERROR }
}
const currentSession = currentResult.session
if (
requestId !== profileRequestIdRef.current ||
currentSession?.sessionId !== expectedSession.sessionId
) {
return { isSuccess: false, error: SESSION_CHANGED_ERROR }
}
startTransition(() => {
sessionKeyRef.current = expectedSession.sessionId
setSessionKey(expectedSession.sessionId)
setSessionError(authError)
setStatus('unavailable')
})
return { isSuccess: false, error: authError }
}
}
/**
* Сверяет captured session scope с domain state и persisted authority.
*/
const isCurrentSession = (expectedSessionKey: string): boolean => {
const currentResult = readSimpleAuthSession()
const persistedSession = currentResult.status === 'ready' ? currentResult.session : null
return (
sessionKeyRef.current === expectedSessionKey &&
persistedSession?.sessionId === expectedSessionKey
)
}
const value: AuthContextValue = {
status,
user,
sessionKey,
sessionError,
isCurrentSession,
signIn,
signOut,
refreshCurrentUser
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}

View File

@@ -1,28 +0,0 @@
import { readSimpleApiFailure } from '@/infra/simple-rest-api'
import type { AuthError } from '../types/auth-error.type'
/**
* Переводит technical failure в ожидаемую ошибку auth-домена.
*/
export const mapAuthError = (error: unknown): AuthError => {
const failure = readSimpleApiFailure(error)
if (failure.code === 'INVALID_CREDENTIALS') {
return { code: 'invalid-credentials', message: 'Email or password is incorrect.' }
}
if (failure.status === 401) {
return { code: 'session-expired', message: 'Your session expired. Sign in again.' }
}
if (failure.status === 403) {
return { code: 'access-denied', message: 'This account cannot perform that action.' }
}
if (failure.status === 0) {
return { code: 'service-unavailable', message: failure.message }
}
return { code: 'unknown', message: 'Authentication could not be completed.' }
}

View File

@@ -1,19 +0,0 @@
'use client'
import { useContext } from 'react'
import { AuthContext } from '../auth.context'
import type { AuthContextValue } from '../types/auth-context.type'
/**
* Возвращает application-scoped auth API текущего пользователя.
*/
export const useAuth = (): AuthContextValue => {
const context = useContext(AuthContext)
if (context === null) {
throw new Error('useAuth must be used inside AuthProvider')
}
return context
}

View File

@@ -1,14 +0,0 @@
'use client'
export { AuthProvider } from './auth.provider'
export { AUTH_DEMO_ACCOUNTS } from './auth.constants'
export { useAuth } from './hooks/use-auth.hook'
export type { AuthError, AuthErrorCode } from './types/auth-error.type'
export type {
AuthContextValue,
AuthStatus,
SignInCredentials
} from './types/auth-context.type'
export type { AuthProviderProps } from './types/auth-provider-props.type'
export type { AuthUser, AuthUserRole } from './types/auth-user.type'
export type { DemoAccount } from './types/demo-account.type'

View File

@@ -1,16 +0,0 @@
import type { SimpleUserDto } from '@/infra/simple-rest-api'
import type { AuthUser } from '../types/auth-user.type'
/**
* Переводит внешний user DTO в модель auth-домена.
*/
export const mapAuthUser = (user: SimpleUserDto): AuthUser => {
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
avatarUrl: typeof user.avatarUrl === 'string' ? user.avatarUrl : null
}
}

View File

@@ -1,41 +0,0 @@
import type { Result } from '@/shared/types/result.type'
import type { AuthError } from './auth-error.type'
import type { AuthUser } from './auth-user.type'
/**
* Состояние восстановления пользовательской сессии.
*/
export type AuthStatus = 'checking' | 'guest' | 'authenticated' | 'unavailable'
/**
* Данные формы входа.
*/
export type SignInCredentials = {
/** Email demo-пользователя. */
email: string
/** Пароль demo-пользователя. */
password: string
}
/**
* Публичный API auth-домена в application scope.
*/
export type AuthContextValue = {
/** Текущее состояние восстановления или авторизации. */
status: AuthStatus
/** Авторизованный пользователь, если сессия активна. */
user: AuthUser | null
/** Непрозрачный ключ логической сессии для изоляции protected state. */
sessionKey: string | null
/** Recoverable ошибка проверки сохранённой сессии. */
sessionError: AuthError | null
/** Проверяет актуальность captured logical session для async workflow. */
isCurrentSession: (sessionKey: string) => boolean
/** Выполняет login и сохраняет технические JWT-данные. */
signIn: (credentials: SignInCredentials) => Promise<Result<AuthUser, AuthError>>
/** Завершает локальную и backend-сессию. */
signOut: () => Promise<void>
/** Повторно загружает профиль после внешнего изменения роли. */
refreshCurrentUser: () => Promise<Result<AuthUser, AuthError>>
}

View File

@@ -1,19 +0,0 @@
/**
* Стабильные коды ожидаемых ошибок auth-сценариев.
*/
export type AuthErrorCode =
| 'invalid-credentials'
| 'session-expired'
| 'access-denied'
| 'service-unavailable'
| 'unknown'
/**
* Безопасная ошибка auth-домена.
*/
export type AuthError = {
/** Код для UI-ветвления. */
code: AuthErrorCode
/** Сообщение без transport payload. */
message: string
}

View File

@@ -1,9 +0,0 @@
import type { ReactNode } from 'react'
/**
* Props application-scoped auth provider.
*/
export type AuthProviderProps = {
/** Поддерево, использующее auth API. */
children: ReactNode
}

View File

@@ -1,20 +0,0 @@
/**
* Роль пользователя в Simple storefront.
*/
export type AuthUserRole = 'admin' | 'customer'
/**
* Авторизованный пользователь без transport-полей.
*/
export type AuthUser = {
/** Стабильный идентификатор пользователя. */
id: string
/** Email для входа и отображения профиля. */
email: string
/** Отображаемое имя. */
name: string
/** Текущая роль доступа. */
role: AuthUserRole
/** Необязательный URL аватара. */
avatarUrl: string | null
}

View File

@@ -1,15 +0,0 @@
import type { AuthUserRole } from './auth-user.type'
/**
* Детерминированная учётная запись fixture-бэкенда.
*/
export type DemoAccount = {
/** Email для формы входа. */
email: string
/** Общий fixture-пароль. */
password: string
/** Роль, которую демонстрирует учётная запись. */
role: AuthUserRole
/** Краткое описание доступных сценариев. */
description: string
}

View File

@@ -1,8 +0,0 @@
import { createContext } from 'react'
import type { CartContextValue } from './types/cart.type'
/**
* Внутренний React context application-scoped корзины.
*/
export const CartContext = createContext<CartContextValue | null>(null)

View File

@@ -1,133 +0,0 @@
import type { Product } from '@/domains/catalog'
import type { CartLine, CartReconciliation, CartTotals } from './types/cart.type'
const MAX_CART_LINE_QUANTITY = 20
/**
* Возвращает единый cart/order limit для конкретного продукта.
*/
export const getCartProductQuantityLimit = (product: Product): number => {
return Math.min(product.stock, MAX_CART_LINE_QUANTITY)
}
/**
* Ограничивает cart quantity текущим stock продукта.
*/
const clampQuantity = (product: Product, quantity: number): number => {
if (!Number.isFinite(quantity)) {
return 0
}
return Math.max(0, Math.min(Math.floor(quantity), getCartProductQuantityLimit(product)))
}
/**
* Добавляет продукт или увеличивает существующую строку корзины.
*/
export const addCartProduct = (lines: CartLine[], product: Product): CartLine[] => {
const existingLine = lines.find((line) => line.product.id === product.id)
if (existingLine === undefined) {
const quantity = clampQuantity(product, 1)
return quantity === 0 ? lines : [...lines, { product, quantity }]
}
return lines.map((line) => {
if (line.product.id !== product.id) {
return line
}
return {
product,
quantity: clampQuantity(product, line.quantity + 1)
}
})
}
/**
* Устанавливает количество строки и удаляет её при нулевом результате.
*/
export const setCartProductQuantity = (
lines: CartLine[],
productId: string,
quantity: number
): CartLine[] => {
return lines.flatMap((line) => {
if (line.product.id !== productId) {
return [line]
}
const nextQuantity = clampQuantity(line.product, quantity)
return nextQuantity === 0 ? [] : [{ ...line, quantity: nextQuantity }]
})
}
/**
* Удаляет одну продуктовую строку.
*/
export const removeCartProduct = (lines: CartLine[], productId: string): CartLine[] => {
return lines.filter((line) => line.product.id !== productId)
}
/**
* Заменяет product snapshots authority-значениями перед checkout.
*/
export const reconcileCartProducts = (
lines: CartLine[],
products: Product[]
): CartReconciliation => {
const productsById = new Map(products.map((product) => [product.id, product]))
let hasChanges = false
const nextLines = lines
.map((line) => {
const currentProduct = productsById.get(line.product.id)
if (currentProduct === undefined) {
return line
}
const hasProductChanged =
currentProduct.version !== line.product.version ||
currentProduct.priceCents !== line.product.priceCents ||
currentProduct.currency !== line.product.currency ||
currentProduct.stock !== line.product.stock
if (hasProductChanged) {
hasChanges = true
}
return {
product: currentProduct,
quantity: clampQuantity(currentProduct, line.quantity)
}
})
.filter((line) => line.quantity > 0)
if (nextLines.length !== lines.length) {
hasChanges = true
}
return { lines: nextLines, hasChanges }
}
/**
* Рассчитывает item count, subtotal и допустимость checkout.
*/
export const calculateCartTotals = (lines: CartLine[]): CartTotals => {
const itemCount = lines.reduce((sum, line) => sum + line.quantity, 0)
const subtotalCents = lines.reduce(
(sum, line) => sum + line.product.priceCents * line.quantity,
0
)
const firstCurrency = lines[0]?.product.currency ?? null
const hasSingleCurrency = lines.every((line) => line.product.currency === firstCurrency)
return {
itemCount,
subtotalCents,
currency: hasSingleCurrency ? firstCurrency : null
}
}

View File

@@ -1,301 +0,0 @@
import { hasOwn, isArrayOf, isNumber, isOneOf, isRecord, isString } from '@/shared/lib/value-predicates'
import {
readJsonStorageResult,
withBrowserStorageLock,
writeJsonStorage
} from '@/infra/browser-storage'
import { isSupportedProductImageUrl } from '@/domains/catalog'
import type { Product } from '@/domains/catalog'
import { getCartProductQuantityLimit } from './cart.logic'
import type { CartLine } from './types/cart.type'
const CURRENCIES = ['USD', 'EUR'] as const
const CART_STORAGE_KEY = 'demo-frontend:cart'
const CART_STORAGE_LOCK = 'demo-frontend:cart-lock'
/**
* Создаёт revision, сохраняющую порядок обычных updates и forced recovery.
*/
const createCartRevision = (currentRevision = 0): number => {
return Math.max(currentRevision + 1, Date.now() * 1000)
}
/**
* Persisted cart snapshot с cross-tab monotonic revision.
*/
export type PersistedCartSnapshot = {
/** Монотонная revision, увеличиваемая под storage lock. */
revision: number
/** Валидированные product snapshots и quantities. */
lines: CartLine[]
}
/**
* Результат чтения persisted cart repository.
*/
export type PersistedCartReadResult =
| {
/** Repository доступен. */
status: 'ready'
/** Последний snapshot либо null до инициализации. */
snapshot: PersistedCartSnapshot | null
/** Требуется ли commit мигрированного legacy payload. */
shouldCommit: boolean
}
| {
/** Browser storage временно недоступен. */
status: 'unavailable'
}
/**
* Результат atomic cart transition.
*/
export type PersistedCartUpdateResult =
| {
/** Snapshot записан с новой revision. */
status: 'updated'
/** Authority snapshot после transition. */
snapshot: PersistedCartSnapshot
}
| {
/** Transform не изменил authority snapshot. */
status: 'unchanged'
/** Текущий authority snapshot. */
snapshot: PersistedCartSnapshot
}
| {
/** Expected revision больше не актуальна. */
status: 'stale'
}
| {
/** Repository transition не удалось подтвердить. */
status: 'unavailable'
}
/**
* Результат проверки persisted cart revision.
*/
export type PersistedCartRevisionStatus = 'current' | 'stale' | 'unavailable'
/**
* Проверяет persisted-снимок продукта перед восстановлением cart state.
*/
const isStoredProduct = (value: unknown): value is Product => {
return (
isRecord(value) &&
hasOwn(value, 'id') && isString(value.id) &&
hasOwn(value, 'name') && isString(value.name) &&
hasOwn(value, 'slug') && isString(value.slug) &&
hasOwn(value, 'description') && isString(value.description) &&
hasOwn(value, 'priceCents') && isNumber(value.priceCents) &&
hasOwn(value, 'currency') && isOneOf(value.currency, CURRENCIES) &&
hasOwn(value, 'categoryId') && isString(value.categoryId) &&
hasOwn(value, 'stock') && isNumber(value.stock) &&
hasOwn(value, 'rating') && isNumber(value.rating) &&
hasOwn(value, 'imageUrl') && isString(value.imageUrl) && isSupportedProductImageUrl(value.imageUrl) &&
hasOwn(value, 'createdAt') && isString(value.createdAt) &&
hasOwn(value, 'version') && isNumber(value.version)
)
}
/**
* Проверяет persisted-строку корзины.
*/
const isStoredCartLine = (value: unknown): value is CartLine => {
return (
isRecord(value) &&
hasOwn(value, 'product') && isStoredProduct(value.product) &&
hasOwn(value, 'quantity') && isNumber(value.quantity) &&
Number.isInteger(value.quantity) &&
value.quantity > 0 &&
value.quantity <= getCartProductQuantityLimit(value.product)
)
}
/**
* Восстанавливает только полностью валидный persisted cart snapshot.
*/
export const hydrateCartLines = (value: unknown): CartLine[] => {
return isArrayOf(value, isStoredCartLine) ? value : []
}
/**
* Проверяет persisted snapshot envelope после чтения storage.
*/
const isPersistedCartSnapshot = (value: unknown): value is PersistedCartSnapshot => {
return (
isRecord(value) &&
hasOwn(value, 'revision') &&
isNumber(value.revision) &&
Number.isInteger(value.revision) &&
value.revision >= 1 &&
hasOwn(value, 'lines') &&
isArrayOf(value.lines, isStoredCartLine)
)
}
/**
* Читает snapshot и мигрирует legacy lines-array в memory representation.
*/
export const readPersistedCartSnapshot = (): PersistedCartReadResult => {
const result = readJsonStorageResult(CART_STORAGE_KEY)
if (result.status === 'unavailable' || result.status === 'invalid') {
return { status: 'unavailable' }
}
if (result.status === 'missing') {
return { status: 'ready', snapshot: null, shouldCommit: true }
}
if (isPersistedCartSnapshot(result.value)) {
return { status: 'ready', snapshot: result.value, shouldCommit: false }
}
return {
status: 'ready',
snapshot: {
revision: createCartRevision(),
lines: hydrateCartLines(result.value)
},
shouldCommit: true
}
}
/**
* Создаёт или мигрирует persisted cart snapshot под cross-tab lock.
*/
export const initializePersistedCartSnapshot = async (): Promise<PersistedCartSnapshot | null> => {
try {
return await withBrowserStorageLock(CART_STORAGE_LOCK, () => {
const result = readPersistedCartSnapshot()
if (result.status === 'unavailable') {
return null
}
const snapshot = result.snapshot ?? { revision: createCartRevision(), lines: [] }
if (result.shouldCommit && !writeJsonStorage(CART_STORAGE_KEY, snapshot)) {
return null
}
return snapshot
})
} catch {
return null
}
}
/**
* Перезаписывает unreadable cart безопасным пустым snapshot по явному действию пользователя.
*/
export const resetPersistedCartSnapshot = async (
minimumRevision = 0
): Promise<PersistedCartSnapshot | null> => {
try {
return await withBrowserStorageLock(CART_STORAGE_LOCK, () => {
const snapshot: PersistedCartSnapshot = {
revision: createCartRevision(minimumRevision),
lines: []
}
return writeJsonStorage(CART_STORAGE_KEY, snapshot) ? snapshot : null
})
} catch {
return null
}
}
/**
* Применяет transform к последнему persisted snapshot под cross-tab lock.
*/
export const updatePersistedCartSnapshot = async (
transform: (lines: CartLine[]) => CartLine[] | null,
expectedRevision?: number
): Promise<PersistedCartUpdateResult> => {
try {
return await withBrowserStorageLock(CART_STORAGE_LOCK, () => {
const result = readPersistedCartSnapshot()
if (result.status === 'unavailable') {
return { status: 'unavailable' }
}
const currentSnapshot = result.snapshot ?? {
revision: createCartRevision(),
lines: []
}
if (
expectedRevision !== undefined &&
currentSnapshot.revision !== expectedRevision
) {
return { status: 'stale' }
}
const nextLines = transform(currentSnapshot.lines)
if (nextLines === null || nextLines === currentSnapshot.lines) {
return { status: 'unchanged', snapshot: currentSnapshot }
}
const nextSnapshot: PersistedCartSnapshot = {
revision: createCartRevision(currentSnapshot.revision),
lines: nextLines
}
if (!writeJsonStorage(CART_STORAGE_KEY, nextSnapshot)) {
return { status: 'unavailable' }
}
return { status: 'updated', snapshot: nextSnapshot }
})
} catch {
return { status: 'unavailable' }
}
}
/**
* Сверяет expected revision с persisted authority под тем же lock.
*/
export const isPersistedCartRevisionCurrent = async (
expectedRevision: number
): Promise<PersistedCartRevisionStatus> => {
try {
return await withBrowserStorageLock(CART_STORAGE_LOCK, () => {
const result = readPersistedCartSnapshot()
if (result.status === 'unavailable') {
return 'unavailable'
}
return result.snapshot?.revision === expectedRevision ? 'current' : 'stale'
})
} catch {
return 'unavailable'
}
}
/**
* Подписывает cart owner на committed snapshots других вкладок.
*/
export const subscribePersistedCartSnapshot = (
listener: (result: PersistedCartReadResult) => void
): (() => void) => {
/**
* Читает committed snapshot после cross-tab storage event.
*/
const handleStorage = (event: StorageEvent): void => {
if (event.key === CART_STORAGE_KEY) {
listener(readPersistedCartSnapshot())
}
}
window.addEventListener('storage', handleStorage)
return () => window.removeEventListener('storage', handleStorage)
}

View File

@@ -1,225 +0,0 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { CartContext } from './cart.context'
import {
addCartProduct,
calculateCartTotals,
reconcileCartProducts,
removeCartProduct,
setCartProductQuantity
} from './cart.logic'
import {
initializePersistedCartSnapshot,
isPersistedCartRevisionCurrent,
resetPersistedCartSnapshot,
subscribePersistedCartSnapshot,
updatePersistedCartSnapshot
} from './cart.persistence'
import type { PersistedCartSnapshot } from './cart.persistence'
import type { CartProviderProps } from './types/cart-provider-props.type'
import type { CartContextValue, CartLine, CartReconciliationStatus } from './types/cart.type'
import type { Product } from '@/domains/catalog'
const CART_STORAGE_ERROR = {
code: 'storage-unavailable',
message: 'Cart storage is unavailable. Changes are paused to prevent cross-tab data loss.'
} as const
/**
* Владеет корзиной в application scope и сохраняет её между перезагрузками.
*
* Используется для:
* - координации каталога и checkout composition
* - восстановления валидного cart snapshot из browser storage
*/
export const CartProvider = (props: CartProviderProps) => {
const { children } = props
const [cartState, setCartState] = useState({
lines: [] as CartLine[],
revision: 0
})
const cartStateRef = useRef(cartState)
const [isHydrated, setIsHydrated] = useState(false)
const [error, setError] = useState<CartContextValue['error']>(null)
const lines = cartState.lines
/**
* Публикует только текущий или более новый persisted cart snapshot.
*/
const publishSnapshot = (snapshot: PersistedCartSnapshot): void => {
if (snapshot.revision < cartStateRef.current.revision) {
return
}
const nextState = {
lines: snapshot.lines,
revision: snapshot.revision
}
cartStateRef.current = nextState
setCartState(nextState)
}
useEffect(() => {
let isActive = true
void initializePersistedCartSnapshot().then((snapshot) => {
if (isActive) {
if (snapshot !== null) {
publishSnapshot(snapshot)
setError(null)
} else {
setError(CART_STORAGE_ERROR)
}
setIsHydrated(true)
}
})
const unsubscribe = subscribePersistedCartSnapshot((result) => {
if (isActive && result.status === 'ready' && result.snapshot !== null) {
publishSnapshot(result.snapshot)
setError(null)
} else if (isActive && result.status === 'unavailable') {
setError(CART_STORAGE_ERROR)
}
})
return () => {
isActive = false
unsubscribe()
}
}, [])
/**
* Применяет cart transform к последнему cross-tab authority snapshot.
*/
const updateLines = async (
transform: (currentLines: CartLine[]) => CartLine[]
): Promise<void> => {
const result = await updatePersistedCartSnapshot(transform)
if (result.status === 'updated' || result.status === 'unchanged') {
publishSnapshot(result.snapshot)
setError(null)
} else if (result.status === 'unavailable') {
setError(CART_STORAGE_ERROR)
}
}
/**
* Добавляет одну доступную единицу продукта.
*/
const addProduct = async (product: Product): Promise<void> => {
await updateLines((currentLines) => addCartProduct(currentLines, product))
}
/**
* Изменяет количество выбранного продукта.
*/
const setQuantity = async (productId: string, quantity: number): Promise<void> => {
await updateLines((currentLines) => {
return setCartProductQuantity(currentLines, productId, quantity)
})
}
/**
* Удаляет продукт из корзины.
*/
const removeProduct = async (productId: string): Promise<void> => {
await updateLines((currentLines) => removeCartProduct(currentLines, productId))
}
/**
* Обновляет stale snapshots перед checkout и сообщает о расхождении.
*/
const reconcileProducts = (
products: Product[],
expectedRevision: number
): Promise<CartReconciliationStatus> => {
return updatePersistedCartSnapshot((currentLines) => {
const reconciliation = reconcileCartProducts(currentLines, products)
return reconciliation.hasChanges ? reconciliation.lines : null
}, expectedRevision).then((result) => {
if (result.status === 'unavailable') {
setError(CART_STORAGE_ERROR)
return 'unavailable'
}
if (result.status === 'stale') {
return 'stale'
}
publishSnapshot(result.snapshot)
setError(null)
return result.status === 'updated' ? 'updated' : 'unchanged'
})
}
/**
* Проверяет revision перед необратимым продолжением async checkout.
*/
const isCurrentRevision: CartContextValue['isCurrentRevision'] = (revision) => {
return isPersistedCartRevisionCurrent(revision).then((status) => {
if (status === 'unavailable') {
setError(CART_STORAGE_ERROR)
}
return status
})
}
/**
* Завершает checkout очисткой всех строк.
*/
const clearCart: CartContextValue['clearCart'] = async (expectedRevision) => {
const result = await updatePersistedCartSnapshot(() => [], expectedRevision)
if (result.status === 'unavailable') {
if (expectedRevision === undefined) {
const recoveredSnapshot = await resetPersistedCartSnapshot(
cartStateRef.current.revision
)
if (recoveredSnapshot !== null) {
publishSnapshot(recoveredSnapshot)
setError(null)
return 'cleared'
}
}
setError(CART_STORAGE_ERROR)
return 'unavailable'
}
if (result.status === 'stale') {
return 'stale'
}
publishSnapshot(result.snapshot)
setError(null)
return 'cleared'
}
const totals = calculateCartTotals(lines)
const value: CartContextValue = {
...totals,
lines,
isHydrated,
revision: cartState.revision,
error,
addProduct,
setQuantity,
removeProduct,
reconcileProducts,
isCurrentRevision,
clearCart
}
return <CartContext.Provider value={value}>{children}</CartContext.Provider>
}

View File

@@ -1,19 +0,0 @@
'use client'
import { useContext } from 'react'
import { CartContext } from '../cart.context'
import type { CartContextValue } from '../types/cart.type'
/**
* Возвращает application-scoped API корзины.
*/
export const useCart = (): CartContextValue => {
const context = useContext(CartContext)
if (context === null) {
throw new Error('useCart must be used inside CartProvider')
}
return context
}

View File

@@ -1,15 +0,0 @@
'use client'
export { CartProvider } from './cart.provider'
export { getCartProductQuantityLimit } from './cart.logic'
export { useCart } from './hooks/use-cart.hook'
export type { CartProviderProps } from './types/cart-provider-props.type'
export type {
CartContextValue,
CartClearStatus,
CartError,
CartLine,
CartReconciliation,
CartRevisionStatus,
CartTotals
} from './types/cart.type'

View File

@@ -1,74 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { Product } from '@/domains/catalog'
import {
addCartProduct,
calculateCartTotals,
reconcileCartProducts,
setCartProductQuantity
} from '../cart.logic'
const PRODUCT: Product = {
id: 'product-test',
name: 'Test object',
slug: 'test-object',
description: 'A deterministic product for cart owner tests.',
priceCents: 2500,
currency: 'USD',
categoryId: 'category-test',
stock: 2,
rating: 4.5,
imageUrl: 'https://picsum.photos/seed/test/640/480',
createdAt: '2026-07-20T10:00:00.000Z',
version: 1
}
describe('cart domain logic', () => {
it('caps quantity at the current product stock', () => {
const once = addCartProduct([], PRODUCT)
const twice = addCartProduct(once, PRODUCT)
const threeAttempts = addCartProduct(twice, PRODUCT)
expect(threeAttempts).toEqual([{ product: PRODUCT, quantity: 2 }])
})
it('caps high-stock products at the order line contract limit', () => {
const highStockProduct = { ...PRODUCT, stock: 100 }
const lines = addCartProduct([], highStockProduct)
expect(setCartProductQuantity(lines, highStockProduct.id, 21)).toEqual([
{ product: highStockProduct, quantity: 20 }
])
})
it('removes a line when quantity becomes zero', () => {
const lines = addCartProduct([], PRODUCT)
expect(setCartProductQuantity(lines, PRODUCT.id, 0)).toEqual([])
})
it('rejects checkout for mixed currencies while retaining item totals', () => {
const eurProduct: Product = { ...PRODUCT, id: 'product-eur', currency: 'EUR' }
const totals = calculateCartTotals([
{ product: PRODUCT, quantity: 1 },
{ product: eurProduct, quantity: 1 }
])
expect(totals).toEqual({
itemCount: 2,
subtotalCents: 5000,
currency: null
})
})
it('reconciles a stale price and version before checkout', () => {
const lines = [{ product: PRODUCT, quantity: 1 }]
const latestProduct = { ...PRODUCT, priceCents: 3100, version: 2 }
expect(reconcileCartProducts(lines, [latestProduct])).toEqual({
lines: [{ product: latestProduct, quantity: 1 }],
hasChanges: true
})
})
})

View File

@@ -1,140 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
hydrateCartLines,
initializePersistedCartSnapshot,
readPersistedCartSnapshot,
resetPersistedCartSnapshot,
updatePersistedCartSnapshot
} from '../cart.persistence'
const STORED_LINE = {
product: {
id: 'product-test',
name: 'Test object',
slug: 'test-object',
description: 'Persisted cart fixture.',
priceCents: 2500,
currency: 'USD' as const,
categoryId: 'category-test',
stock: 2,
rating: 4.5,
imageUrl: 'https://picsum.photos/seed/test/640/480',
createdAt: '2026-07-20T10:00:00.000Z',
version: 1
},
quantity: 1
}
/**
* Создаёт localStorage для persisted cart CAS tests.
*/
const createStorage = (): Storage => {
const values = new Map<string, string>()
return {
get length() {
return values.size
},
clear: () => values.clear(),
getItem: (key) => values.get(key) ?? null,
key: (index) => [...values.keys()][index] ?? null,
removeItem: (key) => values.delete(key),
setItem: (key, value) => values.set(key, value)
}
}
beforeEach(() => {
vi.stubGlobal('window', {
localStorage: createStorage(),
addEventListener: vi.fn(),
removeEventListener: vi.fn()
})
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('cart persistence boundary', () => {
it('rejects a persisted product image outside the configured host', () => {
const storedLines = [
{
...STORED_LINE,
product: {
...STORED_LINE.product,
imageUrl: 'https://example.com/untrusted.png'
}
}
]
expect(hydrateCartLines(storedLines)).toEqual([])
})
it('rejects a legacy quantity above the order line limit', () => {
const storedLines = [
{
...STORED_LINE,
product: { ...STORED_LINE.product, stock: 100 },
quantity: 21
}
]
expect(hydrateCartLines(storedLines)).toEqual([])
})
it('rejects a fractional persisted quantity', () => {
expect(hydrateCartLines([{ ...STORED_LINE, quantity: 1.5 }])).toEqual([])
})
it('does not clear a newer cross-tab snapshot before its storage event arrives', async () => {
const initialSnapshot = await initializePersistedCartSnapshot()
expect(initialSnapshot).not.toBeNull()
if (initialSnapshot === null) {
return
}
const checkoutSnapshot = await updatePersistedCartSnapshot(() => [STORED_LINE])
expect(checkoutSnapshot.status).toBe('updated')
if (checkoutSnapshot.status !== 'updated') {
return
}
await updatePersistedCartSnapshot((lines) => {
const line = lines[0]
return line === undefined ? lines : [{ ...line, quantity: 2 }]
})
const staleClear = await updatePersistedCartSnapshot(
() => [],
checkoutSnapshot.snapshot.revision
)
const current = readPersistedCartSnapshot()
expect(staleClear).toEqual({ status: 'stale' })
expect(current.status === 'ready' ? current.snapshot?.lines[0]?.quantity : null).toBe(2)
})
it('keeps forced recovery above the local revision when the clock moves backwards', async () => {
const currentSnapshot = await initializePersistedCartSnapshot()
expect(currentSnapshot).not.toBeNull()
if (currentSnapshot === null) {
return
}
vi.spyOn(Date, 'now').mockReturnValue(1)
window.localStorage.setItem('demo-frontend:cart', '{broken-json')
const recoveredSnapshot = await resetPersistedCartSnapshot(currentSnapshot.revision)
expect(recoveredSnapshot?.revision).toBeGreaterThan(currentSnapshot.revision)
})
})

View File

@@ -1,9 +0,0 @@
import type { ReactNode } from 'react'
/**
* Props application-scoped cart provider.
*/
export type CartProviderProps = {
/** Поддерево, использующее cart API. */
children: ReactNode
}

View File

@@ -1,87 +0,0 @@
import type { CatalogCurrency, Product } from '@/domains/catalog'
/**
* Одна продуктовая позиция корзины.
*/
export type CartLine = {
/** Снимок продукта на момент добавления. */
product: Product
/** Количество единиц от 1 до backend-лимита 20. */
quantity: number
}
/**
* Рассчитанные итоги текущей корзины.
*/
export type CartTotals = {
/** Суммарное число единиц. */
itemCount: number
/** Сумма в минимальных единицах при единой валюте. */
subtotalCents: number
/** Единая валюта либо null для смешанной корзины. */
currency: CatalogCurrency | null
}
/**
* Результат сверки cart snapshots с authority catalog.
*/
export type CartReconciliation = {
/** Строки с актуальными product snapshots. */
lines: CartLine[]
/** Изменился ли price, currency, stock, version или доступность. */
hasChanges: boolean
}
/**
* Результат CAS-сверки checkout snapshot с authority catalog.
*/
export type CartReconciliationStatus = 'stale' | 'unavailable' | 'unchanged' | 'updated'
/**
* Результат conditional cart clear после checkout или fixture transition.
*/
export type CartClearStatus = 'cleared' | 'stale' | 'unavailable'
/**
* Результат persisted revision precondition.
*/
export type CartRevisionStatus = 'current' | 'stale' | 'unavailable'
/**
* Recoverable failure persisted cart repository.
*/
export type CartError = {
/** Стабильный код storage failure. */
code: 'storage-unavailable'
/** Сообщение для application-level feedback. */
message: string
}
/**
* Публичный API cart-домена в application scope.
*/
export type CartContextValue = CartTotals & {
/** Позиции корзины. */
lines: CartLine[]
/** Завершено ли восстановление persisted-снимка. */
isHydrated: boolean
/** Revision текущего immutable cart snapshot. */
revision: number
/** Последний recoverable repository failure. */
error: CartError | null
/** Добавляет одну единицу продукта с учётом stock. */
addProduct: (product: Product) => Promise<void>
/** Изменяет количество или удаляет строку при нуле. */
setQuantity: (productId: string, quantity: number) => Promise<void>
/** Удаляет продукт из корзины. */
removeProduct: (productId: string) => Promise<void>
/** Заменяет snapshots только для ожидаемой cart revision. */
reconcileProducts: (
products: Product[],
expectedRevision: number
) => Promise<CartReconciliationStatus>
/** Проверяет, что async workflow всё ещё относится к cart snapshot. */
isCurrentRevision: (revision: number) => Promise<CartRevisionStatus>
/** Очищает корзину безусловно либо только для ожидаемой revision. */
clearCart: (expectedRevision?: number) => Promise<CartClearStatus>
}

View File

@@ -1,50 +0,0 @@
import type { CreateProductInput, UpdateProductInput } from './types/catalog-command.type'
import type { CatalogError } from './types/catalog-error.type'
export const PRODUCT_IMAGE_PLACEHOLDER = '/product-placeholder.svg'
/**
* Проверяет, что remote image может быть безопасно отрисован Next Image.
*/
export const isSupportedProductImageUrl = (value: string): boolean => {
if (value === PRODUCT_IMAGE_PLACEHOLDER) {
return true
}
try {
const url = new URL(value)
return url.protocol === 'https:' && url.hostname === 'picsum.photos'
} catch {
return false
}
}
/**
* Нормализует внешний image URL к гарантированно отображаемому product source.
*/
export const normalizeProductImageUrl = (value: string): string => {
return isSupportedProductImageUrl(value) ? value : PRODUCT_IMAGE_PLACEHOLDER
}
/**
* Проверяет frontend product contract до необратимой backend mutation.
*/
export const validateProductInput = (
input: CreateProductInput | UpdateProductInput
): CatalogError | null => {
if (
input.imageUrl !== undefined &&
(
input.imageUrl === PRODUCT_IMAGE_PLACEHOLDER ||
!isSupportedProductImageUrl(input.imageUrl)
)
) {
return {
code: 'validation',
message: 'Use an HTTPS image hosted on picsum.photos.'
}
}
return null
}

View File

@@ -1,36 +0,0 @@
import { readSimpleApiFailure } from '@/infra/simple-rest-api'
import type { CatalogError } from '../types/catalog-error.type'
/**
* Переводит technical failure в ожидаемую ошибку catalog-домена.
*/
export const mapCatalogError = (error: unknown): CatalogError => {
const failure = readSimpleApiFailure(error)
if (failure.code === 'SESSION_CHANGED') {
return { code: 'forbidden', message: 'The active admin session changed. Start again.' }
}
if (failure.status === 404) {
return { code: 'not-found', message: 'The requested product no longer exists.' }
}
if (failure.status === 409) {
return { code: 'conflict', message: 'This product changed. Reload it before saving again.' }
}
if (failure.status === 403) {
return { code: 'forbidden', message: 'Administrator access is required.' }
}
if (failure.status === 400 || failure.status === 422) {
return { code: 'validation', message: 'Check the submitted product fields.' }
}
if (failure.status === 0) {
return { code: 'service-unavailable', message: failure.message }
}
return { code: 'unknown', message: 'The catalog request could not be completed.' }
}

View File

@@ -1,113 +0,0 @@
'use client'
import {
createSimpleAuthSessionRequest,
simpleRestApi,
useSimpleRestApiCache
} from '@/infra/simple-rest-api'
import { mapCatalogError } from '../errors/catalog-error.mapper'
import { mapProduct } from '../mappers/catalog.mapper'
import { validateProductInput } from '../catalog.validation'
import type { CatalogCommands } from '../types/catalog-command.type'
/**
* Возвращает catalog commands, привязанные к текущему REST cache scope.
*/
export const useCatalogCommands = (): CatalogCommands => {
const cache = useSimpleRestApiCache()
/**
* Создаёт продукт с domain validation и backend RBAC.
*/
const createProduct: CatalogCommands['createProduct'] = async (input, sessionKey) => {
const validationError = validateProductInput(input)
if (validationError !== null) {
return { isSuccess: false, error: validationError }
}
try {
const response = await simpleRestApi.products.simpleProductsCreate(
input,
createSimpleAuthSessionRequest(sessionKey)
)
const product = mapProduct(response.data)
await cache.invalidateCatalog()
return { isSuccess: true, data: product }
} catch (error) {
return { isSuccess: false, error: mapCatalogError(error) }
}
}
/**
* Сохраняет продукт с optimistic-lock версией последнего чтения.
*/
const updateProduct: CatalogCommands['updateProduct'] = async (input, sessionKey) => {
const validationError = validateProductInput(input)
if (validationError !== null) {
return { isSuccess: false, error: validationError }
}
const { id, ...changes } = input
try {
const response = await simpleRestApi.products.simpleProductsUpdate(
{ id },
changes,
createSimpleAuthSessionRequest(sessionKey)
)
const product = mapProduct(response.data)
await cache.invalidateCatalog()
return { isSuccess: true, data: product }
} catch (error) {
return { isSuccess: false, error: mapCatalogError(error) }
}
}
/**
* Удаляет продукт и ревалидирует только catalog resources.
*/
const removeProduct: CatalogCommands['removeProduct'] = async (productId, sessionKey) => {
try {
await simpleRestApi.products.simpleProductsRemove(
{ id: productId },
createSimpleAuthSessionRequest(sessionKey)
)
await cache.invalidateCatalog()
return { isSuccess: true, data: { id: productId } }
} catch (error) {
return { isSuccess: false, error: mapCatalogError(error) }
}
}
/**
* Загружает свежие product snapshots перед checkout или conflict recovery.
*/
const loadProducts: CatalogCommands['loadProducts'] = async (productIds) => {
const uniqueProductIds = [...new Set(productIds)]
try {
const responses = await Promise.all(
uniqueProductIds.map((productId) =>
simpleRestApi.products.simpleProductsGet({ id: productId })
)
)
return {
isSuccess: true,
data: responses.map((response) => mapProduct(response.data))
}
} catch (error) {
return { isSuccess: false, error: mapCatalogError(error) }
}
}
return { createProduct, updateProduct, removeProduct, loadProducts }
}

View File

@@ -1,23 +0,0 @@
'use client'
import { useGetCategories } from '@/infra/simple-rest-api'
import { mapCatalogError } from '../errors/catalog-error.mapper'
import { mapCategory } from '../mappers/catalog.mapper'
import type { CategoriesState } from '../types/catalog-state.type'
/**
* Получает справочник категорий для фильтров и admin-форм.
*/
export const useCategories = (): CategoriesState => {
const query = useGetCategories()
return {
categories: query.data?.data.map(mapCategory) ?? [],
isLoading: query.isLoading,
error: query.error ? mapCatalogError(query.error) : null,
reload: async () => {
await query.mutate()
}
}
}

View File

@@ -1,42 +0,0 @@
'use client'
import { useGetProducts } from '@/infra/simple-rest-api'
import { mapCatalogError } from '../errors/catalog-error.mapper'
import { mapProduct } from '../mappers/catalog.mapper'
import type { CatalogFilters } from '../types/catalog-query.type'
import type { ProductCatalogState } from '../types/catalog-state.type'
/**
* Получает фильтрованную выдачу и публикует только catalog-модели.
*/
export const useProductCatalog = (filters: CatalogFilters): ProductCatalogState => {
const query = useGetProducts({
page: filters.page,
limit: filters.limit,
search: filters.search || undefined,
categoryId: filters.categoryId || undefined,
sort: filters.sort
})
const products = query.data?.data.map(mapProduct) ?? []
const meta = query.data?.meta
const pagination = meta
? {
page: meta.page,
limit: meta.limit,
total: meta.total,
totalPages: meta.totalPages
}
: null
return {
products,
pagination,
isLoading: query.isLoading,
isRefreshing: query.isValidating && query.data !== undefined,
error: query.error ? mapCatalogError(query.error) : null,
reload: async () => {
await query.mutate()
}
}
}

View File

@@ -1,23 +0,0 @@
'use client'
import { useGetProduct } from '@/infra/simple-rest-api'
import { mapCatalogError } from '../errors/catalog-error.mapper'
import { mapProduct } from '../mappers/catalog.mapper'
import type { ProductState } from '../types/catalog-state.type'
/**
* Получает один продукт по route-параметру.
*/
export const useProduct = (productId: string): ProductState => {
const query = useGetProduct(productId ? { id: productId } : null)
return {
product: query.data ? mapProduct(query.data.data) : null,
isLoading: query.isLoading,
error: query.error ? mapCatalogError(query.error) : null,
reload: async () => {
await query.mutate()
}
}
}

View File

@@ -1,29 +0,0 @@
'use client'
export { useCategories } from './hooks/use-categories.hook'
export { useCatalogCommands } from './hooks/use-catalog-commands.hook'
export { useProduct } from './hooks/use-product.hook'
export { useProductCatalog } from './hooks/use-product-catalog.hook'
export {
isSupportedProductImageUrl,
PRODUCT_IMAGE_PLACEHOLDER
} from './catalog.validation'
export type {
CatalogCommands,
CreateProductInput,
RemovedProduct,
UpdateProductInput
} from './types/catalog-command.type'
export type { CatalogError, CatalogErrorCode } from './types/catalog-error.type'
export type {
CatalogCurrency,
CatalogPagination,
Category,
Product
} from './types/catalog-model.type'
export type { CatalogFilters, CatalogSort } from './types/catalog-query.type'
export type {
CategoriesState,
ProductCatalogState,
ProductState
} from './types/catalog-state.type'

View File

@@ -1,36 +0,0 @@
import type { SimpleCategoryDto, SimpleProductDto } from '@/infra/simple-rest-api'
import type { Category, Product } from '../types/catalog-model.type'
import { normalizeProductImageUrl } from '../catalog.validation'
/**
* Переводит product DTO в модель catalog-домена.
*/
export const mapProduct = (product: SimpleProductDto): Product => {
return {
id: product.id,
name: product.name,
slug: product.slug,
description: product.description,
priceCents: product.priceCents,
currency: product.currency,
categoryId: product.categoryId,
stock: product.stock,
rating: product.rating,
imageUrl: normalizeProductImageUrl(product.imageUrl),
createdAt: product.createdAt,
version: product.version
}
}
/**
* Переводит category DTO в модель catalog-домена.
*/
export const mapCategory = (category: SimpleCategoryDto): Category => {
return {
id: category.id,
name: category.name,
slug: category.slug,
productCount: category.productCount
}
}

View File

@@ -1,43 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { SimpleProductDto } from '@/infra/simple-rest-api'
import { mapProduct } from '../mappers/catalog.mapper'
import { PRODUCT_IMAGE_PLACEHOLDER } from '../catalog.validation'
const PRODUCT_DTO: SimpleProductDto = {
id: 'product-keyboard',
name: 'Mechanical Keyboard',
slug: 'mechanical-keyboard',
description: 'A deterministic keyboard.',
priceCents: 12990,
currency: 'USD',
categoryId: 'category-electronics',
stock: 24,
rating: 4.8,
imageUrl: 'https://picsum.photos/seed/keyboard/640/480',
createdAt: '2026-07-20T10:00:00.000Z',
version: 3
}
describe('catalog DTO mapping', () => {
it('creates a domain product without retaining the DTO object', () => {
const product = mapProduct(PRODUCT_DTO)
expect(product).not.toBe(PRODUCT_DTO)
expect(product).toMatchObject({
id: 'product-keyboard',
priceCents: 12990,
version: 3
})
})
it('replaces an unsupported remote image before it reaches Next Image', () => {
const product = mapProduct({
...PRODUCT_DTO,
imageUrl: 'https://example.com/untrusted.png'
})
expect(product.imageUrl).toBe(PRODUCT_IMAGE_PLACEHOLDER)
})
})

View File

@@ -1,29 +0,0 @@
import { describe, expect, it } from 'vitest'
import { validateProductInput } from '../catalog.validation'
import type { CreateProductInput } from '../types/catalog-command.type'
const INPUT: CreateProductInput = {
name: 'Safe object',
description: 'A deterministic object with an allowed remote image.',
priceCents: 9900,
currency: 'USD',
categoryId: 'category-electronics',
stock: 10,
imageUrl: 'https://picsum.photos/seed/safe-object/640/480'
}
describe('catalog product validation', () => {
it('accepts the configured image host', () => {
expect(validateProductInput(INPUT)).toBeNull()
})
it('rejects URLs that Next Image cannot render', () => {
expect(
validateProductInput({ ...INPUT, imageUrl: 'https://example.com/object.jpg' })?.code
).toBe('validation')
expect(
validateProductInput({ ...INPUT, imageUrl: 'http://picsum.photos/seed/object/640/480' })?.code
).toBe('validation')
})
})

View File

@@ -1,61 +0,0 @@
import type { CatalogCurrency, Product } from './catalog-model.type'
import type { CatalogError } from './catalog-error.type'
import type { Result } from '@/shared/types/result.type'
/**
* Поля нового продукта, доступные администратору.
*/
export type CreateProductInput = {
/** Название продукта. */
name: string
/** Полное описание продукта. */
description: string
/** Цена в минимальных единицах. */
priceCents: number
/** Валюта цены. */
currency: CatalogCurrency
/** Идентификатор существующей категории. */
categoryId: string
/** Начальный остаток. */
stock: number
/** URL изображения. */
imageUrl: string
}
/**
* Изменение продукта с optimistic-lock версией.
*/
export type UpdateProductInput = Partial<CreateProductInput> & {
/** Идентификатор изменяемого продукта. */
id: string
/** Последняя прочитанная версия. */
version: number
}
/**
* Результат удаления продукта.
*/
export type RemovedProduct = Pick<Product, 'id'>
/**
* Public mutation/read-through commands catalog-домена.
*/
export type CatalogCommands = {
/** Создаёт продукт в captured admin session. */
createProduct: (
input: CreateProductInput,
sessionKey: string
) => Promise<Result<Product, CatalogError>>
/** Сохраняет optimistic-lock изменение в captured admin session. */
updateProduct: (
input: UpdateProductInput,
sessionKey: string
) => Promise<Result<Product, CatalogError>>
/** Удаляет продукт в captured admin session. */
removeProduct: (
productId: string,
sessionKey: string
) => Promise<Result<RemovedProduct, CatalogError>>
/** Загружает authority snapshots для checkout reconciliation. */
loadProducts: (productIds: string[]) => Promise<Result<Product[], CatalogError>>
}

View File

@@ -1,20 +0,0 @@
/**
* Коды ожидаемых ошибок каталога.
*/
export type CatalogErrorCode =
| 'not-found'
| 'conflict'
| 'forbidden'
| 'validation'
| 'service-unavailable'
| 'unknown'
/**
* Безопасная ошибка catalog-домена.
*/
export type CatalogError = {
/** Код для выбора UI outcome. */
code: CatalogErrorCode
/** Сообщение без backend payload. */
message: string
}

View File

@@ -1,62 +0,0 @@
/**
* Валюта витрины, поддерживаемая Simple API.
*/
export type CatalogCurrency = 'USD' | 'EUR'
/**
* Продукт каталога с optimistic-lock версией.
*/
export type Product = {
/** Стабильный идентификатор. */
id: string
/** Название продукта. */
name: string
/** URL-friendly имя. */
slug: string
/** Описание для карточки и detail-экрана. */
description: string
/** Цена в минимальных единицах валюты. */
priceCents: number
/** Валюта цены. */
currency: CatalogCurrency
/** Идентификатор категории. */
categoryId: string
/** Доступный остаток. */
stock: number
/** Рейтинг от нуля до пяти. */
rating: number
/** URL изображения продукта. */
imageUrl: string
/** ISO-дата создания. */
createdAt: string
/** Версия последнего прочитанного состояния. */
version: number
}
/**
* Категория выдачи продуктов.
*/
export type Category = {
/** Стабильный идентификатор. */
id: string
/** Отображаемое название. */
name: string
/** URL-friendly имя. */
slug: string
/** Число продуктов в текущем backend seed. */
productCount: number
}
/**
* Метаданные offset pagination.
*/
export type CatalogPagination = {
/** Текущая страница. */
page: number
/** Размер страницы. */
limit: number
/** Полное число совпадений. */
total: number
/** Полное число страниц. */
totalPages: number
}

View File

@@ -1,20 +0,0 @@
/**
* Поддерживаемая сортировка каталога.
*/
export type CatalogSort = 'newest' | 'price-asc' | 'price-desc' | 'name'
/**
* Фильтры публичной выдачи каталога.
*/
export type CatalogFilters = {
/** Номер страницы. */
page: number
/** Число карточек на странице. */
limit: number
/** Поисковая строка. */
search: string
/** Выбранная категория или пустая строка. */
categoryId: string
/** Активная сортировка. */
sort: CatalogSort
}

Some files were not shown because too many files have changed in this diff Show More