chore: demo app

This commit is contained in:
S. Gromov
2026-08-10 19:13:20 +03:00
parent d0a00fecbc
commit 993dab47df
162 changed files with 12145 additions and 0 deletions

View File

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

27
examples/react-vite/.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.env
.env.*
!.env.example
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,21 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
"domains/*/*",
"infra/*/*",
"ui/*/*",
"shared/lib/*/*",
"compositions/screens/*/*",
"compositions/layouts/*/*"
]
}
],
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}

View File

@@ -0,0 +1,113 @@
# SLM Store
Облегчённое React + Vite приложение по Scoped Layered Module Design. Оно использует только маленький контракт [`../demo-backend/openapi/simple.json`](../demo-backend/openapi/simple.json); `complex.json` намеренно не включён в runtime-граф.
## Возможности
- JWT login, однократный конкурентный refresh и idempotent logout.
- Вход под admin и customer demo-учётными записями.
- Каталог с поиском, категориями, сортировкой и offset pagination.
- Admin create, update с optimistic locking и delete продукта.
- Draft order с фиксацией версии и цены продукта.
- Checkout, stock/currency validation, история и отмена заказов.
- Собственные доменные модели, исходы и runtime-проверка внешних ответов.
## Запуск
Требуются Node.js 20+ и npm 10+.
Сначала запустите Simple backend:
```bash
cd examples/demo-backend
npm install
npm run dev:simple
```
Затем в отдельном терминале запустите frontend:
```bash
cd examples/react-vite
npm install
npm run dev
```
Frontend откроется на `http://localhost:5173`, backend работает на `http://localhost:3001`.
## Demo-пользователи
| Роль | Email | Пароль |
|---|---|---|
| Administrator | `admin@demo.local` | `demo1234` |
| Customer | `customer@demo.local` | `demo1234` |
## Конфигурация
```bash
cp .env.example .env
```
| Переменная | Назначение | Значение по умолчанию |
|---|---|---|
| `VITE_SIMPLE_API_URL` | Base URL Simple API | `http://localhost:3001` |
Access token живёт в памяти вкладки. Refresh token хранится в `sessionStorage` и очищается вместе с session-scoped SWR cache при logout или окончательном истечении сессии.
## OpenAPI
Generated-код находится в `src/infra/simple-rest-api/generated` и не редактируется вручную. Регенерация использует зафиксированную версию `@gromlab/api-codegen`:
```bash
npm run codegen:simple-rest-api
```
OpenAPI ошибочно описывает `page` и `limit` через пустую `Object` schema. Исправленный browser-контракт локализован внутри `infra/simple-rest-api/types`; generated-файлы остаются неизменными.
## SLM
SLM root: `src`.
| Слой | Владельцы и ответственность |
|---|---|
| `app` | Vite entry, router, application providers и cache lifecycle |
| `compositions` | `sign-in`, `storefront`, `app-shell`; только размещение и связывание публичных API |
| `domains` | `session`, `catalog`, `orders`; модели, сценарии, исходы, состояние, UI и source adaptation |
| `infra` | `simple-rest-api`; generated SDK, transport credentials, refresh race и SWR GET-хуки |
| `ui` | Универсальные `button` и `field` |
| `shared` | Чистые formatters и value predicates |
Свёрнутый граф модулей:
```text
app -> compositions
app -> domains
compositions -> compositions
compositions -> domains
domains -> infra
domains -> ui
domains -> shared
```
Каждый внешний импорт проходит через корневой `index.ts` целевого модуля. В корне модуля находятся только публичные фасеты и не более одного главного implementation/assembly-файла; context, hooks, source, types и прочая реализация находятся в сегментах.
### Владение состоянием
| Состояние | Владелец | Область жизни |
|---|---|---|
| Пользователь и session status | `domains/session` | Всё browser-приложение |
| JWT credentials и refresh promise | `infra/simple-rest-api` | Вкладка / transport singleton |
| Product/order server state | REST hooks + доменная адаптация | Application SWR cache |
| Draft order | `domains/orders` | Авторизованный storefront route |
| Фильтры и editor state | `domains/catalog` | Экземпляр CatalogPanel |
## Проверки
```bash
npm run lint
npm run typecheck
npm run test
npm run build
npm run check
```
Тесты покрывают полный login-to-checkout smoke, объединение конкурентных 401 в один refresh и независимое преобразование source errors в доменные исходы.

View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/slm-store.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="SLM Store: демонстрационное React-приложение для Simple API."
/>
<title>SLM Store</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2657
examples/react-vite/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,43 @@
{
"name": "react-vite",
"private": true,
"version": "0.0.0",
"description": "Lightweight SLM React storefront for the Demo Simple API",
"type": "module",
"scripts": {
"codegen:simple-rest-api": "npx @gromlab/api-codegen@5.1.0 -i ../demo-backend/openapi/simple.json -o src/infra/simple-rest-api/generated",
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"typecheck": "tsc -b",
"test": "vitest run",
"test:watch": "vitest",
"check": "npm run lint && npm run typecheck && npm run test && npm run build",
"preview": "vite preview"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"clsx": "^2.1.1",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2",
"swr": "^2.5.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.3",
"@types/node": "^24.13.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.4",
"jsdom": "^30.0.1",
"oxlint": "^1.75.0",
"typescript": "~6.0.2",
"vite": "^8.2.0",
"vitest": "^4.1.10"
}
}

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="18" fill="#192c23"/>
<path fill="#e36f4f" d="M16 17h35L39 31h10L25 51V36H13l12-19h-9Z"/>
<circle cx="46" cy="17" r="5" fill="#9ee0b9"/>
</svg>

After

Width:  |  Height:  |  Size: 243 B

View File

@@ -0,0 +1,151 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { clearSimpleRestApiTokens } from 'infra/simple-rest-api'
import { App } from './app'
import { AppProviders } from './providers/app-providers'
const demoUser = {
id: 'user-admin',
email: 'admin@demo.local',
name: 'Demo Admin',
role: 'admin',
avatarUrl: 'https://i.pravatar.cc/160?img=12'
}
const demoProduct = {
id: 'product-keyboard',
name: 'Mechanical Keyboard',
slug: 'mechanical-keyboard',
description: 'Mechanical Keyboard is a deterministic demo product used by frontend examples.',
priceCents: 12990,
currency: 'USD',
categoryId: 'category-electronics',
stock: 24,
rating: 4.8,
imageUrl: 'https://picsum.photos/seed/mechanical-keyboard/640/480',
createdAt: '2026-07-10T09:00:00.000Z',
version: 1
}
/**
* Создаёт JSON response, совместимый с generated HttpClient.
*/
const jsonResponse = (body: unknown, status = 200): Response => {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' }
})
}
/**
* Эмулирует минимальный Simple API для app-level smoke-сценария.
*/
const createSimpleApiFetch = () => {
return vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url = new URL(String(input))
const method = init?.method ?? 'GET'
if (url.pathname === '/api/v1/auth/login' && method === 'POST') {
return jsonResponse({
data: {
tokens: {
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresIn: 60,
tokenType: 'Bearer'
},
user: demoUser
}
})
}
if (url.pathname === '/api/v1/products' && method === 'GET') {
return jsonResponse({
data: [demoProduct],
meta: { page: 1, limit: 6, total: 1, totalPages: 1 }
})
}
if (url.pathname === '/api/v1/categories' && method === 'GET') {
return jsonResponse({
data: [
{
id: 'category-electronics',
name: 'Electronics',
slug: 'electronics',
productCount: 1
}
]
})
}
if (url.pathname === '/api/v1/orders' && method === 'GET') {
return jsonResponse({
data: [],
meta: { page: 1, limit: 20, total: 0, totalPages: 0 }
})
}
if (url.pathname === '/api/v1/orders' && method === 'POST') {
return jsonResponse(
{
data: {
id: 'order-010',
userId: 'user-admin',
status: 'pending',
items: [
{
productId: demoProduct.id,
productName: demoProduct.name,
quantity: 1,
unitPriceCents: demoProduct.priceCents
}
],
totalCents: demoProduct.priceCents,
currency: 'USD',
createdAt: '2026-08-10T10:00:00.000Z'
}
},
201
)
}
return jsonResponse({ code: 'NOT_FOUND', message: 'Not found' }, 404)
})
}
afterEach(() => {
clearSimpleRestApiTokens()
})
describe('App', () => {
it('authenticates, loads domain data and checks out a product', async () => {
const user = userEvent.setup()
const fetchMock = createSimpleApiFetch()
vi.stubGlobal('fetch', fetchMock)
render(
<AppProviders>
<App />
</AppProviders>
)
await user.click(await screen.findByRole('button', { name: 'Войти в магазин' }))
expect(await screen.findByRole('heading', { name: 'Mechanical Keyboard' })).toBeInTheDocument()
expect(screen.getByText('Demo Admin')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'В заказ' }))
expect(screen.getByText('Draft order')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Оформить заказ' }))
expect(await screen.findByText('order-010')).toBeInTheDocument()
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:3001/api/v1/orders',
expect.objectContaining({ method: 'POST' })
)
})
})

View File

@@ -0,0 +1,46 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import { SignInScreen } from 'compositions/screens/sign-in'
import { StorefrontScreen } from 'compositions/screens/storefront'
import { OrdersProvider } from 'domains/orders'
import { useSessionState } from 'domains/session'
import styles from './styles/app.module.css'
/**
* Browser entry, выбирающий экран по session lifecycle.
*
* Используется для:
* - защиты storefront route пользовательской сессией
* - завершения bootstrap до первого route render
*/
export const App = () => {
const { status } = useSessionState()
if (status === 'restoring') {
return (
<main className={styles.loader} aria-live="polite">
<span className={styles.loaderMark}>S</span>
<strong>Восстанавливаем сессию</strong>
<small>Simple API · JWT rotation</small>
</main>
)
}
const isAuthenticated = status === 'authenticated'
const signInElement = isAuthenticated ? <Navigate to="/" replace /> : <SignInScreen />
const storefrontElement = isAuthenticated ? (
<OrdersProvider>
<StorefrontScreen />
</OrdersProvider>
) : (
<Navigate to="/login" replace />
)
return (
<Routes>
<Route path="/login" element={signInElement} />
<Route path="/" element={storefrontElement} />
<Route path="*" element={<Navigate to={isAuthenticated ? '/' : '/login'} replace />} />
</Routes>
)
}

View File

@@ -0,0 +1,47 @@
import { useState } from 'react'
import type { ReactNode } from 'react'
import { BrowserRouter } from 'react-router-dom'
import { SWRConfig } from 'swr'
import { SessionProvider } from 'domains/session'
/**
* Props глобальной provider-композиции browser-приложения.
*/
export type AppProvidersProps = {
/** Browser-приложение внутри общего cache и session scope. */
children: ReactNode
}
/**
* Собирает технические providers application scope.
*
* Используется для:
* - одного SWR cache на browser-приложение
* - одного session lifecycle и history router
*/
export const AppProviders = (props: AppProvidersProps) => {
const { children } = props
const [swrCache] = useState(() => new Map())
/**
* Удаляет server state предыдущего пользователя при закрытии сессии.
*/
const handleSessionClosed = (): void => {
swrCache.clear()
}
return (
<SWRConfig
value={{
provider: () => swrCache,
revalidateOnFocus: false,
shouldRetryOnError: false
}}
>
<SessionProvider onSessionClosed={handleSessionClosed}>
<BrowserRouter>{children}</BrowserRouter>
</SessionProvider>
</SWRConfig>
)
}

View File

@@ -0,0 +1,42 @@
.loader {
display: grid;
min-height: 100svh;
place-items: center;
align-content: center;
gap: 0.5rem;
color: var(--color-ink-muted);
}
.loaderMark {
display: grid;
width: 3.4rem;
height: 3.4rem;
margin-bottom: 0.75rem;
place-items: center;
border-radius: 1rem 1rem 1rem 0.25rem;
color: #fffaf0;
background: var(--color-accent-strong);
box-shadow: 0 14px 35px rgb(31 86 62 / 22%);
font-family: var(--font-display);
font-size: 1.7rem;
animation: breathe 1.4s ease-in-out infinite;
}
.loader strong {
color: var(--color-ink);
font-family: var(--font-display);
font-size: 1.3rem;
}
.loader small {
font-family: var(--font-mono);
font-size: 0.65rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
@keyframes breathe {
50% {
transform: translateY(-4px);
}
}

View File

@@ -0,0 +1,80 @@
:root {
--color-canvas: #f5f0e5;
--color-surface: #fffdf7;
--color-surface-strong: #ebe6da;
--color-ink: #192c23;
--color-ink-muted: #647069;
--color-ink-faint: #929a95;
--color-line: rgb(27 48 38 / 13%);
--color-line-strong: rgb(27 48 38 / 25%);
--color-accent: #e36f4f;
--color-accent-strong: #bd4e35;
--color-accent-light: #9ee0b9;
--color-accent-soft: #f7e4d9;
--color-danger: #aa3d32;
--color-danger-soft: #fae2de;
--font-body: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-display: Georgia, 'Times New Roman', serif;
--font-mono: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
--shadow-card: 0 12px 35px rgb(30 45 38 / 7%);
--shadow-card-hover: 0 20px 48px rgb(30 45 38 / 12%);
color: var(--color-ink);
background: var(--color-canvas);
font-family: var(--font-body);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
min-width: 320px;
min-height: 100svh;
margin: 0;
background:
radial-gradient(circle at 10% 10%, rgb(227 111 79 / 9%), transparent 24rem),
radial-gradient(circle at 90% 35%, rgb(78 148 107 / 8%), transparent 28rem),
var(--color-canvas);
}
button,
input,
select,
textarea {
font: inherit;
}
button,
a {
-webkit-tap-highlight-color: transparent;
}
img {
display: block;
max-width: 100%;
}
::selection {
color: #fffaf0;
background: var(--color-accent-strong);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

View File

@@ -0,0 +1,41 @@
import cl from 'clsx'
import { SessionBadge } from 'domains/session'
import type { AppShellLayoutProps } from './types/app-shell-layout-props.type'
import styles from './styles/app-shell.module.css'
/**
* Общий каркас авторизованного storefront.
*
* Используется для:
* - единой навигации между каталогом и историей заказов
* - отображения публичного session UI в header
*/
export const AppShellLayout = (props: AppShellLayoutProps) => {
const { children, className, ...rootAttrs } = props
return (
<div {...rootAttrs} className={cl(styles.root, className)}>
<header className={styles.header}>
<a className={styles.brand} href="#catalog" aria-label="SLM Store, к каталогу">
<span className={styles.brandMark}>S</span>
<span>
<strong>SLM Store</strong>
<small>simple contract</small>
</span>
</a>
<nav className={styles.nav} aria-label="Основная навигация">
<a href="#catalog">Каталог</a>
<a href="#orders">Заказы</a>
</nav>
<SessionBadge />
</header>
<div className={styles.content}>{children}</div>
<footer className={styles.footer}>
<span>React + Vite</span>
<span>Scoped Layered Module Design</span>
<span>Simple API · :3001</span>
</footer>
</div>
)
}

View File

@@ -0,0 +1,2 @@
export { AppShellLayout } from './app-shell.layout'
export type { AppShellLayoutProps } from './types/app-shell-layout-props.type'

View File

@@ -0,0 +1,134 @@
.root {
width: min(100%, 92rem);
min-height: 100svh;
margin: 0 auto;
padding: 0 1.2rem;
box-sizing: border-box;
}
.header {
position: sticky;
z-index: 20;
top: 0.7rem;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 1rem;
margin-top: 0.7rem;
padding: 0.68rem 0.78rem;
border: 1px solid rgb(26 43 35 / 10%);
border-radius: 1.1rem;
background: rgb(250 246 236 / 80%);
box-shadow: 0 12px 35px rgb(31 45 37 / 8%);
backdrop-filter: blur(20px);
}
.brand {
display: inline-flex;
align-items: center;
gap: 0.65rem;
justify-self: start;
color: var(--color-ink);
text-decoration: none;
}
.brandMark {
display: grid;
width: 2.35rem;
height: 2.35rem;
place-items: center;
border-radius: 0.75rem 0.75rem 0.75rem 0.2rem;
color: #fffaf0;
background: var(--color-accent-strong);
font-family: var(--font-display);
font-size: 1.25rem;
font-weight: 800;
}
.brand > span:last-child {
display: grid;
line-height: 1.1;
}
.brand strong {
font-size: 0.9rem;
}
.brand small {
margin-top: 0.22rem;
color: var(--color-ink-faint);
font-family: var(--font-mono);
font-size: 0.58rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.nav {
display: flex;
gap: 0.25rem;
padding: 0.25rem;
border-radius: 999px;
background: rgb(31 46 38 / 5%);
}
.nav a {
padding: 0.5rem 0.8rem;
border-radius: 999px;
color: var(--color-ink-muted);
font-size: 0.78rem;
font-weight: 750;
text-decoration: none;
}
.nav a:hover,
.nav a:focus-visible {
color: var(--color-ink);
background: var(--color-surface);
outline: none;
}
.header > :last-child {
justify-self: end;
}
.content {
padding: 1.2rem 0 3.5rem;
}
.footer {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 1.4rem 0 2rem;
border-top: 1px solid var(--color-line);
color: var(--color-ink-faint);
font-family: var(--font-mono);
font-size: 0.63rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
@media (max-width: 760px) {
.header {
grid-template-columns: 1fr auto;
}
.nav {
display: none;
}
.footer {
align-items: center;
flex-direction: column;
}
}
@media (max-width: 480px) {
.root {
padding: 0 0.75rem;
}
.brand small {
display: none;
}
}

View File

@@ -0,0 +1,12 @@
import type { ComponentPropsWithoutRef } from 'react'
/**
* Собственные параметры AppShellLayout.
*/
export type AppShellLayoutParams = object
/** Атрибуты корневого div. */
type RootAttrs = ComponentPropsWithoutRef<'div'>
/** Props общего каркаса авторизованного приложения. */
export type AppShellLayoutProps = RootAttrs & AppShellLayoutParams

View File

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

View File

@@ -0,0 +1,51 @@
import cl from 'clsx'
import { SignInForm } from 'domains/session'
import type { SignInScreenProps } from './types/sign-in-screen-props.type'
import styles from './styles/sign-in.module.css'
/**
* Публичный экран входа в демонстрационный Simple Store.
*
* Используется для:
* - выбора admin или customer demo-сессии
* - краткого объяснения архитектурного среза приложения
*/
export const SignInScreen = (props: SignInScreenProps) => {
const { className, ...rootAttrs } = props
return (
<main {...rootAttrs} className={cl(styles.root, className)}>
<section className={styles.story}>
<div className={styles.brand}>
<span>S</span>
<strong>SLM Store</strong>
</div>
<div className={styles.storyBody}>
<span className={styles.kicker}>Small API · complete boundaries</span>
<h1>Меньше кода.<br />Чётче владельцы.</h1>
<p>
Облегчённый storefront поверх JWT API: каталог, optimistic locking,
draft order и защищённая история.
</p>
</div>
<ol className={styles.layers} aria-label="SLM data flow">
<li><span>01</span> app · lifecycle</li>
<li><span>02</span> compositions · assembly</li>
<li><span>03</span> domains · product meaning</li>
<li><span>04</span> infra · OpenAPI transport</li>
</ol>
</section>
<section className={styles.access} aria-labelledby="sign-in-title">
<div className={styles.accessCard}>
<span className={styles.status}><i /> Simple API · localhost:3001</span>
<h2 id="sign-in-title">Войти в demo</h2>
<p>Выберите роль. Credentials уже заполнены и сбрасываются вместе с backend.</p>
<SignInForm />
</div>
<p className={styles.note}>Refresh token хранится только в sessionStorage текущей вкладки.</p>
</section>
</main>
)
}

View File

@@ -0,0 +1,221 @@
.root {
display: grid;
grid-template-columns: minmax(0, 1.08fr) minmax(25rem, 0.92fr);
min-height: 100svh;
}
.story {
position: relative;
display: flex;
overflow: hidden;
min-height: 100%;
padding: clamp(1.5rem, 4vw, 4.5rem);
box-sizing: border-box;
flex-direction: column;
justify-content: space-between;
color: #fdf8ed;
background:
linear-gradient(145deg, rgb(20 42 32 / 98%), rgb(31 71 53 / 94%)),
var(--color-ink);
}
.story::before,
.story::after {
position: absolute;
border: 1px solid rgb(255 255 255 / 12%);
border-radius: 50%;
content: '';
}
.story::before {
top: -15vw;
right: -9vw;
width: 40vw;
height: 40vw;
box-shadow:
0 0 0 5vw rgb(255 255 255 / 2%),
0 0 0 10vw rgb(255 255 255 / 2%);
}
.story::after {
right: 8%;
bottom: 18%;
width: 0.7rem;
height: 0.7rem;
border-color: var(--color-accent-light);
background: var(--color-accent-light);
box-shadow:
5rem -2rem 0 -0.16rem var(--color-accent-light),
-3rem 4rem 0 -0.25rem var(--color-accent-light);
}
.brand {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 0.75rem;
}
.brand > span {
display: grid;
width: 2.6rem;
height: 2.6rem;
place-items: center;
border: 1px solid rgb(255 255 255 / 28%);
border-radius: 0.85rem 0.85rem 0.85rem 0.2rem;
font-family: var(--font-display);
font-size: 1.35rem;
}
.brand strong {
font-size: 0.92rem;
letter-spacing: 0.04em;
}
.storyBody {
position: relative;
z-index: 1;
max-width: 43rem;
margin: clamp(4rem, 15vh, 10rem) 0;
}
.kicker {
color: var(--color-accent-light);
font-family: var(--font-mono);
font-size: 0.68rem;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.story h1 {
margin: 1rem 0;
font-family: var(--font-display);
font-size: clamp(3.3rem, 7.5vw, 7.2rem);
font-weight: 560;
letter-spacing: -0.065em;
line-height: 0.86;
}
.storyBody p {
max-width: 34rem;
margin: 1.6rem 0 0;
color: rgb(253 248 237 / 64%);
font-size: clamp(0.9rem, 1.4vw, 1.08rem);
line-height: 1.62;
}
.layers {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.45rem 1.5rem;
margin: 0;
padding: 1rem 0 0;
border-top: 1px solid rgb(255 255 255 / 13%);
color: rgb(253 248 237 / 58%);
font-family: var(--font-mono);
font-size: 0.67rem;
list-style: none;
}
.layers li {
display: flex;
gap: 0.55rem;
}
.layers span {
color: var(--color-accent-light);
}
.access {
display: grid;
place-items: center;
align-content: center;
gap: 1rem;
padding: clamp(1.2rem, 5vw, 5rem);
background:
radial-gradient(circle at 86% 12%, rgb(227 107 74 / 13%), transparent 28%),
var(--color-canvas);
}
.accessCard {
width: min(100%, 27rem);
padding: clamp(1.2rem, 4vw, 2rem);
border: 1px solid var(--color-line);
border-radius: 1.4rem;
background: rgb(255 255 255 / 63%);
box-shadow: 0 28px 75px rgb(26 43 35 / 10%);
backdrop-filter: blur(18px);
}
.status {
display: inline-flex;
align-items: center;
gap: 0.45rem;
color: var(--color-ink-muted);
font-family: var(--font-mono);
font-size: 0.64rem;
letter-spacing: 0.04em;
}
.status i {
width: 0.48rem;
height: 0.48rem;
border-radius: 50%;
background: #4caf79;
box-shadow: 0 0 0 0.23rem rgb(76 175 121 / 14%);
}
.accessCard h2 {
margin: 1rem 0 0.45rem;
color: var(--color-ink);
font-family: var(--font-display);
font-size: 2.45rem;
letter-spacing: -0.04em;
}
.accessCard > p {
margin: 0 0 1.35rem;
color: var(--color-ink-muted);
font-size: 0.82rem;
line-height: 1.52;
}
.note {
max-width: 24rem;
margin: 0;
color: var(--color-ink-faint);
font-size: 0.68rem;
line-height: 1.45;
text-align: center;
}
@media (max-width: 900px) {
.root {
grid-template-columns: 1fr;
}
.story {
min-height: 31rem;
}
.storyBody {
margin: 4rem 0;
}
.story h1 {
font-size: clamp(3rem, 11vw, 5.4rem);
}
.access {
min-height: 42rem;
}
}
@media (max-width: 500px) {
.layers {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,12 @@
import type { ComponentPropsWithoutRef } from 'react'
/**
* Собственные параметры экрана SignIn.
*/
export type SignInScreenParams = object
/** Атрибуты корневого main без children. */
type RootAttrs = Omit<ComponentPropsWithoutRef<'main'>, 'children'>
/** Props публичного экрана входа. */
export type SignInScreenProps = RootAttrs & SignInScreenParams

View File

@@ -0,0 +1,2 @@
export { StorefrontScreen } from './storefront.screen'
export type { StorefrontScreenProps } from './types/storefront-screen-props.type'

View File

@@ -0,0 +1,49 @@
import cl from 'clsx'
import { CatalogPanel } from 'domains/catalog'
import { CartPanel, OrderHistory, useAddProductToOrder } from 'domains/orders'
import { useSessionState } from 'domains/session'
import { AppShellLayout } from 'compositions/layouts/app-shell'
import type { StorefrontScreenProps } from './types/storefront-screen-props.type'
import styles from './styles/storefront.module.css'
/**
* Авторизованный storefront, координирующий независимые доменные UI.
*
* Используется для:
* - передачи product snapshot из catalog в draft order
* - совместного отображения каталога, корзины и истории
*/
export const StorefrontScreen = (props: StorefrontScreenProps) => {
const { className, ...rootAttrs } = props
const { user } = useSessionState()
const addProduct = useAddProductToOrder()
const isAdmin = user?.role === 'admin'
return (
<AppShellLayout>
<main {...rootAttrs} className={cl(styles.root, className)}>
<section className={styles.hero}>
<div>
<span className={styles.kicker}>One contract · three owners</span>
<h1>Simple Store,<br />без простой архитектуры.</h1>
</div>
<div className={styles.heroAside}>
<span className={styles.pulse}><i /> API online</span>
<p>
JWT refresh, RBAC, pagination, stock validation и optimistic locking
проходят через отдельные SLM boundaries.
</p>
</div>
</section>
<div id="catalog" className={styles.workspace}>
<CatalogPanel isAdmin={isAdmin} onAddProduct={addProduct} />
<CartPanel />
</div>
<OrderHistory id="orders" />
</main>
</AppShellLayout>
)
}

View File

@@ -0,0 +1,91 @@
.root {
display: grid;
gap: 2.8rem;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(17rem, 0.65fr);
align-items: end;
gap: 2rem;
min-height: 22rem;
padding: clamp(2rem, 6vw, 5.5rem) clamp(1rem, 4vw, 3.2rem);
border-radius: 1.5rem;
color: #fffaf0;
background:
radial-gradient(circle at 86% 18%, rgb(235 127 88 / 36%), transparent 24%),
linear-gradient(125deg, #18372a 0%, #265540 58%, #694d35 145%);
box-shadow: 0 28px 70px rgb(24 55 42 / 15%);
}
.kicker {
color: var(--color-accent-light);
font-family: var(--font-mono);
font-size: 0.66rem;
letter-spacing: 0.13em;
text-transform: uppercase;
}
.hero h1 {
margin: 0.9rem 0 0;
font-family: var(--font-display);
font-size: clamp(3rem, 6.5vw, 6.4rem);
font-weight: 560;
letter-spacing: -0.065em;
line-height: 0.88;
}
.heroAside {
display: grid;
gap: 1rem;
padding: 1.1rem;
border: 1px solid rgb(255 255 255 / 14%);
border-radius: 1rem;
background: rgb(255 255 255 / 6%);
backdrop-filter: blur(14px);
}
.heroAside p {
margin: 0;
color: rgb(255 250 240 / 66%);
font-size: 0.8rem;
line-height: 1.55;
}
.pulse {
display: flex;
align-items: center;
gap: 0.48rem;
font-family: var(--font-mono);
font-size: 0.66rem;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.pulse i {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--color-accent-light);
box-shadow: 0 0 0 0.3rem rgb(151 219 180 / 12%);
}
.workspace {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(18rem, 22rem);
gap: 1rem;
scroll-margin-top: 6.5rem;
}
@media (max-width: 980px) {
.workspace {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.hero {
grid-template-columns: 1fr;
min-height: 25rem;
}
}

View File

@@ -0,0 +1,12 @@
import type { ComponentPropsWithoutRef } from 'react'
/**
* Собственные параметры экрана Storefront.
*/
export type StorefrontScreenParams = object
/** Атрибуты корневого main без children. */
type RootAttrs = Omit<ComponentPropsWithoutRef<'main'>, 'children'>
/** Props авторизованного storefront screen. */
export type StorefrontScreenProps = RootAttrs & StorefrontScreenParams

View File

@@ -0,0 +1,23 @@
/** Ожидаемый неуспешный исход каталога. */
export type CatalogErrorCode =
| 'forbidden'
| 'not-found'
| 'version-conflict'
| 'invalid-input'
| 'rate-limited'
| 'invalid-data'
| 'unavailable'
/**
* Доменная ошибка чтения или изменения каталога.
*/
export class CatalogError extends Error {
/** Стабильный код ожидаемого исхода. */
readonly code: CatalogErrorCode
constructor(code: CatalogErrorCode, message: string) {
super(message)
this.name = 'CatalogError'
this.code = code
}
}

View File

@@ -0,0 +1,112 @@
import {
useGetCategoryList,
useGetProductList
} from 'infra/simple-rest-api'
import { CatalogError } from '../errors/catalog.error'
import type { CatalogCategory } from '../types/catalog-category.type'
import type { CatalogFilters } from '../types/catalog-filters.type'
import type { CatalogPage } from '../types/catalog-page.type'
import { mapCatalogError } from '../source/map-catalog-error'
import {
catalogCategoriesSchema,
catalogPageSchema
} from '../source/catalog.schemas'
/**
* Результат чтения каталога для domain UI.
*/
export type CatalogQuery = {
/** Валидированная страница продуктов или null до первого ответа. */
page: CatalogPage | null
/** Валидированные категории. */
categories: CatalogCategory[]
/** Выполняется ли первый запрос. */
isLoading: boolean
/** Ожидаемая ошибка чтения или null. */
error: CatalogError | null
/** Повторно получает продукты и категории. */
refresh: () => Promise<void>
}
/**
* Проверяет product response и отделяет wire envelope от доменной страницы.
*/
const parseCatalogPage = (response: unknown): CatalogPage => {
const parsedResponse = catalogPageSchema.safeParse(response)
if (!parsedResponse.success) {
throw new CatalogError('invalid-data', 'Simple API вернул каталог неизвестного формата.')
}
return {
products: parsedResponse.data.data,
...parsedResponse.data.meta
}
}
/**
* Проверяет category response до передачи данных domain UI.
*/
const parseCategories = (response: unknown): CatalogCategory[] => {
const parsedResponse = catalogCategoriesSchema.safeParse(response)
if (!parsedResponse.success) {
throw new CatalogError('invalid-data', 'Simple API вернул категории неизвестного формата.')
}
return parsedResponse.data.data
}
/**
* Повторно запускает оба GET-запроса каталога.
*/
const refreshCatalogQueries = async (
mutateProducts: () => Promise<unknown>,
mutateCategories: () => Promise<unknown>
): Promise<void> => {
await Promise.all([mutateProducts(), mutateCategories()])
}
/**
* Предоставляет domain UI валидированные продукты, категории и pagination.
*/
export const useCatalog = (filters: CatalogFilters): CatalogQuery => {
const productQuery = useGetProductList(filters)
const categoryQuery = useGetCategoryList()
let page: CatalogPage | null = null
let categories: CatalogCategory[] = []
let error: CatalogError | null = null
try {
if (productQuery.data) {
page = parseCatalogPage(productQuery.data)
}
if (categoryQuery.data) {
categories = parseCategories(categoryQuery.data)
}
} catch (parseError) {
error = mapCatalogError(parseError)
}
const queryError = productQuery.error ?? categoryQuery.error
if (queryError) {
error = mapCatalogError(queryError)
}
/**
* Обновляет все source-данные каталога после mutation.
*/
const refresh = async (): Promise<void> => {
await refreshCatalogQueries(productQuery.mutate, categoryQuery.mutate)
}
return {
page,
categories,
isLoading: !productQuery.data && !productQuery.error,
error,
refresh
}
}

View File

@@ -0,0 +1,3 @@
export { CatalogPanel } from './ui/catalog-panel'
export type { CatalogPanelProps } from './ui/catalog-panel'
export type { CatalogCurrency, CatalogProduct } from './types/catalog-product.type'

View File

@@ -0,0 +1,39 @@
import { z } from 'zod'
export const catalogProductSchema = z.object({
id: z.string(),
name: z.string(),
slug: z.string(),
description: z.string(),
priceCents: z.number().nonnegative(),
currency: z.enum(['USD', 'EUR']),
categoryId: z.string(),
stock: z.number().int().nonnegative(),
rating: z.number().min(0).max(5),
imageUrl: z.url(),
createdAt: z.iso.datetime(),
version: z.number().int().positive()
})
export const catalogCategorySchema = z.object({
id: z.string(),
name: z.string(),
slug: z.string(),
productCount: z.number().int().nonnegative()
})
export const catalogPageSchema = z.object({
data: z.array(catalogProductSchema),
meta: z.object({
page: z.number().int().positive(),
limit: z.number().int().positive(),
total: z.number().int().nonnegative(),
totalPages: z.number().int().nonnegative()
})
})
export const catalogCategoriesSchema = z.object({
data: z.array(catalogCategorySchema)
})
export const catalogProductResponseSchema = z.object({ data: catalogProductSchema })

View File

@@ -0,0 +1,69 @@
import {
simpleRestApi,
toSimpleRestApiError
} from 'infra/simple-rest-api'
import { CatalogError } from '../errors/catalog.error'
import type {
CatalogProduct,
CreateCatalogProduct,
UpdateCatalogProduct
} from '../types/catalog-product.type'
import { mapCatalogError } from './map-catalog-error'
import { catalogProductResponseSchema } from './catalog.schemas'
/**
* Проверяет и адаптирует одиночный product response.
*/
const parseProductResponse = (response: unknown): CatalogProduct => {
const parsedResponse = catalogProductResponseSchema.safeParse(response)
if (!parsedResponse.success) {
throw new CatalogError('invalid-data', 'Simple API вернул продукт неизвестного формата.')
}
return parsedResponse.data.data
}
/**
* Создаёт продукт от имени администратора.
*/
export const createCatalogProduct = async (
product: CreateCatalogProduct
): Promise<CatalogProduct> => {
try {
const response = await simpleRestApi.products.simpleProductsCreate(product)
return parseProductResponse(response)
} catch (error) {
throw mapCatalogError(error)
}
}
/**
* Обновляет продукт с optimistic locking по последней прочитанной версии.
*/
export const updateCatalogProduct = async (
productId: string,
product: UpdateCatalogProduct
): Promise<CatalogProduct> => {
try {
const response = await simpleRestApi.products.simpleProductsUpdate(
{ id: productId },
product
)
return parseProductResponse(response)
} catch (error) {
throw mapCatalogError(error)
}
}
/**
* Удаляет продукт от имени администратора.
*/
export const deleteCatalogProduct = async (productId: string): Promise<void> => {
try {
await simpleRestApi.products.simpleProductsRemove({ id: productId })
} catch (error) {
const apiError = toSimpleRestApiError(error)
throw mapCatalogError(apiError)
}
}

View File

@@ -0,0 +1,21 @@
import { describe, expect, it, vi } from 'vitest'
import { mapCatalogError } from './map-catalog-error'
vi.mock('infra/simple-rest-api', () => ({
toSimpleRestApiError: () => ({
status: 409,
code: 'PRODUCT_VERSION_CONFLICT',
message: 'Source-specific message',
requestId: 'req-test'
})
}))
describe('mapCatalogError', () => {
it('turns a source optimistic-lock failure into a catalog outcome', () => {
const catalogError = mapCatalogError(new Error('transport failure'))
expect(catalogError.code).toBe('version-conflict')
expect(catalogError.message).not.toContain('Source-specific')
})
})

View File

@@ -0,0 +1,38 @@
import { toSimpleRestApiError } from 'infra/simple-rest-api'
import { CatalogError } from '../errors/catalog.error'
/**
* Преобразует REST failure в ожидаемый исход каталога.
*/
export const mapCatalogError = (error: unknown): CatalogError => {
if (error instanceof CatalogError) {
return error
}
const apiError = toSimpleRestApiError(error)
if (apiError.status === 403) {
return new CatalogError('forbidden', 'Управление каталогом доступно только администратору.')
}
if (apiError.status === 404) {
return new CatalogError('not-found', 'Продукт больше не существует.')
}
if (apiError.code === 'PRODUCT_VERSION_CONFLICT') {
return new CatalogError(
'version-conflict',
'Продукт уже изменён. Каталог обновлён, повторите редактирование.'
)
}
if (apiError.status === 400 || apiError.status === 422) {
return new CatalogError('invalid-input', 'Проверьте поля продукта и выбранную категорию.')
}
if (apiError.status === 429) {
return new CatalogError('rate-limited', 'Simple API ограничил частоту запросов.')
}
return new CatalogError('unavailable', 'Не удалось получить каталог из Simple API.')
}

View File

@@ -0,0 +1,13 @@
/**
* Категория продуктового каталога.
*/
export type CatalogCategory = {
/** Стабильный идентификатор категории. */
id: string
/** Отображаемое название. */
name: string
/** URL-safe имя категории. */
slug: string
/** Число продуктов в категории. */
productCount: number
}

View File

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

View File

@@ -0,0 +1,17 @@
import type { CatalogProduct } from './catalog-product.type'
/**
* Страница продуктов и её pagination metadata.
*/
export type CatalogPage = {
/** Продукты текущей страницы. */
products: CatalogProduct[]
/** Номер текущей страницы. */
page: number
/** Лимит элементов страницы. */
limit: number
/** Общее число продуктов после фильтрации. */
total: number
/** Общее число страниц. */
totalPages: number
}

View File

@@ -0,0 +1,60 @@
/** Валюта продукта в Simple Store. */
export type CatalogCurrency = 'USD' | 'EUR'
/**
* Продукт каталога с данными, необходимыми для покупки и управления.
*/
export type CatalogProduct = {
/** Стабильный идентификатор продукта. */
id: string
/** Отображаемое название. */
name: string
/** URL-safe имя продукта. */
slug: string
/** Пользовательское описание. */
description: string
/** Цена в минимальных единицах валюты. */
priceCents: number
/** Валюта цены. */
currency: CatalogCurrency
/** Идентификатор категории. */
categoryId: string
/** Доступный остаток. */
stock: number
/** Средняя оценка от нуля до пяти. */
rating: number
/** URL изображения продукта. */
imageUrl: string
/** ISO-дата создания. */
createdAt: string
/** Версия для optimistic locking. */
version: number
}
/**
* Данные администратора для создания продукта.
*/
export type CreateCatalogProduct = {
/** Название длиной от двух символов. */
name: string
/** Описание длиной от десяти символов. */
description: string
/** Цена в минимальных единицах валюты. */
priceCents: number
/** Валюта цены. */
currency: CatalogCurrency
/** Идентификатор существующей категории. */
categoryId: string
/** Начальный доступный остаток. */
stock: number
/** HTTPS URL изображения на picsum.photos. */
imageUrl: string
}
/**
* Изменяемые данные продукта вместе с прочитанной версией.
*/
export type UpdateCatalogProduct = CreateCatalogProduct & {
/** Версия, прочитанная перед редактированием. */
version: number
}

View File

@@ -0,0 +1,294 @@
import { useDeferredValue, useState } from 'react'
import cl from 'clsx'
import { formatCurrency } from 'shared/lib/format'
import { isEmptyArray, isNonEmptyArray } from 'shared/lib/value-predicates'
import { Button } from 'ui/button'
import { CatalogError } from '../../errors/catalog.error'
import { useCatalog } from '../../hooks/use-catalog.hook'
import {
createCatalogProduct,
deleteCatalogProduct,
updateCatalogProduct
} from '../../source/catalog.source'
import type { CatalogProduct, CreateCatalogProduct } from '../../types/catalog-product.type'
import type { CatalogSort } from '../../types/catalog-filters.type'
import { ProductForm } from '../product-form/product-form'
import type { CatalogPanelProps } from './types/catalog-panel-props.type'
import styles from './styles/catalog-panel.module.css'
/**
* Каталог продуктов с поиском, покупкой и административными mutations.
*
* Используется для:
* - выбора актуального product snapshot для draft order
* - управления продуктами пользователем с ролью admin
*/
export const CatalogPanel = (props: CatalogPanelProps) => {
const { isAdmin, onAddProduct, className, ...rootAttrs } = props
const [search, setSearch] = useState('')
const deferredSearch = useDeferredValue(search)
const [categoryId, setCategoryId] = useState('')
const [sort, setSort] = useState<CatalogSort>('newest')
const [pageNumber, setPageNumber] = useState(1)
const [editorProduct, setEditorProduct] = useState<CatalogProduct | null | undefined>(undefined)
const [mutationError, setMutationError] = useState<string | null>(null)
const [isMutating, setIsMutating] = useState(false)
const { page, categories, isLoading, error, refresh } = useCatalog({
page: pageNumber,
limit: 6,
search: deferredSearch || undefined,
categoryId: categoryId || undefined,
sort
})
/**
* Создаёт или обновляет продукт в зависимости от открытого editor state.
*/
const handleProductSubmit = async (input: CreateCatalogProduct): Promise<void> => {
setMutationError(null)
setIsMutating(true)
try {
if (editorProduct) {
await updateCatalogProduct(editorProduct.id, {
...input,
version: editorProduct.version
})
} else {
await createCatalogProduct(input)
}
setEditorProduct(undefined)
await refresh()
} catch (mutationFailure) {
const message =
mutationFailure instanceof CatalogError
? mutationFailure.message
: 'Не удалось изменить продукт.'
setMutationError(message)
if (mutationFailure instanceof CatalogError && mutationFailure.code === 'version-conflict') {
await refresh()
}
} finally {
setIsMutating(false)
}
}
/**
* Подтверждает удаление и обновляет текущую страницу каталога.
*/
const handleDelete = async (product: CatalogProduct): Promise<void> => {
const shouldDelete = window.confirm(`Удалить «${product.name}» из каталога?`)
if (!shouldDelete) {
return
}
setMutationError(null)
setIsMutating(true)
try {
await deleteCatalogProduct(product.id)
await refresh()
} catch (mutationFailure) {
const message =
mutationFailure instanceof CatalogError
? mutationFailure.message
: 'Не удалось удалить продукт.'
setMutationError(message)
} finally {
setIsMutating(false)
}
}
let productsContent = (
<div className={styles.state} aria-live="polite">
Загружаем актуальный каталог
</div>
)
if (error) {
productsContent = (
<div className={styles.state} role="alert">
<strong>Каталог недоступен</strong>
<span>{error.message}</span>
<Button variant="secondary" size="small" onClick={() => void refresh()}>
Повторить
</Button>
</div>
)
}
if (!isLoading && page && isEmptyArray(page.products)) {
productsContent = (
<div className={styles.state}>
<strong>Ничего не найдено</strong>
<span>Измените запрос или категорию.</span>
</div>
)
}
if (page && isNonEmptyArray(page.products)) {
productsContent = (
<div className={styles.grid}>
{page.products.map((product) => {
const category = categories.find((item) => item.id === product.categoryId)
const isOutOfStock = product.stock === 0
const addLabel = isOutOfStock ? 'Нет в наличии' : 'В заказ'
return (
<article key={product.id} className={styles.card}>
<div className={styles.imageFrame}>
<img src={product.imageUrl} alt="" loading="lazy" />
<span className={styles.rating}> {product.rating.toFixed(1)}</span>
</div>
<div className={styles.cardBody}>
<div className={styles.cardMeta}>
<span>{category?.name ?? 'Без категории'}</span>
<span>{product.stock} шт.</span>
</div>
<h3>{product.name}</h3>
<p>{product.description}</p>
<div className={styles.cardFooter}>
<strong>{formatCurrency(product.priceCents, product.currency)}</strong>
<Button
size="small"
disabled={isOutOfStock}
onClick={() => onAddProduct(product)}
>
{addLabel}
</Button>
</div>
{isAdmin && (
<div className={styles.adminActions}>
<Button variant="ghost" size="small" onClick={() => setEditorProduct(product)}>
Изменить v{product.version}
</Button>
<Button
variant="danger"
size="small"
disabled={isMutating}
onClick={() => void handleDelete(product)}
>
Удалить
</Button>
</div>
)}
</div>
</article>
)
})}
</div>
)
}
const hasPreviousPage = Boolean(page && page.page > 1)
const hasNextPage = Boolean(page && page.page < page.totalPages)
return (
<section {...rootAttrs} className={cl(styles.root, className)}>
<div className={styles.heading}>
<div>
<span className={styles.eyebrow}>Deterministic catalog</span>
<h2>Рабочее место с товарами</h2>
<p>Фильтры читают Simple API, корзина фиксирует версию и цену перед checkout.</p>
</div>
{isAdmin && (
<Button variant="secondary" onClick={() => setEditorProduct(null)}>
+ Новый продукт
</Button>
)}
</div>
<div className={styles.filters}>
<label className={styles.searchField}>
<span>Поиск</span>
<input
type="search"
value={search}
placeholder="Клавиатура, книга…"
onChange={(event) => {
setSearch(event.currentTarget.value)
setPageNumber(1)
}}
/>
</label>
<label className={styles.selectField}>
<span>Категория</span>
<select
value={categoryId}
onChange={(event) => {
setCategoryId(event.currentTarget.value)
setPageNumber(1)
}}
>
<option value="">Все категории</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name} · {category.productCount}
</option>
))}
</select>
</label>
<label className={styles.selectField}>
<span>Сортировка</span>
<select
value={sort}
onChange={(event) => {
setSort(event.currentTarget.value as CatalogSort)
setPageNumber(1)
}}
>
<option value="newest">Сначала новые</option>
<option value="price-asc">Цена по возрастанию</option>
<option value="price-desc">Цена по убыванию</option>
<option value="name">По названию</option>
</select>
</label>
</div>
{editorProduct !== undefined && (
<ProductForm
key={editorProduct?.id ?? 'new-product'}
product={editorProduct}
categories={categories}
errorMessage={mutationError}
isSubmitting={isMutating}
onCancel={() => {
setEditorProduct(undefined)
setMutationError(null)
}}
onSubmit={handleProductSubmit}
/>
)}
{productsContent}
{page && page.totalPages > 1 && (
<div className={styles.pagination}>
<Button
variant="secondary"
size="small"
disabled={!hasPreviousPage}
onClick={() => setPageNumber((currentPage) => currentPage - 1)}
>
Назад
</Button>
<span>
{page.page} / {page.totalPages} · {page.total} товаров
</span>
<Button
variant="secondary"
size="small"
disabled={!hasNextPage}
onClick={() => setPageNumber((currentPage) => currentPage + 1)}
>
Далее
</Button>
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,2 @@
export { CatalogPanel } from './catalog-panel'
export type { CatalogPanelProps } from './types/catalog-panel-props.type'

View File

@@ -0,0 +1,235 @@
.root {
display: grid;
gap: 1.4rem;
}
.heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 1rem;
}
.heading h2 {
margin: 0.22rem 0 0;
color: var(--color-ink);
font-family: var(--font-display);
line-height: 1.05;
}
.heading h2 {
font-size: clamp(1.75rem, 4vw, 2.65rem);
letter-spacing: -0.04em;
}
.heading p {
max-width: 42rem;
margin: 0.65rem 0 0;
color: var(--color-ink-muted);
line-height: 1.55;
}
.eyebrow {
color: var(--color-accent-strong);
font-family: var(--font-mono);
font-size: 0.69rem;
font-weight: 800;
letter-spacing: 0.13em;
text-transform: uppercase;
}
.filters {
display: grid;
grid-template-columns: minmax(14rem, 1fr) repeat(2, minmax(10rem, 0.42fr));
gap: 0.75rem;
padding: 0.85rem;
border: 1px solid var(--color-line);
border-radius: 1rem;
background: rgb(255 255 255 / 58%);
backdrop-filter: blur(18px);
}
.searchField,
.selectField,
.textareaField {
display: grid;
gap: 0.38rem;
color: var(--color-ink);
font-size: 0.76rem;
font-weight: 750;
}
.searchField input,
.selectField select,
.textareaField textarea {
width: 100%;
min-height: 2.75rem;
box-sizing: border-box;
padding: 0.68rem 0.8rem;
border: 1px solid var(--color-line);
border-radius: 0.72rem;
color: var(--color-ink);
background: rgb(255 255 255 / 76%);
font: inherit;
outline: none;
}
.searchField input:focus,
.selectField select:focus,
.textareaField textarea:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 16%, transparent);
}
.grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
.card {
overflow: hidden;
border: 1px solid var(--color-line);
border-radius: 1.2rem;
background: var(--color-surface);
box-shadow: var(--shadow-card);
transition:
transform 180ms ease,
box-shadow 180ms ease;
}
.card:hover {
transform: translateY(-3px);
box-shadow: var(--shadow-card-hover);
}
.imageFrame {
position: relative;
aspect-ratio: 4 / 2.75;
overflow: hidden;
background: var(--color-surface-strong);
}
.imageFrame img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 350ms ease;
}
.card:hover .imageFrame img {
transform: scale(1.035);
}
.rating {
position: absolute;
right: 0.7rem;
bottom: 0.7rem;
padding: 0.36rem 0.58rem;
border-radius: 999px;
color: var(--color-ink);
background: rgb(255 250 240 / 88%);
font-size: 0.72rem;
font-weight: 800;
backdrop-filter: blur(12px);
}
.cardBody {
display: grid;
gap: 0.8rem;
padding: 1rem;
}
.cardMeta,
.cardFooter,
.adminActions,
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
}
.cardMeta {
color: var(--color-ink-faint);
font-family: var(--font-mono);
font-size: 0.66rem;
text-transform: uppercase;
}
.card h3 {
margin: 0;
color: var(--color-ink);
font-family: var(--font-display);
font-size: 1.3rem;
line-height: 1.1;
}
.card p {
min-height: 3.8rem;
margin: 0;
color: var(--color-ink-muted);
font-size: 0.82rem;
line-height: 1.52;
}
.cardFooter strong {
color: var(--color-ink);
font-size: 1.04rem;
}
.adminActions {
padding-top: 0.75rem;
border-top: 1px dashed var(--color-line);
}
.state {
display: grid;
place-items: center;
gap: 0.55rem;
min-height: 14rem;
padding: 2rem;
border: 1px dashed var(--color-line-strong);
border-radius: 1.2rem;
color: var(--color-ink-muted);
text-align: center;
}
.state strong {
color: var(--color-ink);
font-family: var(--font-display);
font-size: 1.35rem;
}
.pagination {
justify-content: center;
color: var(--color-ink-muted);
font-family: var(--font-mono);
font-size: 0.72rem;
}
@media (max-width: 940px) {
.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.filters {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.heading {
align-items: stretch;
flex-direction: column;
}
.grid,
.filters {
grid-template-columns: 1fr;
}
.pagination {
justify-content: space-between;
}
}

View File

@@ -0,0 +1,19 @@
import type { ComponentPropsWithoutRef } from 'react'
import type { CatalogProduct } from '../../../types/catalog-product.type'
/**
* Собственные параметры CatalogPanel.
*/
export type CatalogPanelParams = {
/** Разрешает административные mutations каталога. */
isAdmin: boolean
/** Передаёт выбранный продукт владельцу draft order. */
onAddProduct: (product: CatalogProduct) => void
}
/** Атрибуты корневой section без children. */
type RootAttrs = Omit<ComponentPropsWithoutRef<'section'>, 'children'>
/** Props интерактивной панели каталога. */
export type CatalogPanelProps = RootAttrs & CatalogPanelParams

View File

@@ -0,0 +1,147 @@
import { useState } from 'react'
import type { FormEvent } from 'react'
import { Button } from 'ui/button'
import { Field } from 'ui/field'
import type { CatalogCurrency } from '../../types/catalog-product.type'
import type { ProductFormProps } from './types/product-form-props.type'
import styles from './styles/product-form.module.css'
/**
* Внутренняя форма административного product mutation.
*
* Используется для:
* - создания продукта в выбранной категории
* - редактирования последней прочитанной версии продукта
*/
export const ProductForm = (props: ProductFormProps) => {
const { product, categories, errorMessage, isSubmitting, onCancel, onSubmit } = props
const [name, setName] = useState(product?.name ?? '')
const [description, setDescription] = useState(product?.description ?? '')
const [price, setPrice] = useState(product ? String(product.priceCents / 100) : '')
const [currency, setCurrency] = useState<CatalogCurrency>(product?.currency ?? 'USD')
const [categoryId, setCategoryId] = useState(product?.categoryId ?? categories[0]?.id ?? '')
const [stock, setStock] = useState(product ? String(product.stock) : '')
const [imageUrl, setImageUrl] = useState(
product?.imageUrl ?? 'https://picsum.photos/seed/new-product/640/480'
)
/**
* Нормализует значения HTML-формы в product contract.
*/
const handleSubmit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault()
await onSubmit({
name: name.trim(),
description: description.trim(),
priceCents: Math.round(Number(price) * 100),
currency,
categoryId,
stock: Number(stock),
imageUrl: imageUrl.trim()
})
}
const title = product ? `Редактирование · v${product.version}` : 'Новый продукт'
const submitLabel = product ? 'Сохранить версию' : 'Добавить продукт'
return (
<form className={styles.editor} onSubmit={handleSubmit}>
<div className={styles.editorHeader}>
<div>
<span className={styles.eyebrow}>Admin workspace</span>
<h3>{title}</h3>
</div>
<Button variant="ghost" size="small" onClick={onCancel}>
Закрыть
</Button>
</div>
<div className={styles.editorGrid}>
<Field
label="Название"
inputProps={{
value: name,
minLength: 2,
maxLength: 120,
onChange: (event) => setName(event.currentTarget.value),
required: true
}}
/>
<Field
label="Цена"
inputProps={{
value: price,
type: 'number',
min: 0,
step: '0.01',
onChange: (event) => setPrice(event.currentTarget.value),
required: true
}}
/>
<label className={styles.selectField}>
<span>Валюта</span>
<select value={currency} onChange={(event) => setCurrency(event.currentTarget.value as CatalogCurrency)}>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
</select>
</label>
<label className={styles.selectField}>
<span>Категория</span>
<select value={categoryId} onChange={(event) => setCategoryId(event.currentTarget.value)} required>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
</label>
<Field
label="Остаток"
inputProps={{
value: stock,
type: 'number',
min: 0,
step: 1,
onChange: (event) => setStock(event.currentTarget.value),
required: true
}}
/>
<Field
label="Изображение"
inputProps={{
value: imageUrl,
type: 'url',
pattern: 'https://picsum\\.photos/.*',
onChange: (event) => setImageUrl(event.currentTarget.value),
required: true
}}
/>
</div>
<label className={styles.textareaField}>
<span>Описание</span>
<textarea
value={description}
minLength={10}
maxLength={1000}
rows={3}
onChange={(event) => setDescription(event.currentTarget.value)}
required
/>
</label>
{errorMessage && (
<p className={styles.formError} role="alert">
{errorMessage}
</p>
)}
<div className={styles.editorActions}>
<Button type="submit" isLoading={isSubmitting}>
{submitLabel}
</Button>
</div>
</form>
)
}

View File

@@ -0,0 +1,97 @@
.editor {
display: grid;
gap: 1rem;
padding: 1.1rem;
border: 1px solid color-mix(in srgb, var(--color-accent) 32%, var(--color-line));
border-radius: 1.1rem;
background: var(--color-accent-soft);
}
.editorHeader,
.editorActions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
}
.editorHeader h3 {
margin: 0.22rem 0 0;
color: var(--color-ink);
font-family: var(--font-display);
line-height: 1.05;
}
.eyebrow {
color: var(--color-accent-strong);
font-family: var(--font-mono);
font-size: 0.69rem;
font-weight: 800;
letter-spacing: 0.13em;
text-transform: uppercase;
}
.editorGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.8rem;
}
.selectField,
.textareaField {
display: grid;
gap: 0.38rem;
color: var(--color-ink);
font-size: 0.76rem;
font-weight: 750;
}
.selectField select,
.textareaField textarea {
width: 100%;
min-height: 2.75rem;
box-sizing: border-box;
padding: 0.68rem 0.8rem;
border: 1px solid var(--color-line);
border-radius: 0.72rem;
color: var(--color-ink);
background: rgb(255 255 255 / 76%);
font: inherit;
outline: none;
}
.selectField select:focus,
.textareaField textarea:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 16%, transparent);
}
.textareaField textarea {
min-height: 6.5rem;
resize: vertical;
}
.editorActions {
justify-content: flex-end;
}
.formError {
margin: 0;
padding: 0.7rem;
border-radius: 0.7rem;
color: var(--color-danger);
background: var(--color-danger-soft);
font-size: 0.8rem;
}
@media (max-width: 940px) {
.editorGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.editorGrid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,23 @@
import type { CatalogCategory } from '../../../types/catalog-category.type'
import type {
CatalogProduct,
CreateCatalogProduct
} from '../../../types/catalog-product.type'
/**
* Props внутренней формы создания и редактирования продукта.
*/
export type ProductFormProps = {
/** Редактируемый продукт или null для создания. */
product: CatalogProduct | null
/** Доступные категории каталога. */
categories: CatalogCategory[]
/** Ожидаемая ошибка последней mutation. */
errorMessage: string | null
/** Выполняется ли mutation. */
isSubmitting: boolean
/** Отменяет редактирование без mutation. */
onCancel: () => void
/** Передаёт проверенные значения владельцу mutation. */
onSubmit: (product: CreateCatalogProduct) => Promise<void>
}

View File

@@ -0,0 +1,6 @@
import { createContext } from 'react'
import type { OrdersContextValue } from '../types/orders-context-value.type'
/** React-контекст draft order в storefront scope. */
export const OrdersContext = createContext<OrdersContextValue | null>(null)

View File

@@ -0,0 +1,25 @@
/** Ожидаемый неуспешный исход заказа. */
export type OrderErrorCode =
| 'product-changed'
| 'insufficient-stock'
| 'unsupported-currency'
| 'cannot-cancel'
| 'not-found'
| 'invalid-order'
| 'rate-limited'
| 'invalid-data'
| 'unavailable'
/**
* Доменная ошибка draft checkout или истории заказов.
*/
export class OrderError extends Error {
/** Стабильный код ожидаемого исхода. */
readonly code: OrderErrorCode
constructor(code: OrderErrorCode, message: string) {
super(message)
this.name = 'OrderError'
this.code = code
}
}

View File

@@ -0,0 +1,9 @@
import { useOrders } from './use-orders.hook'
import type { DraftOrderProduct } from '../types/draft-order.type'
/**
* Возвращает минимальную capability добавления product snapshot в draft order.
*/
export const useAddProductToOrder = (): ((product: DraftOrderProduct) => void) => {
return useOrders().addProduct
}

View File

@@ -0,0 +1,77 @@
import { useGetOrderList } from 'infra/simple-rest-api'
import { OrderError } from '../errors/order.error'
import { mapOrderError } from '../source/map-order-error'
import { orderPageSchema } from '../source/order.schemas'
import type { OrderPage } from '../types/order-page.type'
/**
* Результат чтения истории заказов.
*/
export type OrderHistoryQuery = {
/** Валидированная страница заказов или null до ответа. */
page: OrderPage | null
/** Выполняется ли первый запрос. */
isLoading: boolean
/** Ожидаемая ошибка чтения или null. */
error: OrderError | null
/** Повторно получает текущую страницу. */
refresh: () => Promise<void>
}
/**
* Проверяет wire response истории заказов.
*/
const parseOrderPage = (response: unknown): OrderPage => {
const parsedResponse = orderPageSchema.safeParse(response)
if (!parsedResponse.success) {
throw new OrderError('invalid-data', 'Simple API вернул историю неизвестного формата.')
}
return {
orders: parsedResponse.data.data,
...parsedResponse.data.meta
}
}
/**
* Повторно запускает GET текущей страницы заказов.
*/
const refreshOrderQuery = async (mutateOrders: () => Promise<unknown>): Promise<void> => {
await mutateOrders()
}
/**
* Предоставляет domain UI валидированную историю заказов.
*/
export const useOrderHistory = (): OrderHistoryQuery => {
const orderQuery = useGetOrderList({ page: 1, limit: 20 })
let page: OrderPage | null = null
let error: OrderError | null = null
try {
if (orderQuery.data) {
page = parseOrderPage(orderQuery.data)
}
} catch (parseError) {
error = mapOrderError(parseError)
}
if (orderQuery.error) {
error = mapOrderError(orderQuery.error)
}
/**
* Обновляет историю после checkout или cancel mutation.
*/
const refresh = async (): Promise<void> => {
await refreshOrderQuery(orderQuery.mutate)
}
return {
page,
isLoading: !orderQuery.data && !orderQuery.error,
error,
refresh
}
}

View File

@@ -0,0 +1,17 @@
import { use } from 'react'
import { OrdersContext } from '../context/orders.context'
import type { OrdersContextValue } from '../types/orders-context-value.type'
/**
* Возвращает публичные возможности draft order и checkout.
*/
export const useOrders = (): OrdersContextValue => {
const orders = use(OrdersContext)
if (!orders) {
throw new Error('useOrders must be used inside OrdersProvider')
}
return orders
}

View File

@@ -0,0 +1,9 @@
export { useAddProductToOrder } from './hooks/use-add-product-to-order.hook'
export { OrdersProvider } from './orders.provider'
export { CartPanel } from './ui/cart-panel'
export { OrderHistory } from './ui/order-history'
export type { CartPanelProps } from './ui/cart-panel'
export type { OrderHistoryProps } from './ui/order-history'
export type { DraftOrderProduct } from './types/draft-order.type'
export type { Order, OrderCurrency, OrderItem, OrderStatus } from './types/order.type'
export type { OrdersProviderProps } from './types/orders-provider-props.type'

View File

@@ -0,0 +1,176 @@
import { useState } from 'react'
import { useSWRConfig } from 'swr'
import { getOrderListKey } from 'infra/simple-rest-api'
import { isEmptyArray } from 'shared/lib/value-predicates'
import { OrderError } from './errors/order.error'
import { OrdersContext } from './context/orders.context'
import { createOrder } from './source/orders.source'
import type { DraftOrderItem, DraftOrderProduct } from './types/draft-order.type'
import type { Order } from './types/order.type'
import type { OrdersProviderProps } from './types/orders-provider-props.type'
const ORDER_LIMIT_PER_PRODUCT = 20
/**
* Владелец draft order и checkout lifecycle внутри storefront.
*
* Используется для:
* - фиксации product snapshots до подтверждения заказа
* - единственной координации локального draft и server mutation
*/
export const OrdersProvider = (props: OrdersProviderProps) => {
const { children } = props
const { mutate } = useSWRConfig()
const [items, setItems] = useState<DraftOrderItem[]>([])
const [notice, setNotice] = useState<string | null>(null)
const [createdOrder, setCreatedOrder] = useState<Order | null>(null)
const [isCheckingOut, setIsCheckingOut] = useState(false)
/**
* Добавляет USD product snapshot либо объясняет неподдерживаемый исход.
*/
const addProduct = (product: DraftOrderProduct): void => {
setNotice(null)
setCreatedOrder(null)
if (product.currency !== 'USD') {
setNotice('Simple API оформляет только продукты в USD.')
return
}
if (product.stock === 0) {
setNotice('Товар закончился на складе.')
return
}
setItems((currentItems) => {
const existingItem = currentItems.find((item) => item.productId === product.id)
if (!existingItem) {
return [
...currentItems,
{
productId: product.id,
productName: product.name,
quantity: 1,
unitPriceCents: product.priceCents,
currency: 'USD',
expectedVersion: product.version,
availableStock: product.stock,
imageUrl: product.imageUrl
}
]
}
const maxQuantity = Math.min(product.stock, ORDER_LIMIT_PER_PRODUCT)
if (existingItem.quantity >= maxQuantity) {
setNotice(`Для «${product.name}» достигнут доступный лимит.`)
return currentItems
}
return currentItems.map((item) => {
if (item.productId !== product.id) {
return item
}
return {
...item,
quantity: item.quantity + 1,
unitPriceCents: product.priceCents,
expectedVersion: product.version,
availableStock: product.stock
}
})
})
}
/**
* Изменяет количество строки в допустимом диапазоне.
*/
const setQuantity = (productId: string, quantity: number): void => {
setNotice(null)
setItems((currentItems) =>
currentItems.map((item) => {
if (item.productId !== productId) {
return item
}
const maxQuantity = Math.min(item.availableStock, ORDER_LIMIT_PER_PRODUCT)
const safeQuantity = Math.max(1, Math.min(quantity, maxQuantity))
return { ...item, quantity: safeQuantity }
})
)
}
/**
* Удаляет одну строку из draft order.
*/
const removeProduct = (productId: string): void => {
setNotice(null)
setItems((currentItems) => currentItems.filter((item) => item.productId !== productId))
}
/**
* Полностью очищает draft order и checkout feedback.
*/
const clearDraft = (): void => {
setItems([])
setNotice(null)
setCreatedOrder(null)
}
/**
* Подтверждает product snapshots на backend и создаёт заказ.
*/
const checkout = async (): Promise<void> => {
if (isEmptyArray(items)) {
setNotice('Добавьте хотя бы один продукт.')
return
}
setNotice(null)
setCreatedOrder(null)
setIsCheckingOut(true)
try {
const order = await createOrder(items)
setItems([])
setCreatedOrder(order)
await mutate(getOrderListKey({ page: 1, limit: 20 }))
} catch (error) {
const message = error instanceof OrderError ? error.message : 'Не удалось создать заказ.'
setNotice(message)
} finally {
setIsCheckingOut(false)
}
}
const itemCount = items.reduce((total, item) => total + item.quantity, 0)
const totalCents = items.reduce(
(total, item) => total + item.unitPriceCents * item.quantity,
0
)
return (
<OrdersContext
value={{
items,
itemCount,
totalCents,
notice,
createdOrder,
isCheckingOut,
addProduct,
setQuantity,
removeProduct,
clearDraft,
checkout
}}
>
{children}
</OrdersContext>
)
}

View File

@@ -0,0 +1,21 @@
import { describe, expect, it, vi } from 'vitest'
import { mapOrderError } from './map-order-error'
vi.mock('infra/simple-rest-api', () => ({
toSimpleRestApiError: () => ({
status: 422,
code: 'UNSUPPORTED_ORDER_CURRENCY',
message: 'Source-specific message',
requestId: 'req-test'
})
}))
describe('mapOrderError', () => {
it('turns a source currency failure into an orders outcome', () => {
const orderError = mapOrderError(new Error('transport failure'))
expect(orderError.code).toBe('unsupported-currency')
expect(orderError.message).not.toContain('Source-specific')
})
})

View File

@@ -0,0 +1,43 @@
import { toSimpleRestApiError } from 'infra/simple-rest-api'
import { OrderError } from '../errors/order.error'
/**
* Преобразует REST failure в ожидаемый исход домена заказов.
*/
export const mapOrderError = (error: unknown): OrderError => {
if (error instanceof OrderError) {
return error
}
const apiError = toSimpleRestApiError(error)
if (apiError.code === 'PRODUCT_CHANGED') {
return new OrderError('product-changed', 'Цена или версия продукта изменилась. Обновите каталог и корзину.')
}
if (apiError.code === 'INSUFFICIENT_STOCK') {
return new OrderError('insufficient-stock', 'Товара уже недостаточно на складе.')
}
if (apiError.code === 'UNSUPPORTED_ORDER_CURRENCY') {
return new OrderError('unsupported-currency', 'Checkout Simple API принимает только продукты в USD.')
}
if (apiError.code === 'ORDER_CANNOT_BE_CANCELLED') {
return new OrderError('cannot-cancel', 'Заказ в этом статусе уже нельзя отменить.')
}
if (apiError.status === 404) {
return new OrderError('not-found', 'Заказ или продукт больше не существует.')
}
if (apiError.status === 400 || apiError.status === 422) {
return new OrderError('invalid-order', 'Состав заказа не соответствует правилам checkout.')
}
if (apiError.status === 429) {
return new OrderError('rate-limited', 'Simple API ограничил частоту запросов.')
}
return new OrderError('unavailable', 'Не удалось выполнить операцию с заказом.')
}

View File

@@ -0,0 +1,30 @@
import { z } from 'zod'
export const orderSchema = z.object({
id: z.string(),
userId: z.string(),
status: z.enum(['pending', 'paid', 'shipped', 'cancelled']),
items: z.array(
z.object({
productId: z.string(),
productName: z.string(),
quantity: z.number().int().positive(),
unitPriceCents: z.number().int().nonnegative()
})
),
totalCents: z.number().int().nonnegative(),
currency: z.enum(['USD', 'EUR']),
createdAt: z.iso.datetime()
})
export const orderResponseSchema = z.object({ data: orderSchema })
export const orderPageSchema = z.object({
data: z.array(orderSchema),
meta: z.object({
page: z.number().int().positive(),
limit: z.number().int().positive(),
total: z.number().int().nonnegative(),
totalPages: z.number().int().nonnegative()
})
})

View File

@@ -0,0 +1,52 @@
import { simpleRestApi } from 'infra/simple-rest-api'
import { OrderError } from '../errors/order.error'
import type { DraftOrderItem } from '../types/draft-order.type'
import type { Order } from '../types/order.type'
import { mapOrderError } from './map-order-error'
import { orderResponseSchema } from './order.schemas'
/**
* Проверяет wire response одиночного заказа.
*/
const parseOrderResponse = (response: unknown): Order => {
const parsedResponse = orderResponseSchema.safeParse(response)
if (!parsedResponse.success) {
throw new OrderError('invalid-data', 'Simple API вернул заказ неизвестного формата.')
}
return parsedResponse.data.data
}
/**
* Создаёт заказ из зафиксированных строк draft order.
*/
export const createOrder = async (items: DraftOrderItem[]): Promise<Order> => {
const body = {
items: items.map((item) => ({
productId: item.productId,
quantity: item.quantity,
expectedVersion: item.expectedVersion,
expectedUnitPriceCents: item.unitPriceCents
}))
}
try {
const response = await simpleRestApi.orders.simpleOrdersCreate(body)
return parseOrderResponse(response)
} catch (error) {
throw mapOrderError(error)
}
}
/**
* Отменяет доступный пользователю заказ.
*/
export const cancelOrder = async (orderId: string): Promise<Order> => {
try {
const response = await simpleRestApi.orders.simpleOrdersCancel({ id: orderId })
return parseOrderResponse(response)
} catch (error) {
throw mapOrderError(error)
}
}

View File

@@ -0,0 +1,41 @@
/**
* Минимальный product snapshot, принимаемый владельцем draft order.
*/
export type DraftOrderProduct = {
/** Идентификатор продукта. */
id: string
/** Название продукта. */
name: string
/** Цена в минимальных единицах. */
priceCents: number
/** Валюта продукта. */
currency: 'USD' | 'EUR'
/** Версия product snapshot. */
version: number
/** Доступный остаток. */
stock: number
/** URL изображения. */
imageUrl: string
}
/**
* Строка draft order до checkout.
*/
export type DraftOrderItem = {
/** Идентификатор продукта. */
productId: string
/** Название продукта. */
productName: string
/** Выбранное количество. */
quantity: number
/** Цена из product snapshot. */
unitPriceCents: number
/** Валюта продукта. */
currency: 'USD'
/** Версия из product snapshot. */
expectedVersion: number
/** Остаток, ограничивающий количество. */
availableStock: number
/** URL изображения продукта. */
imageUrl: string
}

View File

@@ -0,0 +1,17 @@
import type { Order } from './order.type'
/**
* Страница доступных пользователю заказов.
*/
export type OrderPage = {
/** Заказы текущей страницы. */
orders: Order[]
/** Номер текущей страницы. */
page: number
/** Лимит страницы. */
limit: number
/** Общее число доступных заказов. */
total: number
/** Общее число страниц. */
totalPages: number
}

View File

@@ -0,0 +1,39 @@
/** Статус заказа Simple Store. */
export type OrderStatus = 'pending' | 'paid' | 'shipped' | 'cancelled'
/** Валюта заказа. */
export type OrderCurrency = 'USD' | 'EUR'
/**
* Зафиксированная строка оформленного заказа.
*/
export type OrderItem = {
/** Идентификатор купленного продукта. */
productId: string
/** Название продукта на момент оформления. */
productName: string
/** Купленное количество. */
quantity: number
/** Цена единицы на момент оформления. */
unitPriceCents: number
}
/**
* Заказ, доступный текущему пользователю.
*/
export type Order = {
/** Стабильный идентификатор заказа. */
id: string
/** Идентификатор владельца заказа. */
userId: string
/** Текущее состояние заказа. */
status: OrderStatus
/** Зафиксированные строки заказа. */
items: OrderItem[]
/** Итоговая стоимость в минимальных единицах. */
totalCents: number
/** Валюта всего заказа. */
currency: OrderCurrency
/** ISO-дата оформления. */
createdAt: string
}

View File

@@ -0,0 +1,30 @@
import type { DraftOrderItem, DraftOrderProduct } from './draft-order.type'
import type { Order } from './order.type'
/**
* Публичные возможности draft order и checkout.
*/
export type OrdersContextValue = {
/** Текущие строки draft order. */
items: DraftOrderItem[]
/** Общее количество единиц в draft order. */
itemCount: number
/** Итоговая стоимость draft order в USD cents. */
totalCents: number
/** Последнее пользовательское сообщение checkout. */
notice: string | null
/** Последний успешно созданный заказ. */
createdOrder: Order | null
/** Выполняется ли checkout. */
isCheckingOut: boolean
/** Добавляет актуальный product snapshot в draft order. */
addProduct: (product: DraftOrderProduct) => void
/** Изменяет количество строки с учётом stock и API-лимита. */
setQuantity: (productId: string, quantity: number) => void
/** Удаляет продукт из draft order. */
removeProduct: (productId: string) => void
/** Очищает draft order. */
clearDraft: () => void
/** Проверяет snapshot на backend и создаёт заказ. */
checkout: () => Promise<void>
}

View File

@@ -0,0 +1,9 @@
import type { ReactNode } from 'react'
/**
* Props владельца draft order.
*/
export type OrdersProviderProps = {
/** Storefront scope, внутри которого живёт draft order. */
children: ReactNode
}

View File

@@ -0,0 +1,119 @@
import cl from 'clsx'
import { formatCurrency } from 'shared/lib/format'
import { isEmptyArray, isNonEmptyArray } from 'shared/lib/value-predicates'
import { Button } from 'ui/button'
import { useOrders } from '../../hooks/use-orders.hook'
import type { CartPanelProps } from './types/cart-panel-props.type'
import styles from './styles/cart-panel.module.css'
/**
* Панель draft order с количеством и checkout.
*
* Используется для:
* - проверки выбранных product snapshots перед отправкой
* - запуска единственного checkout-сценария домена orders
*/
export const CartPanel = (props: CartPanelProps) => {
const { className, ...rootAttrs } = props
const {
items,
itemCount,
totalCents,
notice,
createdOrder,
isCheckingOut,
setQuantity,
removeProduct,
clearDraft,
checkout
} = useOrders()
return (
<aside {...rootAttrs} className={cl(styles.root, className)}>
<div className={styles.heading}>
<div>
<span className={styles.eyebrow}>Draft order</span>
<h2>Корзина</h2>
</div>
<span className={styles.count}>{itemCount}</span>
</div>
{isEmptyArray(items) && !createdOrder && (
<div className={styles.empty}>
<span className={styles.emptyMark}>+</span>
<p>Выберите продукты в каталоге. Версия и цена сохранятся до checkout.</p>
</div>
)}
{isNonEmptyArray(items) && (
<div className={styles.items}>
{items.map((item) => (
<article key={item.productId} className={styles.item}>
<img src={item.imageUrl} alt="" />
<div className={styles.itemInfo}>
<strong>{item.productName}</strong>
<span>
{formatCurrency(item.unitPriceCents, item.currency)} · v{item.expectedVersion}
</span>
</div>
<div className={styles.quantity}>
<button
type="button"
aria-label={`Уменьшить количество ${item.productName}`}
onClick={() => setQuantity(item.productId, item.quantity - 1)}
>
</button>
<span>{item.quantity}</span>
<button
type="button"
aria-label={`Увеличить количество ${item.productName}`}
onClick={() => setQuantity(item.productId, item.quantity + 1)}
>
+
</button>
</div>
<button
type="button"
className={styles.remove}
onClick={() => removeProduct(item.productId)}
>
Удалить
</button>
</article>
))}
</div>
)}
{notice && (
<p className={styles.notice} role="alert">
{notice}
</p>
)}
{createdOrder && (
<div className={styles.success} role="status">
<span>Заказ создан</span>
<strong>{createdOrder.id}</strong>
<small>{formatCurrency(createdOrder.totalCents, createdOrder.currency)}</small>
</div>
)}
{isNonEmptyArray(items) && (
<div className={styles.summary}>
<div>
<span>Итого</span>
<strong>{formatCurrency(totalCents, 'USD')}</strong>
</div>
<Button isLoading={isCheckingOut} onClick={() => void checkout()}>
Оформить заказ
</Button>
<Button variant="ghost" size="small" onClick={clearDraft}>
Очистить
</Button>
</div>
)}
</aside>
)
}

View File

@@ -0,0 +1,2 @@
export { CartPanel } from './cart-panel'
export type { CartPanelProps } from './types/cart-panel-props.type'

View File

@@ -0,0 +1,196 @@
.root {
position: sticky;
top: 5.8rem;
display: grid;
gap: 1rem;
align-self: start;
padding: 1rem;
border: 1px solid var(--color-line);
border-radius: 1.2rem;
background: var(--color-ink);
box-shadow: 0 24px 60px rgb(22 37 30 / 16%);
}
.heading,
.summary > div,
.item,
.quantity {
display: flex;
align-items: center;
}
.heading,
.summary > div {
justify-content: space-between;
}
.heading h2 {
margin: 0.15rem 0 0;
color: #fffaf0;
font-family: var(--font-display);
font-size: 1.7rem;
}
.eyebrow {
color: var(--color-accent-light);
font-family: var(--font-mono);
font-size: 0.63rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.count {
display: grid;
width: 2rem;
height: 2rem;
place-items: center;
border-radius: 50%;
color: var(--color-ink);
background: var(--color-accent-light);
font-weight: 850;
}
.empty {
display: grid;
place-items: center;
gap: 0.65rem;
min-height: 10rem;
padding: 1rem;
border: 1px dashed rgb(255 255 255 / 20%);
border-radius: 0.9rem;
color: rgb(255 250 240 / 62%);
font-size: 0.8rem;
line-height: 1.5;
text-align: center;
}
.empty p {
margin: 0;
}
.emptyMark {
display: grid;
width: 2.6rem;
height: 2.6rem;
place-items: center;
border: 1px solid rgb(255 255 255 / 20%);
border-radius: 50%;
font-size: 1.45rem;
}
.items {
display: grid;
gap: 0.65rem;
max-height: 22rem;
overflow: auto;
}
.item {
display: grid;
grid-template-columns: 2.7rem minmax(0, 1fr) auto;
gap: 0.6rem;
padding: 0.65rem;
border-radius: 0.85rem;
background: rgb(255 255 255 / 7%);
}
.item img {
width: 2.7rem;
height: 2.7rem;
border-radius: 0.65rem;
object-fit: cover;
}
.itemInfo {
display: grid;
min-width: 0;
color: rgb(255 250 240 / 58%);
font-size: 0.66rem;
}
.itemInfo strong {
overflow: hidden;
color: #fffaf0;
font-size: 0.77rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.quantity {
gap: 0.4rem;
color: #fffaf0;
font-size: 0.75rem;
}
.quantity button {
display: grid;
width: 1.45rem;
height: 1.45rem;
place-items: center;
border: 1px solid rgb(255 255 255 / 18%);
border-radius: 50%;
color: inherit;
background: transparent;
cursor: pointer;
}
.remove {
grid-column: 2 / -1;
justify-self: start;
padding: 0;
border: 0;
color: #f5a9a1;
background: transparent;
font: inherit;
font-size: 0.66rem;
cursor: pointer;
}
.notice,
.success {
margin: 0;
padding: 0.75rem;
border-radius: 0.75rem;
font-size: 0.75rem;
}
.notice {
color: #ffd5cf;
background: rgb(180 61 47 / 24%);
}
.success {
display: grid;
color: var(--color-accent-light);
background: rgb(92 194 148 / 12%);
}
.success strong {
color: #fffaf0;
font-family: var(--font-display);
font-size: 1.15rem;
}
.summary {
display: grid;
gap: 0.65rem;
padding-top: 0.9rem;
border-top: 1px solid rgb(255 255 255 / 12%);
}
.summary span {
color: rgb(255 250 240 / 58%);
font-size: 0.76rem;
}
.summary strong {
color: #fffaf0;
font-family: var(--font-display);
font-size: 1.4rem;
}
@media (max-width: 980px) {
.root {
position: static;
}
}

View File

@@ -0,0 +1,10 @@
import type { ComponentPropsWithoutRef } from 'react'
/** Собственные параметры CartPanel. */
export type CartPanelParams = object
/** Атрибуты корневого aside без children. */
type RootAttrs = Omit<ComponentPropsWithoutRef<'aside'>, 'children'>
/** Props панели draft order. */
export type CartPanelProps = RootAttrs & CartPanelParams

View File

@@ -0,0 +1,2 @@
export { OrderHistory } from './order-history'
export type { OrderHistoryProps } from './types/order-history-props.type'

View File

@@ -0,0 +1,129 @@
import { useState } from 'react'
import cl from 'clsx'
import { formatCurrency, formatDate } from 'shared/lib/format'
import { isEmptyArray, isNonEmptyArray } from 'shared/lib/value-predicates'
import { Button } from 'ui/button'
import { OrderError } from '../../errors/order.error'
import { useOrderHistory } from '../../hooks/use-order-history.hook'
import { cancelOrder } from '../../source/orders.source'
import type { OrderHistoryProps } from './types/order-history-props.type'
import styles from './styles/order-history.module.css'
const STATUS_LABELS = {
pending: 'Ожидает оплаты',
paid: 'Оплачен',
shipped: 'Отправлен',
cancelled: 'Отменён'
} as const
/**
* История доступных пользователю заказов и допустимая отмена.
*
* Используется для:
* - просмотра customer-owned или всех admin-заказов
* - выполнения разрешённого status transition в cancelled
*/
export const OrderHistory = (props: OrderHistoryProps) => {
const { className, ...rootAttrs } = props
const { page, isLoading, error, refresh } = useOrderHistory()
const [mutationError, setMutationError] = useState<string | null>(null)
const [cancellingOrderId, setCancellingOrderId] = useState<string | null>(null)
/**
* Отменяет заказ и повторно получает серверную историю.
*/
const handleCancel = async (orderId: string): Promise<void> => {
setMutationError(null)
setCancellingOrderId(orderId)
try {
await cancelOrder(orderId)
await refresh()
} catch (cancelError) {
const message =
cancelError instanceof OrderError ? cancelError.message : 'Не удалось отменить заказ.'
setMutationError(message)
} finally {
setCancellingOrderId(null)
}
}
let content = <div className={styles.state}>Загружаем историю</div>
if (error) {
content = (
<div className={styles.state} role="alert">
<strong>История недоступна</strong>
<span>{error.message}</span>
<Button variant="secondary" size="small" onClick={() => void refresh()}>
Повторить
</Button>
</div>
)
}
if (!isLoading && page && isEmptyArray(page.orders)) {
content = <div className={styles.state}>Заказов пока нет. Соберите первый draft.</div>
}
if (page && isNonEmptyArray(page.orders)) {
content = (
<div className={styles.list}>
{page.orders.map((order) => {
const canCancel = order.status === 'pending' || order.status === 'paid'
const itemSummary = order.items
.map((item) => `${item.productName} × ${item.quantity}`)
.join(', ')
return (
<article key={order.id} className={styles.order}>
<div className={styles.orderTopline}>
<div>
<span className={styles.orderId}>{order.id}</span>
<span className={cl(styles.status, styles[order.status])}>
{STATUS_LABELS[order.status]}
</span>
</div>
<strong>{formatCurrency(order.totalCents, order.currency)}</strong>
</div>
<p>{itemSummary}</p>
<div className={styles.orderFooter}>
<span>{formatDate(order.createdAt)}</span>
<span>Владелец: {order.userId}</span>
{canCancel && (
<Button
variant="danger"
size="small"
isLoading={cancellingOrderId === order.id}
onClick={() => void handleCancel(order.id)}
>
Отменить
</Button>
)}
</div>
</article>
)
})}
</div>
)
}
return (
<section {...rootAttrs} className={cl(styles.root, className)}>
<div className={styles.heading}>
<div>
<span className={styles.eyebrow}>Protected resource</span>
<h2>История заказов</h2>
</div>
{page && <span>{page.total} записей</span>}
</div>
{mutationError && (
<p className={styles.error} role="alert">
{mutationError}
</p>
)}
{content}
</section>
)
}

View File

@@ -0,0 +1,140 @@
.root {
display: grid;
gap: 1rem;
padding-top: 2rem;
border-top: 1px solid var(--color-line);
}
.heading,
.orderTopline,
.orderFooter,
.orderTopline > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.heading h2 {
margin: 0.15rem 0 0;
color: var(--color-ink);
font-family: var(--font-display);
font-size: 1.8rem;
letter-spacing: -0.035em;
}
.heading > span,
.eyebrow,
.orderId {
color: var(--color-ink-faint);
font-family: var(--font-mono);
font-size: 0.67rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
}
.order {
display: grid;
gap: 0.8rem;
padding: 1rem;
border: 1px solid var(--color-line);
border-radius: 1rem;
background: rgb(255 255 255 / 62%);
}
.orderTopline strong {
color: var(--color-ink);
font-family: var(--font-display);
font-size: 1.12rem;
}
.status {
padding: 0.28rem 0.48rem;
border-radius: 999px;
font-size: 0.66rem;
font-weight: 800;
}
.pending {
color: #8a5e10;
background: #fff1c7;
}
.paid {
color: #176042;
background: #dff5e9;
}
.shipped {
color: #20598a;
background: #e0f0ff;
}
.cancelled {
color: var(--color-ink-muted);
background: var(--color-surface-strong);
}
.order p {
min-height: 2.5rem;
margin: 0;
color: var(--color-ink-muted);
font-size: 0.8rem;
line-height: 1.5;
}
.orderFooter {
justify-content: flex-start;
color: var(--color-ink-faint);
font-size: 0.68rem;
}
.orderFooter button {
margin-left: auto;
}
.state {
display: grid;
place-items: center;
gap: 0.5rem;
min-height: 8rem;
padding: 1rem;
border: 1px dashed var(--color-line-strong);
border-radius: 1rem;
color: var(--color-ink-muted);
text-align: center;
}
.state strong {
color: var(--color-ink);
}
.error {
margin: 0;
padding: 0.7rem;
border-radius: 0.7rem;
color: var(--color-danger);
background: var(--color-danger-soft);
font-size: 0.8rem;
}
@media (max-width: 740px) {
.list {
grid-template-columns: 1fr;
}
.orderFooter {
align-items: flex-start;
flex-direction: column;
}
.orderFooter button {
margin-left: 0;
}
}

View File

@@ -0,0 +1,10 @@
import type { ComponentPropsWithoutRef } from 'react'
/** Собственные параметры OrderHistory. */
export type OrderHistoryParams = object
/** Атрибуты корневой section без children. */
type RootAttrs = Omit<ComponentPropsWithoutRef<'section'>, 'children'>
/** Props истории доступных заказов. */
export type OrderHistoryProps = RootAttrs & OrderHistoryParams

View File

@@ -0,0 +1,6 @@
import { createContext } from 'react'
import type { SessionContextValue } from '../types/session-context-value.type'
/** React-контекст application-scoped пользовательской сессии. */
export const SessionContext = createContext<SessionContextValue | null>(null)

View File

@@ -0,0 +1,20 @@
/** Ожидаемый неуспешный исход сценария сессии. */
export type SessionErrorCode =
| 'invalid-credentials'
| 'rate-limited'
| 'expired'
| 'unavailable'
/**
* Доменная ошибка входа или восстановления сессии.
*/
export class SessionError extends Error {
/** Стабильный код ожидаемого исхода. */
readonly code: SessionErrorCode
constructor(code: SessionErrorCode, message: string) {
super(message)
this.name = 'SessionError'
this.code = code
}
}

View File

@@ -0,0 +1,11 @@
import { useSession } from './use-session.hook'
import type { SessionState } from '../types/session-state.type'
/**
* Возвращает read-only состояние сессии для app и compositions.
*/
export const useSessionState = (): SessionState => {
const { user, status } = useSession()
return { user, status }
}

View File

@@ -0,0 +1,17 @@
import { use } from 'react'
import { SessionContext } from '../context/session.context'
import type { SessionContextValue } from '../types/session-context-value.type'
/**
* Возвращает публичные возможности текущей пользовательской сессии.
*/
export const useSession = (): SessionContextValue => {
const session = use(SessionContext)
if (!session) {
throw new Error('useSession must be used inside SessionProvider')
}
return session
}

View File

@@ -0,0 +1,7 @@
export { useSessionState } from './hooks/use-session-state.hook'
export { SessionProvider } from './session.provider'
export { SessionBadge } from './ui/session-badge'
export { SignInForm } from './ui/sign-in-form'
export type { SessionProviderProps } from './types/session-provider-props.type'
export type { SessionState } from './types/session-state.type'
export type { SessionRole, SessionUser } from './types/session-user.type'

View File

@@ -0,0 +1,87 @@
import { useEffect, useEffectEvent, useState } from 'react'
import { subscribeSimpleRestApiSessionExpired } from 'infra/simple-rest-api'
import { SessionContext } from './context/session.context'
import { loginSession, logoutSession, restoreSession } from './source/session.source'
import type { SessionCredentials } from './types/session-credentials.type'
import type { SessionProviderProps } from './types/session-provider-props.type'
import type { SessionStatus } from './types/session-context-value.type'
import type { SessionUser } from './types/session-user.type'
/**
* Владелец application-scoped пользовательской сессии.
*
* Используется для:
* - восстановления пользователя при загрузке browser-приложения
* - синхронизации login, logout и окончательного истечения credentials
*/
export const SessionProvider = (props: SessionProviderProps) => {
const { children, onSessionClosed } = props
const [user, setUser] = useState<SessionUser | null>(null)
const [status, setStatus] = useState<SessionStatus>('restoring')
const handleSessionExpired = useEffectEvent(() => {
setUser(null)
setStatus('anonymous')
onSessionClosed?.()
})
useEffect(() => {
let isActive = true
void restoreSession()
.then((restoredUser) => {
if (!isActive) {
return
}
setUser(restoredUser)
setStatus(restoredUser ? 'authenticated' : 'anonymous')
})
.catch(() => {
if (!isActive) {
return
}
handleSessionExpired()
})
return () => {
isActive = false
}
}, [])
useEffect(() => {
return subscribeSimpleRestApiSessionExpired(handleSessionExpired)
}, [])
/**
* Выполняет вход и публикует нового пользователя в session context.
*/
const login = async (credentials: SessionCredentials): Promise<void> => {
const authenticatedUser = await loginSession(credentials)
setUser(authenticatedUser)
setStatus('authenticated')
}
/**
* Завершает сессию независимо от доступности logout endpoint.
*/
const logout = async (): Promise<void> => {
try {
await logoutSession()
} catch {
// Local session still closes when the idempotent revoke endpoint is unavailable.
} finally {
setUser(null)
setStatus('anonymous')
onSessionClosed?.()
}
}
return (
<SessionContext value={{ user, status, login, logout }}>
{children}
</SessionContext>
)
}

View File

@@ -0,0 +1,104 @@
import { z } from 'zod'
import {
clearSimpleRestApiTokens,
getSimpleRestApiRefreshToken,
hasSimpleRestApiRefreshToken,
setSimpleRestApiTokens,
simpleRestApi,
toSimpleRestApiError
} from 'infra/simple-rest-api'
import type { SessionCredentials } from '../types/session-credentials.type'
import type { SessionUser } from '../types/session-user.type'
import { SessionError } from '../errors/session.error'
const sessionUserSchema = z.object({
id: z.string(),
email: z.email(),
name: z.string(),
role: z.enum(['admin', 'customer']),
avatarUrl: z.url().nullable()
})
const authResponseSchema = z.object({
data: z.object({
tokens: z.object({
accessToken: z.string(),
refreshToken: z.string(),
expiresIn: z.number(),
tokenType: z.literal('Bearer')
}),
user: sessionUserSchema
})
})
const userResponseSchema = z.object({ data: sessionUserSchema })
/**
* Преобразует source failure в ожидаемый исход домена сессии.
*/
const mapSessionError = (error: unknown): SessionError => {
const apiError = toSimpleRestApiError(error)
if (apiError.code === 'INVALID_CREDENTIALS') {
return new SessionError('invalid-credentials', 'Неверная почта или пароль.')
}
if (apiError.status === 429) {
return new SessionError('rate-limited', 'Слишком много попыток. Повторите через несколько секунд.')
}
if (apiError.status === 401) {
return new SessionError('expired', 'Сессия истекла. Войдите снова.')
}
return new SessionError('unavailable', 'Simple API недоступен. Проверьте, запущен ли demo-backend.')
}
/**
* Открывает сессию по demo-credentials и сохраняет transport tokens.
*/
export const loginSession = async (credentials: SessionCredentials): Promise<SessionUser> => {
try {
const response = await simpleRestApi.auth.simpleAuthLogin(credentials)
const parsedResponse = authResponseSchema.parse(response)
setSimpleRestApiTokens(parsedResponse.data.tokens)
return parsedResponse.data.user
} catch (error) {
throw mapSessionError(error)
}
}
/**
* Восстанавливает пользователя через сохранённый refresh token.
*/
export const restoreSession = async (): Promise<SessionUser | null> => {
if (!hasSimpleRestApiRefreshToken()) {
return null
}
try {
const response = await simpleRestApi.users.simpleUsersMe()
return userResponseSchema.parse(response).data
} catch (error) {
clearSimpleRestApiTokens()
throw mapSessionError(error)
}
}
/**
* Отзывает refresh token и всегда очищает локальные credentials.
*/
export const logoutSession = async (): Promise<void> => {
const refreshToken = getSimpleRestApiRefreshToken()
try {
if (refreshToken) {
await simpleRestApi.auth.simpleAuthLogout({ refreshToken })
}
} finally {
clearSimpleRestApiTokens()
}
}

View File

@@ -0,0 +1,19 @@
import type { SessionCredentials } from './session-credentials.type'
import type { SessionUser } from './session-user.type'
/** Состояние восстановления пользовательской сессии. */
export type SessionStatus = 'restoring' | 'authenticated' | 'anonymous'
/**
* Публичные возможности домена пользовательской сессии.
*/
export type SessionContextValue = {
/** Текущий пользователь или null вне авторизованной сессии. */
user: SessionUser | null
/** Текущее состояние lifecycle сессии. */
status: SessionStatus
/** Выполняет вход и открывает новую пользовательскую сессию. */
login: (credentials: SessionCredentials) => Promise<void>
/** Завершает пользовательскую сессию и отзывает refresh token. */
logout: () => Promise<void>
}

View File

@@ -0,0 +1,9 @@
/**
* Credentials формы входа.
*/
export type SessionCredentials = {
/** Email demo-пользователя. */
email: string
/** Пароль demo-пользователя. */
password: string
}

View File

@@ -0,0 +1,11 @@
import type { ReactNode } from 'react'
/**
* Props application-scoped SessionProvider.
*/
export type SessionProviderProps = {
/** Browser-приложение, использующее одну пользовательскую сессию. */
children: ReactNode
/** Сообщает app assembly, что session-scoped технические данные нужно очистить. */
onSessionClosed?: () => void
}

View File

@@ -0,0 +1,12 @@
import type { SessionStatus } from './session-context-value.type'
import type { SessionUser } from './session-user.type'
/**
* Публичное read-only состояние пользовательской сессии.
*/
export type SessionState = {
/** Текущий пользователь или null вне авторизованной сессии. */
user: SessionUser | null
/** Текущее состояние session lifecycle. */
status: SessionStatus
}

View File

@@ -0,0 +1,18 @@
/** Роль пользователя в Simple Store. */
export type SessionRole = 'admin' | 'customer'
/**
* Пользователь, с которым связана текущая browser-сессия.
*/
export type SessionUser = {
/** Стабильный идентификатор пользователя. */
id: string
/** Email для входа и отображения. */
email: string
/** Отображаемое имя. */
name: string
/** Роль, определяющая доступные продуктовые действия. */
role: SessionRole
/** URL аватара или null для текстового fallback. */
avatarUrl: string | null
}

View File

@@ -0,0 +1,2 @@
export { SessionBadge } from './session-badge'
export type { SessionBadgeProps } from './types/session-badge-props.type'

View File

@@ -0,0 +1,58 @@
import { useState } from 'react'
import cl from 'clsx'
import { Button } from 'ui/button'
import { useSession } from '../../hooks/use-session.hook'
import type { SessionBadgeProps } from './types/session-badge-props.type'
import styles from './styles/session-badge.module.css'
/**
* Краткое представление пользователя и действие выхода.
*
* Используется для:
* - отображения активной роли в application shell
* - завершения пользовательской сессии
*/
export const SessionBadge = (props: SessionBadgeProps) => {
const { className, ...rootAttrs } = props
const { user, logout } = useSession()
const [isLoggingOut, setIsLoggingOut] = useState(false)
/**
* Завершает сессию и блокирует повторное действие до очистки контекста.
*/
const handleLogout = async (): Promise<void> => {
setIsLoggingOut(true)
await logout()
}
if (!user) {
return null
}
const roleLabel = user.role === 'admin' ? 'Администратор' : 'Покупатель'
const initials = user.name
.split(' ')
.map((part) => part.slice(0, 1))
.join('')
.slice(0, 2)
let avatar = <span className={styles.avatarFallback}>{initials}</span>
if (user.avatarUrl) {
avatar = <img className={styles.avatar} src={user.avatarUrl} alt="" />
}
return (
<div {...rootAttrs} className={cl(styles.root, className)}>
{avatar}
<span className={styles.identity}>
<strong>{user.name}</strong>
<span>{roleLabel}</span>
</span>
<Button variant="ghost" size="small" isLoading={isLoggingOut} onClick={handleLogout}>
Выйти
</Button>
</div>
)
}

View File

@@ -0,0 +1,48 @@
.root {
display: flex;
align-items: center;
gap: 0.7rem;
}
.avatar,
.avatarFallback {
width: 2.35rem;
height: 2.35rem;
flex: 0 0 auto;
border-radius: 50%;
}
.avatar {
object-fit: cover;
}
.avatarFallback {
display: grid;
place-items: center;
color: #fff;
background: var(--color-accent-strong);
font-size: 0.72rem;
font-weight: 850;
}
.identity {
display: grid;
min-width: 8rem;
color: var(--color-ink-muted);
font-size: 0.72rem;
line-height: 1.3;
}
.identity strong {
overflow: hidden;
color: var(--color-ink);
font-size: 0.84rem;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 640px) {
.identity {
display: none;
}
}

View File

@@ -0,0 +1,10 @@
import type { ComponentPropsWithoutRef } from 'react'
/** Собственные параметры SessionBadge. */
export type SessionBadgeParams = object
/** Атрибуты корневого div без children. */
type RootAttrs = Omit<ComponentPropsWithoutRef<'div'>, 'children'>
/** Props краткого представления текущей сессии. */
export type SessionBadgeProps = RootAttrs & SessionBadgeParams

View File

@@ -0,0 +1,2 @@
export { SignInForm } from './sign-in-form'
export type { SignInFormProps } from './types/sign-in-form-props.type'

View File

@@ -0,0 +1,109 @@
import { useState } from 'react'
import type { FormEvent } from 'react'
import cl from 'clsx'
import { Button } from 'ui/button'
import { Field } from 'ui/field'
import { SessionError } from '../../errors/session.error'
import { useSession } from '../../hooks/use-session.hook'
import type { SignInFormProps } from './types/sign-in-form-props.type'
import styles from './styles/sign-in-form.module.css'
const DEMO_PASSWORD = 'demo1234'
/**
* Форма входа в Simple Store с быстрым выбором demo-роли.
*
* Используется для:
* - входа администратора для управления каталогом
* - входа покупателя для оформления и просмотра заказов
*/
export const SignInForm = (props: SignInFormProps) => {
const { className, ...rootAttrs } = props
const { login } = useSession()
const [email, setEmail] = useState('admin@demo.local')
const [password, setPassword] = useState(DEMO_PASSWORD)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
/**
* Проверяет credentials через session domain и показывает ожидаемый исход.
*/
const handleSubmit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault()
setErrorMessage(null)
setIsSubmitting(true)
try {
await login({ email, password })
} catch (error) {
const message =
error instanceof SessionError ? error.message : 'Не удалось выполнить вход.'
setErrorMessage(message)
} finally {
setIsSubmitting(false)
}
}
return (
<form
{...rootAttrs}
className={cl(styles.root, className)}
onSubmit={handleSubmit}
>
<div className={styles.accounts} aria-label="Demo accounts">
<button
type="button"
className={cl(styles.account, email === 'admin@demo.local' && styles.accountActive)}
onClick={() => setEmail('admin@demo.local')}
>
<span className={styles.accountRole}>Администратор</span>
<span>Каталог и заказы</span>
</button>
<button
type="button"
className={cl(styles.account, email === 'customer@demo.local' && styles.accountActive)}
onClick={() => setEmail('customer@demo.local')}
>
<span className={styles.accountRole}>Покупатель</span>
<span>Покупки и история</span>
</button>
</div>
<Field
label="Email"
inputProps={{
type: 'email',
name: 'email',
autoComplete: 'username',
value: email,
onChange: (event) => setEmail(event.currentTarget.value),
required: true
}}
/>
<Field
label="Пароль"
hint="Для обеих demo-учётных записей: demo1234"
inputProps={{
type: 'password',
name: 'password',
autoComplete: 'current-password',
value: password,
minLength: 8,
onChange: (event) => setPassword(event.currentTarget.value),
required: true
}}
/>
{errorMessage && (
<p className={styles.error} role="alert">
{errorMessage}
</p>
)}
<Button type="submit" isLoading={isSubmitting}>
Войти в магазин
</Button>
</form>
)
}

View File

@@ -0,0 +1,57 @@
.root {
display: grid;
gap: 1.1rem;
}
.accounts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.7rem;
}
.account {
display: grid;
gap: 0.2rem;
padding: 0.85rem;
border: 1px solid var(--color-line);
border-radius: 0.9rem;
color: var(--color-ink-muted);
background: rgb(255 255 255 / 55%);
font: inherit;
font-size: 0.75rem;
text-align: left;
cursor: pointer;
}
.account:hover,
.account:focus-visible {
border-color: var(--color-accent);
outline: none;
}
.accountActive {
border-color: var(--color-accent);
background: var(--color-accent-soft);
box-shadow: inset 0 0 0 1px var(--color-accent);
}
.accountRole {
color: var(--color-ink);
font-size: 0.85rem;
font-weight: 800;
}
.error {
margin: 0;
padding: 0.8rem 0.9rem;
border-radius: 0.75rem;
color: var(--color-danger);
background: var(--color-danger-soft);
font-size: 0.82rem;
}
@media (max-width: 520px) {
.accounts {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,10 @@
import type { ComponentPropsWithoutRef } from 'react'
/** Собственные параметры SignInForm. */
export type SignInFormParams = object
/** Атрибуты корневой form. */
type RootAttrs = Omit<ComponentPropsWithoutRef<'form'>, 'children' | 'onSubmit'>
/** Props формы входа. */
export type SignInFormProps = RootAttrs & SignInFormParams

View File

@@ -0,0 +1,6 @@
/** Базовый URL локального Simple API. */
export const SIMPLE_REST_API_BASE_URL =
import.meta.env.VITE_SIMPLE_API_URL ?? 'http://localhost:3001'
/** Максимальное время выполнения одного REST-запроса. */
export const SIMPLE_REST_API_TIMEOUT_MS = 12_000

View File

@@ -0,0 +1,2 @@
export { SimpleRestApiError } from './simple-rest-api.error'
export { toSimpleRestApiError } from './to-simple-rest-api-error'

View File

@@ -0,0 +1,28 @@
/**
* Нормализованная ошибка Simple API для source adapters.
*/
export class SimpleRestApiError extends Error {
/** HTTP-статус ответа, если сервер успел его вернуть. */
readonly status: number | null
/** Стабильный код ошибки внешнего API. */
readonly code: string
/** Идентификатор запроса для диагностики backend-логов. */
readonly requestId: string | null
constructor(params: {
/** HTTP-статус ответа, если сервер успел его вернуть. */
status: number | null
/** Стабильный код ошибки внешнего API. */
code: string
/** Безопасное публичное сообщение backend. */
message: string
/** Идентификатор запроса для диагностики backend-логов. */
requestId: string | null
}) {
super(params.message)
this.name = 'SimpleRestApiError'
this.status = params.status
this.code = params.code
this.requestId = params.requestId
}
}

View File

@@ -0,0 +1,55 @@
import { z } from 'zod'
import { ApiError } from '../generated'
import { SimpleRestApiError } from './simple-rest-api.error'
const errorResponseSchema = z.object({
code: z.string(),
message: z.string(),
requestId: z.string().optional()
})
/**
* Преобразует transport failure в стабильную ошибку REST-модуля.
*/
export const toSimpleRestApiError = (error: unknown): SimpleRestApiError => {
if (error instanceof SimpleRestApiError) {
return error
}
if (error instanceof ApiError) {
const parsedError = errorResponseSchema.safeParse(error.error)
if (parsedError.success) {
return new SimpleRestApiError({
status: error.status,
code: parsedError.data.code,
message: parsedError.data.message,
requestId: parsedError.data.requestId ?? null
})
}
return new SimpleRestApiError({
status: error.status,
code: `HTTP_${error.status}`,
message: error.message,
requestId: null
})
}
if (error instanceof Error) {
return new SimpleRestApiError({
status: null,
code: 'NETWORK_ERROR',
message: error.message,
requestId: null
})
}
return new SimpleRestApiError({
status: null,
code: 'UNKNOWN_ERROR',
message: 'Не удалось выполнить запрос к Simple API.',
requestId: null
})
}

View File

@@ -0,0 +1,60 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
import type { ApiRequestClient } from "./http-client";
export type ApiOperation<TClient extends ApiRequestClient = ApiRequestClient> =
(client: TClient, ...args: any[]) => any;
export type ApiTree<TClient extends ApiRequestClient = ApiRequestClient> = {
readonly [key: string]: ApiOperation<TClient> | ApiTree<TClient>;
};
export type BoundApi<TTree, TClient extends ApiRequestClient> = {
readonly [K in keyof TTree]: TTree[K] extends (
client: TClient,
...args: infer Args
) => infer Result
? (...args: Args) => Result
: TTree[K] extends ApiTree<TClient>
? BoundApi<TTree[K], TClient>
: never;
};
export const createApiClient = <
TClient extends ApiRequestClient,
const TTree extends ApiTree<TClient>,
>(
client: TClient,
tree: TTree,
): BoundApi<TTree, TClient> => {
const bindNode = (
node: ApiOperation<TClient> | ApiTree<TClient>,
): unknown => {
if (typeof node === "function") {
return (...args: unknown[]) => node(client, ...args);
}
return Object.fromEntries(
Object.entries(node).map(([key, value]) => [
key,
bindNode(value as ApiOperation<TClient> | ApiTree<TClient>),
]),
);
};
return bindNode(tree) as BoundApi<TTree, TClient>;
};

View File

@@ -0,0 +1,743 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
export interface HealthDataDto {
/** @example "simple" */
application: HealthDataDtoApplicationEnum;
/** @example "ok" */
status: HealthDataDtoStatusEnum;
/** @format date-time */
timestamp: string;
/** @example "1.0.0" */
version: string;
}
export interface HealthResponseDto {
data: HealthDataDto;
}
export interface LoginDto {
/**
* @format email
* @example "admin@demo.local"
*/
email: string;
/**
* @format password
* @minLength 8
* @example "demo1234"
*/
password: string;
}
export interface JwtTokensDto {
accessToken: string;
refreshToken: string;
/**
* Access-token lifetime in seconds.
* @example 60
*/
expiresIn: number;
/** @example "Bearer" */
tokenType: JwtTokensDtoTokenTypeEnum;
}
export interface SimpleUserDto {
/** @example "user-admin" */
id: string;
/**
* @format email
* @example "admin@demo.local"
*/
email: string;
/** @example "Demo Admin" */
name: string;
role: SimpleUserDtoRoleEnum;
/** @example "https://i.pravatar.cc/160?img=12" */
avatarUrl: object | null;
}
export interface JwtAuthDataDto {
tokens: JwtTokensDto;
user: SimpleUserDto;
}
export interface JwtAuthResponseDto {
data: JwtAuthDataDto;
}
export interface ErrorDetailDto {
/** @example "email" */
field?: string;
/** @example "must be an email" */
message: string;
/** @example "isEmail" */
code?: string;
}
export interface ErrorResponseDto {
/** @example 404 */
statusCode: number;
/** @example "PRODUCT_NOT_FOUND" */
code: string;
/** @example "Product not found" */
message: string;
details: ErrorDetailDto[];
/**
* @format date-time
* @example "2026-07-30T12:00:00.000Z"
*/
timestamp: string;
/** @example "/api/v1/products/product-404" */
path: string;
/** @example "req-5c9f7a3d" */
requestId: string;
}
export interface RefreshTokenDto {
/** Refresh token returned by login or the previous refresh call. */
refreshToken: string;
}
export interface SimpleUserResponseDto {
data: SimpleUserDto;
}
export type Object = object;
export interface SimpleProductDto {
/** @example "product-keyboard" */
id: string;
/** @example "Mechanical Keyboard" */
name: string;
/** @example "mechanical-keyboard" */
slug: string;
/** @example "Hot-swappable compact keyboard." */
description: string;
/**
* Price in the smallest currency unit.
* @example 12990
*/
priceCents: number;
/** @example "USD" */
currency: SimpleProductDtoCurrencyEnum;
/** @example "category-electronics" */
categoryId: string;
/**
* @min 0
* @example 24
*/
stock: number;
/**
* @min 0
* @max 5
* @example 4.8
*/
rating: number;
/**
* @format uri
* @example "https://picsum.photos/seed/keyboard/640/480"
*/
imageUrl: string;
/** @format date-time */
createdAt: string;
/**
* Optimistic-lock version.
* @example 1
*/
version: number;
}
export interface PageMetaDto {
/**
* @min 1
* @example 1
*/
page: number;
/**
* @min 1
* @example 20
*/
limit: number;
/**
* @min 0
* @example 48
*/
total: number;
/**
* @min 0
* @example 3
*/
totalPages: number;
}
export interface ProductsResponseDto {
data: SimpleProductDto[];
meta: PageMetaDto;
}
export interface ProductResponseDto {
data: SimpleProductDto;
}
export interface CreateProductDto {
/** @example "USB-C Dock" */
name: string;
/** @example "Dock with HDMI, Ethernet and power delivery." */
description: string;
/**
* @min 0
* @example 8990
*/
priceCents: number;
/** @example "USD" */
currency: CreateProductDtoCurrencyEnum;
/** @example "category-electronics" */
categoryId: string;
/**
* @min 0
* @example 15
*/
stock: number;
/**
* @format uri
* @example "https://picsum.photos/seed/dock/640/480"
*/
imageUrl: string;
}
export interface UpdateProductDto {
/** @example "USB-C Dock" */
name?: string;
/** @example "Dock with HDMI, Ethernet and power delivery." */
description?: string;
/**
* @min 0
* @example 8990
*/
priceCents?: number;
/** @example "USD" */
currency?: UpdateProductDtoCurrencyEnum;
/** @example "category-electronics" */
categoryId?: string;
/**
* @min 0
* @example 15
*/
stock?: number;
/**
* @format uri
* @example "https://picsum.photos/seed/dock/640/480"
*/
imageUrl?: string;
/**
* Version last read by the frontend.
* @min 1
* @example 1
*/
version: number;
}
export interface MutationResultDto {
/** @example "product-001" */
id: string;
/** @example true */
success: boolean;
}
export interface MutationResponseDto {
data: MutationResultDto;
}
export interface SimpleCategoryDto {
/** @example "category-electronics" */
id: string;
/** @example "Electronics" */
name: string;
/** @example "electronics" */
slug: string;
/** @example 4 */
productCount: number;
}
export interface CategoriesResponseDto {
data: SimpleCategoryDto[];
}
export interface CategoryResponseDto {
data: SimpleCategoryDto;
}
export interface SimpleOrderItemDto {
/** @example "product-keyboard" */
productId: string;
/** @example "Mechanical Keyboard" */
productName: string;
/** @example 1 */
quantity: number;
/** @example 12990 */
unitPriceCents: number;
}
export interface SimpleOrderDto {
/** @example "order-001" */
id: string;
/** @example "user-customer" */
userId: string;
status: SimpleOrderDtoStatusEnum;
items: SimpleOrderItemDto[];
/** @example 17980 */
totalCents: number;
/** @example "USD" */
currency: SimpleOrderDtoCurrencyEnum;
/** @format date-time */
createdAt: string;
}
export interface OrdersResponseDto {
data: SimpleOrderDto[];
meta: PageMetaDto;
}
export interface OrderResponseDto {
data: SimpleOrderDto;
}
export interface CreateOrderItemDto {
/** @example "product-keyboard" */
productId: string;
/**
* @min 1
* @max 20
* @example 1
*/
quantity: number;
/**
* @min 1
* @example 1
*/
expectedVersion: number;
/**
* @min 0
* @example 12990
*/
expectedUnitPriceCents: number;
}
export interface CreateOrderDto {
items: CreateOrderItemDto[];
}
export interface ScenarioDto {
/** @example "slow" */
name: string;
/** @example "Delays the response to exercise loading and cancellation states." */
description: string;
}
export interface ScenariosResponseDto {
data: ScenarioDto[];
}
export interface TestingActionDataDto {
/** @example true */
success: boolean;
/** @example "State reset to the default deterministic seed." */
message: string;
}
export interface TestingActionResponseDto {
data: TestingActionDataDto;
}
export interface ChangeSimpleRoleDto {
/** @example "customer" */
role: ChangeSimpleRoleDtoRoleEnum;
}
/** @example "simple" */
export type HealthDataDtoApplicationEnum = "simple" | "complex";
/** @example "ok" */
export type HealthDataDtoStatusEnum = "ok";
/** @example "Bearer" */
export type JwtTokensDtoTokenTypeEnum = "Bearer";
export type SimpleUserDtoRoleEnum = "admin" | "customer";
/** @example "USD" */
export type SimpleProductDtoCurrencyEnum = "USD" | "EUR";
/** @example "USD" */
export type CreateProductDtoCurrencyEnum = "USD" | "EUR";
/** @example "USD" */
export type UpdateProductDtoCurrencyEnum = "USD" | "EUR";
export type SimpleOrderDtoStatusEnum =
| "pending"
| "paid"
| "shipped"
| "cancelled";
/** @example "USD" */
export type SimpleOrderDtoCurrencyEnum = "USD" | "EUR";
/** @example "customer" */
export type ChangeSimpleRoleDtoRoleEnum = "admin" | "customer";
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleHealthHealthParamsXDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleAuthLoginParamsXDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleAuthRefreshParamsXDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleAuthLogoutParamsXDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleUsersMeParamsXDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
export interface SimpleProductsListParams {
/**
* @min 1
* @default 1
*/
page?: Object;
/**
* @min 1
* @max 100
* @default 20
*/
limit?: Object;
/** @example "keyboard" */
search?: string;
/** @example "category-electronics" */
categoryId?: string;
/** @default "newest" */
sort?: SortEnum;
}
/** @default "newest" */
export type SortEnum = "newest" | "price-asc" | "price-desc" | "name";
/** @default "newest" */
export type SimpleProductsListParams1SortEnum =
| "newest"
| "price-asc"
| "price-desc"
| "name";
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleProductsListParams1XDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleProductsCreateParamsXDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
export interface SimpleProductsGetParams {
id: string;
}
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleProductsGetParams1XDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
export interface SimpleProductsUpdateParams {
id: string;
}
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleProductsUpdateParams1XDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
export interface SimpleProductsRemoveParams {
id: string;
}
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleProductsRemoveParams1XDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleCategoriesListParamsXDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
export interface SimpleCategoriesGetParams {
id: string;
}
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleCategoriesGetParams1XDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
export interface SimpleOrdersListParams {
/**
* @min 1
* @default 1
*/
page?: Object;
/**
* @min 1
* @max 100
* @default 20
*/
limit?: Object;
}
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleOrdersListParams1XDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleOrdersCreateParamsXDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
export interface SimpleOrdersGetParams {
id: string;
}
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleOrdersGetParams1XDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
export interface SimpleOrdersCancelParams {
id: string;
}
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleOrdersCancelParams1XDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleTestingScenariosParamsXDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleTestingResetParamsXDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
export interface SimpleTestingSeedParams {
preset: PresetEnum;
}
export type PresetEnum = "small" | "large";
export type SimpleTestingSeedParams1PresetEnum = "small" | "large";
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleTestingSeedParams1XDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";
export type SimpleTestingSeedParams1Enum = "small" | "large";
export interface SimpleTestingChangeRoleParams {
userId: string;
}
/** Forces a deterministic frontend-testing scenario for this request. */
export type SimpleTestingChangeRoleParams1XDemoScenarioEnum =
| "normal"
| "slow"
| "timeout"
| "server-error"
| "rate-limited"
| "empty"
| "expired-auth"
| "forbidden"
| "conflict"
| "large-dataset";

View File

@@ -0,0 +1,543 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
export type QueryParamsType = Record<string | number, any>;
export type ResponseFormat = keyof Omit<Body, "body" | "bodyUsed">;
export interface FullRequestParams extends Omit<RequestInit, "body"> {
/** set parameter to `true` to mark this request as protected */
secure?: boolean;
/** request path */
path: string;
/** content type of request body */
type?: ContentType;
/** query params */
query?: QueryParamsType;
/** format of response (i.e. response.json() -> format: "json") */
format?: ResponseFormat;
/** request body */
body?: unknown;
/** base url */
baseUrl?: string;
/** request cancellation token */
cancelToken?: CancelToken;
/** request timeout in milliseconds */
timeout?: number;
}
export type RequestParams = Omit<
FullRequestParams,
"body" | "method" | "query" | "path"
>;
export interface RequestContext<TResult = unknown> {
url: string;
request: FullRequestParams;
retryCount: number;
retry: () => Promise<TResult>;
}
export type RequestInterceptor = (
params: FullRequestParams,
context: RequestContext,
) => FullRequestParams | Promise<FullRequestParams>;
export type ResponseInterceptor = <D = unknown, E = unknown>(
response: HttpResponse<D, E>,
context: RequestContext,
) => HttpResponse<D, E> | Promise<HttpResponse<D, E>>;
export type ErrorInterceptor = <TResult = unknown>(
error: unknown,
context: RequestContext<TResult>,
) => TResult | Promise<TResult>;
export type ParamsSerializer = (query: QueryParamsType) => string;
export type ResponseParser = (
response: Response,
format?: ResponseFormat,
) => unknown | Promise<unknown>;
export interface ApiRequestClient {
request<T = any, E = any>(params: FullRequestParams): Promise<T>;
}
export interface ApiConfig<SecurityDataType = unknown>
extends Omit<RequestParams, "baseUrl" | "cancelToken" | "signal"> {
baseUrl?: string;
customFetch?: typeof fetch;
paramsSerializer?: ParamsSerializer;
responseParser?: ResponseParser;
onRequest?: RequestInterceptor;
onResponse?: ResponseInterceptor;
onError?: ErrorInterceptor;
}
export interface HttpResponse<D extends unknown, E extends unknown = unknown>
extends Response {
data: D;
error: E;
}
export class ApiError<E = unknown> extends Error {
public readonly status: number;
public readonly statusText: string;
public readonly response: Response;
public readonly data: unknown;
public readonly error: E;
public readonly request: FullRequestParams;
constructor(
response: Response,
request: FullRequestParams,
data: unknown,
error: E,
) {
super(
`Request failed with status ${response.status} ${response.statusText}`.trim(),
);
this.name = "ApiError";
this.status = response.status;
this.statusText = response.statusText;
this.response = response;
this.data = data;
this.error = error;
this.request = request;
}
}
export type CancelToken = Symbol | string | number;
export enum ContentType {
Json = "application/json",
JsonApi = "application/vnd.api+json",
FormData = "multipart/form-data",
UrlEncoded = "application/x-www-form-urlencoded",
Text = "text/plain",
}
export class HttpClient<SecurityDataType = unknown>
implements ApiRequestClient
{
public baseUrl: string = "http://localhost:3001";
private abortControllers = new Map<CancelToken, AbortController>();
private customFetch: typeof fetch = (...fetchParams) => fetch(...fetchParams);
private paramsSerializer?: ParamsSerializer;
private responseParser?: ResponseParser;
private onRequest?: RequestInterceptor;
private onResponse?: ResponseInterceptor;
private onError?: ErrorInterceptor;
private baseRequestParams: RequestParams = {
credentials: "same-origin",
headers: {},
redirect: "follow",
referrerPolicy: "no-referrer",
};
constructor({
baseUrl,
customFetch,
paramsSerializer,
responseParser,
onRequest,
onResponse,
onError,
...baseRequestParams
}: ApiConfig<SecurityDataType> = {}) {
if (typeof baseUrl === "string") {
this.baseUrl = baseUrl;
}
this.customFetch = customFetch || this.customFetch;
this.paramsSerializer = paramsSerializer;
this.responseParser = responseParser;
this.onRequest = onRequest;
this.onResponse = onResponse;
this.onError = onError;
this.baseRequestParams = this.mergeRequestParams(
this.baseRequestParams,
baseRequestParams,
);
}
protected encodeQueryParam(key: string, value: any) {
const encodedKey = encodeURIComponent(key);
return `${encodedKey}=${encodeURIComponent(typeof value === "number" ? value : `${value}`)}`;
}
protected addQueryParam(query: QueryParamsType, key: string) {
return this.encodeQueryParam(key, query[key]);
}
protected addArrayQueryParam(query: QueryParamsType, key: string) {
const value = query[key];
return value.map((v: any) => this.encodeQueryParam(key, v)).join("&");
}
protected toQueryString(rawQuery?: QueryParamsType): string {
const query = rawQuery || {};
if (this.paramsSerializer) {
return this.paramsSerializer(query);
}
const keys = Object.keys(query).filter(
(key) => "undefined" !== typeof query[key],
);
return keys
.map((key) =>
Array.isArray(query[key])
? this.addArrayQueryParam(query, key)
: this.addQueryParam(query, key),
)
.join("&");
}
protected addQueryParams(rawQuery?: QueryParamsType): string {
const queryString = this.toQueryString(rawQuery);
return queryString ? `?${queryString}` : "";
}
protected buildRequestUrl(
baseUrl: string | undefined,
path: string,
query?: QueryParamsType,
): string {
return `${baseUrl || this.baseUrl || ""}${path}${this.addQueryParams(query)}`;
}
protected createRequestContext<TResult>(
request: FullRequestParams,
retryCount: number,
retry: () => Promise<TResult>,
): RequestContext<TResult> {
return {
url: this.buildRequestUrl(request.baseUrl, request.path, request.query),
request,
retryCount,
retry,
};
}
protected updateRequestContext<TResult>(
context: RequestContext<TResult>,
request: FullRequestParams,
) {
context.request = request;
context.url = this.buildRequestUrl(
request.baseUrl,
request.path,
request.query,
);
}
protected mergeHeaders(
...headers: Array<HeadersInit | undefined>
): HeadersInit {
const mergedHeaders = new Headers();
headers.forEach((headers) => {
if (!headers) {
return;
}
new Headers(headers).forEach((value, key) =>
mergedHeaders.set(key, value),
);
});
return Object.fromEntries(mergedHeaders.entries());
}
protected mergeRequestParams<T extends Partial<FullRequestParams>>(
params1: T,
params2?: Partial<FullRequestParams>,
): T {
return {
...params1,
...(params2 || {}),
headers: this.mergeHeaders(params1.headers, params2?.headers),
} as T;
}
protected createAbortSignal = (
cancelToken: CancelToken,
): AbortSignal | undefined => {
if (this.abortControllers.has(cancelToken)) {
const abortController = this.abortControllers.get(cancelToken);
if (abortController) {
return abortController.signal;
}
return void 0;
}
const abortController = new AbortController();
this.abortControllers.set(cancelToken, abortController);
return abortController.signal;
};
protected createRequestSignal = (
signal?: AbortSignal | null,
cancelToken?: CancelToken,
timeout?: number,
): { signal: AbortSignal | null; cleanup: () => void } => {
const signals: AbortSignal[] = [];
let timeoutId: ReturnType<typeof setTimeout> | undefined;
if (signal) {
signals.push(signal);
}
if (cancelToken) {
const cancelSignal = this.createAbortSignal(cancelToken);
if (cancelSignal) {
signals.push(cancelSignal);
}
}
if (typeof timeout === "number" && timeout > 0) {
const timeoutController = new AbortController();
timeoutId = setTimeout(() => timeoutController.abort(), timeout);
signals.push(timeoutController.signal);
}
const cleanupTimeout = () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
if (signals.length === 0) {
return { signal: null, cleanup: cleanupTimeout };
}
if (signals.length === 1) {
return { signal: signals[0] || null, cleanup: cleanupTimeout };
}
const abortController = new AbortController();
const abortRequest = () => abortController.abort();
signals.forEach((signal) => {
if (signal.aborted) {
abortController.abort();
} else {
signal.addEventListener("abort", abortRequest, { once: true });
}
});
return {
signal: abortController.signal,
cleanup: () => {
cleanupTimeout();
signals.forEach((signal) =>
signal.removeEventListener("abort", abortRequest),
);
},
};
};
public abortRequest = (cancelToken: CancelToken) => {
const abortController = this.abortControllers.get(cancelToken);
if (abortController) {
abortController.abort();
this.abortControllers.delete(cancelToken);
}
};
private contentFormatters: Record<ContentType, (input: any) => any> = {
[ContentType.Json]: (input: any) =>
input !== null && (typeof input === "object" || typeof input === "string")
? JSON.stringify(input)
: input,
[ContentType.JsonApi]: (input: any) =>
input !== null && (typeof input === "object" || typeof input === "string")
? JSON.stringify(input)
: input,
[ContentType.Text]: (input: any) =>
input !== null && typeof input !== "string"
? JSON.stringify(input)
: input,
[ContentType.FormData]: (input: any) => {
if (input instanceof FormData) {
return input;
}
return Object.keys(input || {}).reduce((formData, key) => {
const property = input[key];
formData.append(
key,
property instanceof Blob
? property
: typeof property === "object" && property !== null
? JSON.stringify(property)
: `${property}`,
);
return formData;
}, new FormData());
},
[ContentType.UrlEncoded]: (input: any) => this.toQueryString(input),
};
protected parseResponse = async <T = any, E = any>(
response: Response,
responseFormat?: ResponseFormat,
): Promise<HttpResponse<T, E>> => {
const parsedResponse = response as HttpResponse<T, E>;
parsedResponse.data = null as unknown as T;
parsedResponse.error = null as unknown as E;
if (!responseFormat && !this.responseParser) {
return parsedResponse;
}
const responseToParse = response.clone();
await Promise.resolve(
this.responseParser
? this.responseParser(responseToParse, responseFormat)
: responseToParse[responseFormat as ResponseFormat](),
)
.then((data) => {
if (parsedResponse.ok) {
parsedResponse.data = data as T;
} else {
parsedResponse.error = data as E;
}
})
.catch((error) => {
if (parsedResponse.ok) {
throw error;
}
parsedResponse.error = error as E;
});
return parsedResponse;
};
public request = async <T = any, E = any>(
requestParams: FullRequestParams,
) => {
return this.requestWithRetry<T, E>(requestParams, 0);
};
private requestWithRetry = async <T = any, E = any>(
requestParams: FullRequestParams,
retryCount: number,
): Promise<T> => {
let request = this.mergeRequestParams(
this.baseRequestParams,
requestParams,
) as FullRequestParams;
request.baseUrl = request.baseUrl || this.baseUrl;
request.secure =
typeof request.secure === "boolean"
? request.secure
: this.baseRequestParams.secure;
const context = this.createRequestContext<T>(request, retryCount, () =>
this.requestWithRetry<T, E>(requestParams, retryCount + 1),
);
let cleanupSignal = () => {};
let cancelToken: CancelToken | undefined;
const cleanupRequest = () => {
cleanupSignal();
if (cancelToken) {
this.abortControllers.delete(cancelToken);
}
};
try {
if (this.onRequest) {
request = await this.onRequest(request, context);
this.updateRequestContext(context, request);
}
const {
body,
secure,
path,
type,
query,
format,
baseUrl,
cancelToken: requestCancelToken,
timeout,
...params
} = request;
cancelToken = requestCancelToken;
const { signal, cleanup } = this.createRequestSignal(
params.signal,
cancelToken,
timeout,
);
cleanupSignal = cleanup;
const payloadFormatter = this.contentFormatters[type || ContentType.Json];
const responseFormat = format;
const response = await this.customFetch(context.url, {
...params,
headers: this.mergeHeaders(
params.headers,
type && type !== ContentType.FormData
? { "Content-Type": type }
: undefined,
),
signal,
body:
typeof body === "undefined" || body === null
? null
: payloadFormatter(body),
});
const parsedResponse = await this.parseResponse<T, E>(
response,
responseFormat,
);
if (!parsedResponse.ok) {
throw new ApiError<E>(
parsedResponse,
request,
parsedResponse.error || parsedResponse.data,
parsedResponse.error,
);
}
const finalResponse = this.onResponse
? await this.onResponse<T, E>(parsedResponse, context)
: parsedResponse;
return finalResponse.data;
} catch (error) {
cleanupRequest();
if (this.onError) {
return this.onError(error, context);
}
throw error;
} finally {
cleanupRequest();
}
};
}

View File

@@ -0,0 +1,39 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
export { createApiClient } from "./create-api-client";
export type { ApiOperation, ApiTree, BoundApi } from "./create-api-client";
export type * from "./data-contracts";
export { ApiError, ContentType, HttpClient } from "./http-client";
export type {
ApiConfig,
ApiRequestClient,
ErrorInterceptor,
FullRequestParams,
HttpResponse,
ParamsSerializer,
QueryParamsType,
RequestContext,
RequestInterceptor,
RequestParams,
ResponseFormat,
ResponseInterceptor,
ResponseParser,
} from "./http-client";
export * from "./operations";
export * as operations from "./operations";
export { operationsTree } from "./operations-tree";
export type { OperationsTree } from "./operations-tree";

View File

@@ -0,0 +1,75 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
import { simpleAuthLogin } from "./operations/simple-auth-login";
import { simpleAuthLogout } from "./operations/simple-auth-logout";
import { simpleAuthRefresh } from "./operations/simple-auth-refresh";
import { simpleCategoriesGet } from "./operations/simple-categories-get";
import { simpleCategoriesList } from "./operations/simple-categories-list";
import { simpleHealthHealth } from "./operations/simple-health-health";
import { simpleOrdersCancel } from "./operations/simple-orders-cancel";
import { simpleOrdersCreate } from "./operations/simple-orders-create";
import { simpleOrdersGet } from "./operations/simple-orders-get";
import { simpleOrdersList } from "./operations/simple-orders-list";
import { simpleProductsCreate } from "./operations/simple-products-create";
import { simpleProductsGet } from "./operations/simple-products-get";
import { simpleProductsList } from "./operations/simple-products-list";
import { simpleProductsRemove } from "./operations/simple-products-remove";
import { simpleProductsUpdate } from "./operations/simple-products-update";
import { simpleTestingChangeRole } from "./operations/simple-testing-change-role";
import { simpleTestingReset } from "./operations/simple-testing-reset";
import { simpleTestingScenarios } from "./operations/simple-testing-scenarios";
import { simpleTestingSeed } from "./operations/simple-testing-seed";
import { simpleUsersMe } from "./operations/simple-users-me";
export const operationsTree = {
health: {
simpleHealthHealth: simpleHealthHealth,
},
auth: {
simpleAuthLogin: simpleAuthLogin,
simpleAuthRefresh: simpleAuthRefresh,
simpleAuthLogout: simpleAuthLogout,
},
users: {
simpleUsersMe: simpleUsersMe,
},
products: {
simpleProductsList: simpleProductsList,
simpleProductsCreate: simpleProductsCreate,
simpleProductsGet: simpleProductsGet,
simpleProductsUpdate: simpleProductsUpdate,
simpleProductsRemove: simpleProductsRemove,
},
categories: {
simpleCategoriesList: simpleCategoriesList,
simpleCategoriesGet: simpleCategoriesGet,
},
orders: {
simpleOrdersList: simpleOrdersList,
simpleOrdersCreate: simpleOrdersCreate,
simpleOrdersGet: simpleOrdersGet,
simpleOrdersCancel: simpleOrdersCancel,
},
testing: {
simpleTestingScenarios: simpleTestingScenarios,
simpleTestingReset: simpleTestingReset,
simpleTestingSeed: simpleTestingSeed,
simpleTestingChangeRole: simpleTestingChangeRole,
},
} as const;
export type OperationsTree = typeof operationsTree;

View File

@@ -0,0 +1,36 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
export { simpleAuthLogin } from "./simple-auth-login";
export { simpleAuthLogout } from "./simple-auth-logout";
export { simpleAuthRefresh } from "./simple-auth-refresh";
export { simpleCategoriesGet } from "./simple-categories-get";
export { simpleCategoriesList } from "./simple-categories-list";
export { simpleHealthHealth } from "./simple-health-health";
export { simpleOrdersCancel } from "./simple-orders-cancel";
export { simpleOrdersCreate } from "./simple-orders-create";
export { simpleOrdersGet } from "./simple-orders-get";
export { simpleOrdersList } from "./simple-orders-list";
export { simpleProductsCreate } from "./simple-products-create";
export { simpleProductsGet } from "./simple-products-get";
export { simpleProductsList } from "./simple-products-list";
export { simpleProductsRemove } from "./simple-products-remove";
export { simpleProductsUpdate } from "./simple-products-update";
export { simpleTestingChangeRole } from "./simple-testing-change-role";
export { simpleTestingReset } from "./simple-testing-reset";
export { simpleTestingScenarios } from "./simple-testing-scenarios";
export { simpleTestingSeed } from "./simple-testing-seed";
export { simpleUsersMe } from "./simple-users-me";

View File

@@ -0,0 +1,45 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
import type {
ErrorResponseDto,
JwtAuthResponseDto,
LoginDto,
} from "../data-contracts";
import type { ApiRequestClient, RequestParams } from "../http-client";
import { ContentType } from "../http-client";
/**
* No description
*
* @tags Auth
* @name SimpleAuthLogin
* @summary Login and receive JWT access/refresh tokens
* @request POST:/api/v1/auth/login
*/
export const simpleAuthLogin = (
http: ApiRequestClient,
data: LoginDto,
requestParams: RequestParams = {},
) =>
http.request<JwtAuthResponseDto, ErrorResponseDto>({
path: `/api/v1/auth/login`,
method: "POST",
body: data,
type: ContentType.Json,
format: "json",
...requestParams,
});

View File

@@ -0,0 +1,40 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
import type { ErrorResponseDto, RefreshTokenDto } from "../data-contracts";
import type { ApiRequestClient, RequestParams } from "../http-client";
import { ContentType } from "../http-client";
/**
* No description
*
* @tags Auth
* @name SimpleAuthLogout
* @summary Revoke a refresh token; the operation is idempotent
* @request POST:/api/v1/auth/logout
*/
export const simpleAuthLogout = (
http: ApiRequestClient,
data: RefreshTokenDto,
requestParams: RequestParams = {},
) =>
http.request<void, ErrorResponseDto>({
path: `/api/v1/auth/logout`,
method: "POST",
body: data,
type: ContentType.Json,
...requestParams,
});

View File

@@ -0,0 +1,45 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ----------------------------------------------------------------------
* ## АВТОМАТИЧЕСКИ СГЕНЕРИРОВАННЫЙ ФАЙЛ ##
* ## ##
* ## Не редактируйте вручную: изменения будут перезаписаны. ##
* ## Для изменений перегенерируйте клиент. ##
* ## ##
* ## Генератор: @gromlab/api-codegen ##
* ## Репозиторий: https://gromlab.ru/gromov/api-codegen ##
* ----------------------------------------------------------------------
*/
import type {
ErrorResponseDto,
JwtAuthResponseDto,
RefreshTokenDto,
} from "../data-contracts";
import type { ApiRequestClient, RequestParams } from "../http-client";
import { ContentType } from "../http-client";
/**
* No description
*
* @tags Auth
* @name SimpleAuthRefresh
* @summary Rotate a refresh token and issue a new token pair
* @request POST:/api/v1/auth/refresh
*/
export const simpleAuthRefresh = (
http: ApiRequestClient,
data: RefreshTokenDto,
requestParams: RequestParams = {},
) =>
http.request<JwtAuthResponseDto, ErrorResponseDto>({
path: `/api/v1/auth/refresh`,
method: "POST",
body: data,
type: ContentType.Json,
format: "json",
...requestParams,
});

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