mirror of
https://github.com/gromlab-ru/slm-design.git
synced 2026-08-22 07:30:16 +03:00
chore: demo app
This commit is contained in:
1
examples/react-vite/.env.example
Normal file
1
examples/react-vite/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
VITE_SIMPLE_API_URL=http://localhost:3001
|
||||
27
examples/react-vite/.gitignore
vendored
Normal file
27
examples/react-vite/.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
21
examples/react-vite/.oxlintrc.json
Normal file
21
examples/react-vite/.oxlintrc.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
"domains/*/*",
|
||||
"infra/*/*",
|
||||
"ui/*/*",
|
||||
"shared/lib/*/*",
|
||||
"compositions/screens/*/*",
|
||||
"compositions/layouts/*/*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
113
examples/react-vite/README.md
Normal file
113
examples/react-vite/README.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# SLM Store
|
||||
|
||||
Облегчённое React + Vite приложение по Scoped Layered Module Design. Оно использует только маленький контракт [`../demo-backend/openapi/simple.json`](../demo-backend/openapi/simple.json); `complex.json` намеренно не включён в runtime-граф.
|
||||
|
||||
## Возможности
|
||||
|
||||
- JWT login, однократный конкурентный refresh и idempotent logout.
|
||||
- Вход под admin и customer demo-учётными записями.
|
||||
- Каталог с поиском, категориями, сортировкой и offset pagination.
|
||||
- Admin create, update с optimistic locking и delete продукта.
|
||||
- Draft order с фиксацией версии и цены продукта.
|
||||
- Checkout, stock/currency validation, история и отмена заказов.
|
||||
- Собственные доменные модели, исходы и runtime-проверка внешних ответов.
|
||||
|
||||
## Запуск
|
||||
|
||||
Требуются Node.js 20+ и npm 10+.
|
||||
|
||||
Сначала запустите Simple backend:
|
||||
|
||||
```bash
|
||||
cd examples/demo-backend
|
||||
npm install
|
||||
npm run dev:simple
|
||||
```
|
||||
|
||||
Затем в отдельном терминале запустите frontend:
|
||||
|
||||
```bash
|
||||
cd examples/react-vite
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Frontend откроется на `http://localhost:5173`, backend работает на `http://localhost:3001`.
|
||||
|
||||
## Demo-пользователи
|
||||
|
||||
| Роль | Email | Пароль |
|
||||
|---|---|---|
|
||||
| Administrator | `admin@demo.local` | `demo1234` |
|
||||
| Customer | `customer@demo.local` | `demo1234` |
|
||||
|
||||
## Конфигурация
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
| Переменная | Назначение | Значение по умолчанию |
|
||||
|---|---|---|
|
||||
| `VITE_SIMPLE_API_URL` | Base URL Simple API | `http://localhost:3001` |
|
||||
|
||||
Access token живёт в памяти вкладки. Refresh token хранится в `sessionStorage` и очищается вместе с session-scoped SWR cache при logout или окончательном истечении сессии.
|
||||
|
||||
## OpenAPI
|
||||
|
||||
Generated-код находится в `src/infra/simple-rest-api/generated` и не редактируется вручную. Регенерация использует зафиксированную версию `@gromlab/api-codegen`:
|
||||
|
||||
```bash
|
||||
npm run codegen:simple-rest-api
|
||||
```
|
||||
|
||||
OpenAPI ошибочно описывает `page` и `limit` через пустую `Object` schema. Исправленный browser-контракт локализован внутри `infra/simple-rest-api/types`; generated-файлы остаются неизменными.
|
||||
|
||||
## SLM
|
||||
|
||||
SLM root: `src`.
|
||||
|
||||
| Слой | Владельцы и ответственность |
|
||||
|---|---|
|
||||
| `app` | Vite entry, router, application providers и cache lifecycle |
|
||||
| `compositions` | `sign-in`, `storefront`, `app-shell`; только размещение и связывание публичных API |
|
||||
| `domains` | `session`, `catalog`, `orders`; модели, сценарии, исходы, состояние, UI и source adaptation |
|
||||
| `infra` | `simple-rest-api`; generated SDK, transport credentials, refresh race и SWR GET-хуки |
|
||||
| `ui` | Универсальные `button` и `field` |
|
||||
| `shared` | Чистые formatters и value predicates |
|
||||
|
||||
Свёрнутый граф модулей:
|
||||
|
||||
```text
|
||||
app -> compositions
|
||||
app -> domains
|
||||
compositions -> compositions
|
||||
compositions -> domains
|
||||
domains -> infra
|
||||
domains -> ui
|
||||
domains -> shared
|
||||
```
|
||||
|
||||
Каждый внешний импорт проходит через корневой `index.ts` целевого модуля. В корне модуля находятся только публичные фасеты и не более одного главного implementation/assembly-файла; context, hooks, source, types и прочая реализация находятся в сегментах.
|
||||
|
||||
### Владение состоянием
|
||||
|
||||
| Состояние | Владелец | Область жизни |
|
||||
|---|---|---|
|
||||
| Пользователь и session status | `domains/session` | Всё browser-приложение |
|
||||
| JWT credentials и refresh promise | `infra/simple-rest-api` | Вкладка / transport singleton |
|
||||
| Product/order server state | REST hooks + доменная адаптация | Application SWR cache |
|
||||
| Draft order | `domains/orders` | Авторизованный storefront route |
|
||||
| Фильтры и editor state | `domains/catalog` | Экземпляр CatalogPanel |
|
||||
|
||||
## Проверки
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
npm run test
|
||||
npm run build
|
||||
npm run check
|
||||
```
|
||||
|
||||
Тесты покрывают полный login-to-checkout smoke, объединение конкурентных 401 в один refresh и независимое преобразование source errors в доменные исходы.
|
||||
17
examples/react-vite/index.html
Normal file
17
examples/react-vite/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/slm-store.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="SLM Store: демонстрационное React-приложение для Simple API."
|
||||
/>
|
||||
<title>SLM Store</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2657
examples/react-vite/package-lock.json
generated
Normal file
2657
examples/react-vite/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
examples/react-vite/package.json
Normal file
43
examples/react-vite/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "react-vite",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"description": "Lightweight SLM React storefront for the Demo Simple API",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"codegen:simple-rest-api": "npx @gromlab/api-codegen@5.1.0 -i ../demo-backend/openapi/simple.json -o src/infra/simple-rest-api/generated",
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc -b",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"check": "npm run lint && npm run typecheck && npm run test && npm run build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"swr": "^2.5.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.3",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.4",
|
||||
"jsdom": "^30.0.1",
|
||||
"oxlint": "^1.75.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.0",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
5
examples/react-vite/public/slm-store.svg
Normal file
5
examples/react-vite/public/slm-store.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="18" fill="#192c23"/>
|
||||
<path fill="#e36f4f" d="M16 17h35L39 31h10L25 51V36H13l12-19h-9Z"/>
|
||||
<circle cx="46" cy="17" r="5" fill="#9ee0b9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 243 B |
151
examples/react-vite/src/app/app.test.tsx
Normal file
151
examples/react-vite/src/app/app.test.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { clearSimpleRestApiTokens } from 'infra/simple-rest-api'
|
||||
import { App } from './app'
|
||||
import { AppProviders } from './providers/app-providers'
|
||||
|
||||
const demoUser = {
|
||||
id: 'user-admin',
|
||||
email: 'admin@demo.local',
|
||||
name: 'Demo Admin',
|
||||
role: 'admin',
|
||||
avatarUrl: 'https://i.pravatar.cc/160?img=12'
|
||||
}
|
||||
|
||||
const demoProduct = {
|
||||
id: 'product-keyboard',
|
||||
name: 'Mechanical Keyboard',
|
||||
slug: 'mechanical-keyboard',
|
||||
description: 'Mechanical Keyboard is a deterministic demo product used by frontend examples.',
|
||||
priceCents: 12990,
|
||||
currency: 'USD',
|
||||
categoryId: 'category-electronics',
|
||||
stock: 24,
|
||||
rating: 4.8,
|
||||
imageUrl: 'https://picsum.photos/seed/mechanical-keyboard/640/480',
|
||||
createdAt: '2026-07-10T09:00:00.000Z',
|
||||
version: 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт JSON response, совместимый с generated HttpClient.
|
||||
*/
|
||||
const jsonResponse = (body: unknown, status = 200): Response => {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Эмулирует минимальный Simple API для app-level smoke-сценария.
|
||||
*/
|
||||
const createSimpleApiFetch = () => {
|
||||
return vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const url = new URL(String(input))
|
||||
const method = init?.method ?? 'GET'
|
||||
|
||||
if (url.pathname === '/api/v1/auth/login' && method === 'POST') {
|
||||
return jsonResponse({
|
||||
data: {
|
||||
tokens: {
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
expiresIn: 60,
|
||||
tokenType: 'Bearer'
|
||||
},
|
||||
user: demoUser
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/v1/products' && method === 'GET') {
|
||||
return jsonResponse({
|
||||
data: [demoProduct],
|
||||
meta: { page: 1, limit: 6, total: 1, totalPages: 1 }
|
||||
})
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/v1/categories' && method === 'GET') {
|
||||
return jsonResponse({
|
||||
data: [
|
||||
{
|
||||
id: 'category-electronics',
|
||||
name: 'Electronics',
|
||||
slug: 'electronics',
|
||||
productCount: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/v1/orders' && method === 'GET') {
|
||||
return jsonResponse({
|
||||
data: [],
|
||||
meta: { page: 1, limit: 20, total: 0, totalPages: 0 }
|
||||
})
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/v1/orders' && method === 'POST') {
|
||||
return jsonResponse(
|
||||
{
|
||||
data: {
|
||||
id: 'order-010',
|
||||
userId: 'user-admin',
|
||||
status: 'pending',
|
||||
items: [
|
||||
{
|
||||
productId: demoProduct.id,
|
||||
productName: demoProduct.name,
|
||||
quantity: 1,
|
||||
unitPriceCents: demoProduct.priceCents
|
||||
}
|
||||
],
|
||||
totalCents: demoProduct.priceCents,
|
||||
currency: 'USD',
|
||||
createdAt: '2026-08-10T10:00:00.000Z'
|
||||
}
|
||||
},
|
||||
201
|
||||
)
|
||||
}
|
||||
|
||||
return jsonResponse({ code: 'NOT_FOUND', message: 'Not found' }, 404)
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
clearSimpleRestApiTokens()
|
||||
})
|
||||
|
||||
describe('App', () => {
|
||||
it('authenticates, loads domain data and checks out a product', async () => {
|
||||
const user = userEvent.setup()
|
||||
const fetchMock = createSimpleApiFetch()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
render(
|
||||
<AppProviders>
|
||||
<App />
|
||||
</AppProviders>
|
||||
)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Войти в магазин' }))
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Mechanical Keyboard' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Demo Admin')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'В заказ' }))
|
||||
expect(screen.getByText('Draft order')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Оформить заказ' }))
|
||||
|
||||
expect(await screen.findByText('order-010')).toBeInTheDocument()
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:3001/api/v1/orders',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
)
|
||||
})
|
||||
})
|
||||
46
examples/react-vite/src/app/app.tsx
Normal file
46
examples/react-vite/src/app/app.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
|
||||
import { SignInScreen } from 'compositions/screens/sign-in'
|
||||
import { StorefrontScreen } from 'compositions/screens/storefront'
|
||||
import { OrdersProvider } from 'domains/orders'
|
||||
import { useSessionState } from 'domains/session'
|
||||
import styles from './styles/app.module.css'
|
||||
|
||||
/**
|
||||
* Browser entry, выбирающий экран по session lifecycle.
|
||||
*
|
||||
* Используется для:
|
||||
* - защиты storefront route пользовательской сессией
|
||||
* - завершения bootstrap до первого route render
|
||||
*/
|
||||
export const App = () => {
|
||||
const { status } = useSessionState()
|
||||
|
||||
if (status === 'restoring') {
|
||||
return (
|
||||
<main className={styles.loader} aria-live="polite">
|
||||
<span className={styles.loaderMark}>S</span>
|
||||
<strong>Восстанавливаем сессию</strong>
|
||||
<small>Simple API · JWT rotation</small>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const isAuthenticated = status === 'authenticated'
|
||||
const signInElement = isAuthenticated ? <Navigate to="/" replace /> : <SignInScreen />
|
||||
const storefrontElement = isAuthenticated ? (
|
||||
<OrdersProvider>
|
||||
<StorefrontScreen />
|
||||
</OrdersProvider>
|
||||
) : (
|
||||
<Navigate to="/login" replace />
|
||||
)
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={signInElement} />
|
||||
<Route path="/" element={storefrontElement} />
|
||||
<Route path="*" element={<Navigate to={isAuthenticated ? '/' : '/login'} replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
47
examples/react-vite/src/app/providers/app-providers.tsx
Normal file
47
examples/react-vite/src/app/providers/app-providers.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { SWRConfig } from 'swr'
|
||||
|
||||
import { SessionProvider } from 'domains/session'
|
||||
|
||||
/**
|
||||
* Props глобальной provider-композиции browser-приложения.
|
||||
*/
|
||||
export type AppProvidersProps = {
|
||||
/** Browser-приложение внутри общего cache и session scope. */
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Собирает технические providers application scope.
|
||||
*
|
||||
* Используется для:
|
||||
* - одного SWR cache на browser-приложение
|
||||
* - одного session lifecycle и history router
|
||||
*/
|
||||
export const AppProviders = (props: AppProvidersProps) => {
|
||||
const { children } = props
|
||||
const [swrCache] = useState(() => new Map())
|
||||
|
||||
/**
|
||||
* Удаляет server state предыдущего пользователя при закрытии сессии.
|
||||
*/
|
||||
const handleSessionClosed = (): void => {
|
||||
swrCache.clear()
|
||||
}
|
||||
|
||||
return (
|
||||
<SWRConfig
|
||||
value={{
|
||||
provider: () => swrCache,
|
||||
revalidateOnFocus: false,
|
||||
shouldRetryOnError: false
|
||||
}}
|
||||
>
|
||||
<SessionProvider onSessionClosed={handleSessionClosed}>
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
</SessionProvider>
|
||||
</SWRConfig>
|
||||
)
|
||||
}
|
||||
42
examples/react-vite/src/app/styles/app.module.css
Normal file
42
examples/react-vite/src/app/styles/app.module.css
Normal file
@@ -0,0 +1,42 @@
|
||||
.loader {
|
||||
display: grid;
|
||||
min-height: 100svh;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--color-ink-muted);
|
||||
}
|
||||
|
||||
.loaderMark {
|
||||
display: grid;
|
||||
width: 3.4rem;
|
||||
height: 3.4rem;
|
||||
margin-bottom: 0.75rem;
|
||||
place-items: center;
|
||||
border-radius: 1rem 1rem 1rem 0.25rem;
|
||||
color: #fffaf0;
|
||||
background: var(--color-accent-strong);
|
||||
box-shadow: 0 14px 35px rgb(31 86 62 / 22%);
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.7rem;
|
||||
animation: breathe 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.loader strong {
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.loader small {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
50% {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
80
examples/react-vite/src/app/styles/global.css
Normal file
80
examples/react-vite/src/app/styles/global.css
Normal file
@@ -0,0 +1,80 @@
|
||||
:root {
|
||||
--color-canvas: #f5f0e5;
|
||||
--color-surface: #fffdf7;
|
||||
--color-surface-strong: #ebe6da;
|
||||
--color-ink: #192c23;
|
||||
--color-ink-muted: #647069;
|
||||
--color-ink-faint: #929a95;
|
||||
--color-line: rgb(27 48 38 / 13%);
|
||||
--color-line-strong: rgb(27 48 38 / 25%);
|
||||
--color-accent: #e36f4f;
|
||||
--color-accent-strong: #bd4e35;
|
||||
--color-accent-light: #9ee0b9;
|
||||
--color-accent-soft: #f7e4d9;
|
||||
--color-danger: #aa3d32;
|
||||
--color-danger-soft: #fae2de;
|
||||
--font-body: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--font-display: Georgia, 'Times New Roman', serif;
|
||||
--font-mono: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
|
||||
--shadow-card: 0 12px 35px rgb(30 45 38 / 7%);
|
||||
--shadow-card-hover: 0 20px 48px rgb(30 45 38 / 12%);
|
||||
|
||||
color: var(--color-ink);
|
||||
background: var(--color-canvas);
|
||||
font-family: var(--font-body);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
min-height: 100svh;
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(circle at 10% 10%, rgb(227 111 79 / 9%), transparent 24rem),
|
||||
radial-gradient(circle at 90% 35%, rgb(78 148 107 / 8%), transparent 28rem),
|
||||
var(--color-canvas);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
a {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
::selection {
|
||||
color: #fffaf0;
|
||||
background: var(--color-accent-strong);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import cl from 'clsx'
|
||||
|
||||
import { SessionBadge } from 'domains/session'
|
||||
import type { AppShellLayoutProps } from './types/app-shell-layout-props.type'
|
||||
import styles from './styles/app-shell.module.css'
|
||||
|
||||
/**
|
||||
* Общий каркас авторизованного storefront.
|
||||
*
|
||||
* Используется для:
|
||||
* - единой навигации между каталогом и историей заказов
|
||||
* - отображения публичного session UI в header
|
||||
*/
|
||||
export const AppShellLayout = (props: AppShellLayoutProps) => {
|
||||
const { children, className, ...rootAttrs } = props
|
||||
|
||||
return (
|
||||
<div {...rootAttrs} className={cl(styles.root, className)}>
|
||||
<header className={styles.header}>
|
||||
<a className={styles.brand} href="#catalog" aria-label="SLM Store, к каталогу">
|
||||
<span className={styles.brandMark}>S</span>
|
||||
<span>
|
||||
<strong>SLM Store</strong>
|
||||
<small>simple contract</small>
|
||||
</span>
|
||||
</a>
|
||||
<nav className={styles.nav} aria-label="Основная навигация">
|
||||
<a href="#catalog">Каталог</a>
|
||||
<a href="#orders">Заказы</a>
|
||||
</nav>
|
||||
<SessionBadge />
|
||||
</header>
|
||||
<div className={styles.content}>{children}</div>
|
||||
<footer className={styles.footer}>
|
||||
<span>React + Vite</span>
|
||||
<span>Scoped Layered Module Design</span>
|
||||
<span>Simple API · :3001</span>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { AppShellLayout } from './app-shell.layout'
|
||||
export type { AppShellLayoutProps } from './types/app-shell-layout-props.type'
|
||||
@@ -0,0 +1,134 @@
|
||||
.root {
|
||||
width: min(100%, 92rem);
|
||||
min-height: 100svh;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.2rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: sticky;
|
||||
z-index: 20;
|
||||
top: 0.7rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 0.7rem;
|
||||
padding: 0.68rem 0.78rem;
|
||||
border: 1px solid rgb(26 43 35 / 10%);
|
||||
border-radius: 1.1rem;
|
||||
background: rgb(250 246 236 / 80%);
|
||||
box-shadow: 0 12px 35px rgb(31 45 37 / 8%);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
justify-self: start;
|
||||
color: var(--color-ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brandMark {
|
||||
display: grid;
|
||||
width: 2.35rem;
|
||||
height: 2.35rem;
|
||||
place-items: center;
|
||||
border-radius: 0.75rem 0.75rem 0.75rem 0.2rem;
|
||||
color: #fffaf0;
|
||||
background: var(--color-accent-strong);
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.brand > span:last-child {
|
||||
display: grid;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.brand small {
|
||||
margin-top: 0.22rem;
|
||||
color: var(--color-ink-faint);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem;
|
||||
border-radius: 999px;
|
||||
background: rgb(31 46 38 / 5%);
|
||||
}
|
||||
|
||||
.nav a {
|
||||
padding: 0.5rem 0.8rem;
|
||||
border-radius: 999px;
|
||||
color: var(--color-ink-muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 750;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav a:hover,
|
||||
.nav a:focus-visible {
|
||||
color: var(--color-ink);
|
||||
background: var(--color-surface);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.header > :last-child {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 1.2rem 0 3.5rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1.4rem 0 2rem;
|
||||
border-top: 1px solid var(--color-line);
|
||||
color: var(--color-ink-faint);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.63rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.header {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.footer {
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.root {
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.brand small {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
/**
|
||||
* Собственные параметры AppShellLayout.
|
||||
*/
|
||||
export type AppShellLayoutParams = object
|
||||
|
||||
/** Атрибуты корневого div. */
|
||||
type RootAttrs = ComponentPropsWithoutRef<'div'>
|
||||
|
||||
/** Props общего каркаса авторизованного приложения. */
|
||||
export type AppShellLayoutProps = RootAttrs & AppShellLayoutParams
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SignInScreen } from './sign-in.screen'
|
||||
export type { SignInScreenProps } from './types/sign-in-screen-props.type'
|
||||
@@ -0,0 +1,51 @@
|
||||
import cl from 'clsx'
|
||||
|
||||
import { SignInForm } from 'domains/session'
|
||||
import type { SignInScreenProps } from './types/sign-in-screen-props.type'
|
||||
import styles from './styles/sign-in.module.css'
|
||||
|
||||
/**
|
||||
* Публичный экран входа в демонстрационный Simple Store.
|
||||
*
|
||||
* Используется для:
|
||||
* - выбора admin или customer demo-сессии
|
||||
* - краткого объяснения архитектурного среза приложения
|
||||
*/
|
||||
export const SignInScreen = (props: SignInScreenProps) => {
|
||||
const { className, ...rootAttrs } = props
|
||||
|
||||
return (
|
||||
<main {...rootAttrs} className={cl(styles.root, className)}>
|
||||
<section className={styles.story}>
|
||||
<div className={styles.brand}>
|
||||
<span>S</span>
|
||||
<strong>SLM Store</strong>
|
||||
</div>
|
||||
<div className={styles.storyBody}>
|
||||
<span className={styles.kicker}>Small API · complete boundaries</span>
|
||||
<h1>Меньше кода.<br />Чётче владельцы.</h1>
|
||||
<p>
|
||||
Облегчённый storefront поверх JWT API: каталог, optimistic locking,
|
||||
draft order и защищённая история.
|
||||
</p>
|
||||
</div>
|
||||
<ol className={styles.layers} aria-label="SLM data flow">
|
||||
<li><span>01</span> app · lifecycle</li>
|
||||
<li><span>02</span> compositions · assembly</li>
|
||||
<li><span>03</span> domains · product meaning</li>
|
||||
<li><span>04</span> infra · OpenAPI transport</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section className={styles.access} aria-labelledby="sign-in-title">
|
||||
<div className={styles.accessCard}>
|
||||
<span className={styles.status}><i /> Simple API · localhost:3001</span>
|
||||
<h2 id="sign-in-title">Войти в demo</h2>
|
||||
<p>Выберите роль. Credentials уже заполнены и сбрасываются вместе с backend.</p>
|
||||
<SignInForm />
|
||||
</div>
|
||||
<p className={styles.note}>Refresh token хранится только в sessionStorage текущей вкладки.</p>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
.root {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.08fr) minmax(25rem, 0.92fr);
|
||||
min-height: 100svh;
|
||||
}
|
||||
|
||||
.story {
|
||||
position: relative;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
min-height: 100%;
|
||||
padding: clamp(1.5rem, 4vw, 4.5rem);
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
color: #fdf8ed;
|
||||
background:
|
||||
linear-gradient(145deg, rgb(20 42 32 / 98%), rgb(31 71 53 / 94%)),
|
||||
var(--color-ink);
|
||||
}
|
||||
|
||||
.story::before,
|
||||
.story::after {
|
||||
position: absolute;
|
||||
border: 1px solid rgb(255 255 255 / 12%);
|
||||
border-radius: 50%;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.story::before {
|
||||
top: -15vw;
|
||||
right: -9vw;
|
||||
width: 40vw;
|
||||
height: 40vw;
|
||||
box-shadow:
|
||||
0 0 0 5vw rgb(255 255 255 / 2%),
|
||||
0 0 0 10vw rgb(255 255 255 / 2%);
|
||||
}
|
||||
|
||||
.story::after {
|
||||
right: 8%;
|
||||
bottom: 18%;
|
||||
width: 0.7rem;
|
||||
height: 0.7rem;
|
||||
border-color: var(--color-accent-light);
|
||||
background: var(--color-accent-light);
|
||||
box-shadow:
|
||||
5rem -2rem 0 -0.16rem var(--color-accent-light),
|
||||
-3rem 4rem 0 -0.25rem var(--color-accent-light);
|
||||
}
|
||||
|
||||
.brand {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.brand > span {
|
||||
display: grid;
|
||||
width: 2.6rem;
|
||||
height: 2.6rem;
|
||||
place-items: center;
|
||||
border: 1px solid rgb(255 255 255 / 28%);
|
||||
border-radius: 0.85rem 0.85rem 0.85rem 0.2rem;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-size: 0.92rem;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.storyBody {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 43rem;
|
||||
margin: clamp(4rem, 15vh, 10rem) 0;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
color: var(--color-accent-light);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.story h1 {
|
||||
margin: 1rem 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(3.3rem, 7.5vw, 7.2rem);
|
||||
font-weight: 560;
|
||||
letter-spacing: -0.065em;
|
||||
line-height: 0.86;
|
||||
}
|
||||
|
||||
.storyBody p {
|
||||
max-width: 34rem;
|
||||
margin: 1.6rem 0 0;
|
||||
color: rgb(253 248 237 / 64%);
|
||||
font-size: clamp(0.9rem, 1.4vw, 1.08rem);
|
||||
line-height: 1.62;
|
||||
}
|
||||
|
||||
.layers {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.45rem 1.5rem;
|
||||
margin: 0;
|
||||
padding: 1rem 0 0;
|
||||
border-top: 1px solid rgb(255 255 255 / 13%);
|
||||
color: rgb(253 248 237 / 58%);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.67rem;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.layers li {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.layers span {
|
||||
color: var(--color-accent-light);
|
||||
}
|
||||
|
||||
.access {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 1rem;
|
||||
padding: clamp(1.2rem, 5vw, 5rem);
|
||||
background:
|
||||
radial-gradient(circle at 86% 12%, rgb(227 107 74 / 13%), transparent 28%),
|
||||
var(--color-canvas);
|
||||
}
|
||||
|
||||
.accessCard {
|
||||
width: min(100%, 27rem);
|
||||
padding: clamp(1.2rem, 4vw, 2rem);
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 1.4rem;
|
||||
background: rgb(255 255 255 / 63%);
|
||||
box-shadow: 0 28px 75px rgb(26 43 35 / 10%);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
color: var(--color-ink-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.64rem;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.status i {
|
||||
width: 0.48rem;
|
||||
height: 0.48rem;
|
||||
border-radius: 50%;
|
||||
background: #4caf79;
|
||||
box-shadow: 0 0 0 0.23rem rgb(76 175 121 / 14%);
|
||||
}
|
||||
|
||||
.accessCard h2 {
|
||||
margin: 1rem 0 0.45rem;
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-display);
|
||||
font-size: 2.45rem;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.accessCard > p {
|
||||
margin: 0 0 1.35rem;
|
||||
color: var(--color-ink-muted);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.52;
|
||||
}
|
||||
|
||||
.note {
|
||||
max-width: 24rem;
|
||||
margin: 0;
|
||||
color: var(--color-ink-faint);
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.45;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.root {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.story {
|
||||
min-height: 31rem;
|
||||
}
|
||||
|
||||
.storyBody {
|
||||
margin: 4rem 0;
|
||||
}
|
||||
|
||||
.story h1 {
|
||||
font-size: clamp(3rem, 11vw, 5.4rem);
|
||||
}
|
||||
|
||||
.access {
|
||||
min-height: 42rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.layers {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
/**
|
||||
* Собственные параметры экрана SignIn.
|
||||
*/
|
||||
export type SignInScreenParams = object
|
||||
|
||||
/** Атрибуты корневого main без children. */
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'main'>, 'children'>
|
||||
|
||||
/** Props публичного экрана входа. */
|
||||
export type SignInScreenProps = RootAttrs & SignInScreenParams
|
||||
@@ -0,0 +1,2 @@
|
||||
export { StorefrontScreen } from './storefront.screen'
|
||||
export type { StorefrontScreenProps } from './types/storefront-screen-props.type'
|
||||
@@ -0,0 +1,49 @@
|
||||
import cl from 'clsx'
|
||||
|
||||
import { CatalogPanel } from 'domains/catalog'
|
||||
import { CartPanel, OrderHistory, useAddProductToOrder } from 'domains/orders'
|
||||
import { useSessionState } from 'domains/session'
|
||||
import { AppShellLayout } from 'compositions/layouts/app-shell'
|
||||
import type { StorefrontScreenProps } from './types/storefront-screen-props.type'
|
||||
import styles from './styles/storefront.module.css'
|
||||
|
||||
/**
|
||||
* Авторизованный storefront, координирующий независимые доменные UI.
|
||||
*
|
||||
* Используется для:
|
||||
* - передачи product snapshot из catalog в draft order
|
||||
* - совместного отображения каталога, корзины и истории
|
||||
*/
|
||||
export const StorefrontScreen = (props: StorefrontScreenProps) => {
|
||||
const { className, ...rootAttrs } = props
|
||||
const { user } = useSessionState()
|
||||
const addProduct = useAddProductToOrder()
|
||||
const isAdmin = user?.role === 'admin'
|
||||
|
||||
return (
|
||||
<AppShellLayout>
|
||||
<main {...rootAttrs} className={cl(styles.root, className)}>
|
||||
<section className={styles.hero}>
|
||||
<div>
|
||||
<span className={styles.kicker}>One contract · three owners</span>
|
||||
<h1>Simple Store,<br />без простой архитектуры.</h1>
|
||||
</div>
|
||||
<div className={styles.heroAside}>
|
||||
<span className={styles.pulse}><i /> API online</span>
|
||||
<p>
|
||||
JWT refresh, RBAC, pagination, stock validation и optimistic locking
|
||||
проходят через отдельные SLM boundaries.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="catalog" className={styles.workspace}>
|
||||
<CatalogPanel isAdmin={isAdmin} onAddProduct={addProduct} />
|
||||
<CartPanel />
|
||||
</div>
|
||||
|
||||
<OrderHistory id="orders" />
|
||||
</main>
|
||||
</AppShellLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
.root {
|
||||
display: grid;
|
||||
gap: 2.8rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(17rem, 0.65fr);
|
||||
align-items: end;
|
||||
gap: 2rem;
|
||||
min-height: 22rem;
|
||||
padding: clamp(2rem, 6vw, 5.5rem) clamp(1rem, 4vw, 3.2rem);
|
||||
border-radius: 1.5rem;
|
||||
color: #fffaf0;
|
||||
background:
|
||||
radial-gradient(circle at 86% 18%, rgb(235 127 88 / 36%), transparent 24%),
|
||||
linear-gradient(125deg, #18372a 0%, #265540 58%, #694d35 145%);
|
||||
box-shadow: 0 28px 70px rgb(24 55 42 / 15%);
|
||||
}
|
||||
|
||||
.kicker {
|
||||
color: var(--color-accent-light);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.13em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0.9rem 0 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(3rem, 6.5vw, 6.4rem);
|
||||
font-weight: 560;
|
||||
letter-spacing: -0.065em;
|
||||
line-height: 0.88;
|
||||
}
|
||||
|
||||
.heroAside {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 1.1rem;
|
||||
border: 1px solid rgb(255 255 255 / 14%);
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 255 255 / 6%);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.heroAside p {
|
||||
margin: 0;
|
||||
color: rgb(255 250 240 / 66%);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.pulse {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.48rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pulse i {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent-light);
|
||||
box-shadow: 0 0 0 0.3rem rgb(151 219 180 / 12%);
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(18rem, 22rem);
|
||||
gap: 1rem;
|
||||
scroll-margin-top: 6.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.hero {
|
||||
grid-template-columns: 1fr;
|
||||
min-height: 25rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
/**
|
||||
* Собственные параметры экрана Storefront.
|
||||
*/
|
||||
export type StorefrontScreenParams = object
|
||||
|
||||
/** Атрибуты корневого main без children. */
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'main'>, 'children'>
|
||||
|
||||
/** Props авторизованного storefront screen. */
|
||||
export type StorefrontScreenProps = RootAttrs & StorefrontScreenParams
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Ожидаемый неуспешный исход каталога. */
|
||||
export type CatalogErrorCode =
|
||||
| 'forbidden'
|
||||
| 'not-found'
|
||||
| 'version-conflict'
|
||||
| 'invalid-input'
|
||||
| 'rate-limited'
|
||||
| 'invalid-data'
|
||||
| 'unavailable'
|
||||
|
||||
/**
|
||||
* Доменная ошибка чтения или изменения каталога.
|
||||
*/
|
||||
export class CatalogError extends Error {
|
||||
/** Стабильный код ожидаемого исхода. */
|
||||
readonly code: CatalogErrorCode
|
||||
|
||||
constructor(code: CatalogErrorCode, message: string) {
|
||||
super(message)
|
||||
this.name = 'CatalogError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
useGetCategoryList,
|
||||
useGetProductList
|
||||
} from 'infra/simple-rest-api'
|
||||
import { CatalogError } from '../errors/catalog.error'
|
||||
import type { CatalogCategory } from '../types/catalog-category.type'
|
||||
import type { CatalogFilters } from '../types/catalog-filters.type'
|
||||
import type { CatalogPage } from '../types/catalog-page.type'
|
||||
import { mapCatalogError } from '../source/map-catalog-error'
|
||||
import {
|
||||
catalogCategoriesSchema,
|
||||
catalogPageSchema
|
||||
} from '../source/catalog.schemas'
|
||||
|
||||
/**
|
||||
* Результат чтения каталога для domain UI.
|
||||
*/
|
||||
export type CatalogQuery = {
|
||||
/** Валидированная страница продуктов или null до первого ответа. */
|
||||
page: CatalogPage | null
|
||||
/** Валидированные категории. */
|
||||
categories: CatalogCategory[]
|
||||
/** Выполняется ли первый запрос. */
|
||||
isLoading: boolean
|
||||
/** Ожидаемая ошибка чтения или null. */
|
||||
error: CatalogError | null
|
||||
/** Повторно получает продукты и категории. */
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет product response и отделяет wire envelope от доменной страницы.
|
||||
*/
|
||||
const parseCatalogPage = (response: unknown): CatalogPage => {
|
||||
const parsedResponse = catalogPageSchema.safeParse(response)
|
||||
|
||||
if (!parsedResponse.success) {
|
||||
throw new CatalogError('invalid-data', 'Simple API вернул каталог неизвестного формата.')
|
||||
}
|
||||
|
||||
return {
|
||||
products: parsedResponse.data.data,
|
||||
...parsedResponse.data.meta
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет category response до передачи данных domain UI.
|
||||
*/
|
||||
const parseCategories = (response: unknown): CatalogCategory[] => {
|
||||
const parsedResponse = catalogCategoriesSchema.safeParse(response)
|
||||
|
||||
if (!parsedResponse.success) {
|
||||
throw new CatalogError('invalid-data', 'Simple API вернул категории неизвестного формата.')
|
||||
}
|
||||
|
||||
return parsedResponse.data.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Повторно запускает оба GET-запроса каталога.
|
||||
*/
|
||||
const refreshCatalogQueries = async (
|
||||
mutateProducts: () => Promise<unknown>,
|
||||
mutateCategories: () => Promise<unknown>
|
||||
): Promise<void> => {
|
||||
await Promise.all([mutateProducts(), mutateCategories()])
|
||||
}
|
||||
|
||||
/**
|
||||
* Предоставляет domain UI валидированные продукты, категории и pagination.
|
||||
*/
|
||||
export const useCatalog = (filters: CatalogFilters): CatalogQuery => {
|
||||
const productQuery = useGetProductList(filters)
|
||||
const categoryQuery = useGetCategoryList()
|
||||
let page: CatalogPage | null = null
|
||||
let categories: CatalogCategory[] = []
|
||||
let error: CatalogError | null = null
|
||||
|
||||
try {
|
||||
if (productQuery.data) {
|
||||
page = parseCatalogPage(productQuery.data)
|
||||
}
|
||||
|
||||
if (categoryQuery.data) {
|
||||
categories = parseCategories(categoryQuery.data)
|
||||
}
|
||||
} catch (parseError) {
|
||||
error = mapCatalogError(parseError)
|
||||
}
|
||||
|
||||
const queryError = productQuery.error ?? categoryQuery.error
|
||||
|
||||
if (queryError) {
|
||||
error = mapCatalogError(queryError)
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет все source-данные каталога после mutation.
|
||||
*/
|
||||
const refresh = async (): Promise<void> => {
|
||||
await refreshCatalogQueries(productQuery.mutate, categoryQuery.mutate)
|
||||
}
|
||||
|
||||
return {
|
||||
page,
|
||||
categories,
|
||||
isLoading: !productQuery.data && !productQuery.error,
|
||||
error,
|
||||
refresh
|
||||
}
|
||||
}
|
||||
3
examples/react-vite/src/domains/catalog/index.ts
Normal file
3
examples/react-vite/src/domains/catalog/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { CatalogPanel } from './ui/catalog-panel'
|
||||
export type { CatalogPanelProps } from './ui/catalog-panel'
|
||||
export type { CatalogCurrency, CatalogProduct } from './types/catalog-product.type'
|
||||
@@ -0,0 +1,39 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const catalogProductSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
description: z.string(),
|
||||
priceCents: z.number().nonnegative(),
|
||||
currency: z.enum(['USD', 'EUR']),
|
||||
categoryId: z.string(),
|
||||
stock: z.number().int().nonnegative(),
|
||||
rating: z.number().min(0).max(5),
|
||||
imageUrl: z.url(),
|
||||
createdAt: z.iso.datetime(),
|
||||
version: z.number().int().positive()
|
||||
})
|
||||
|
||||
export const catalogCategorySchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
productCount: z.number().int().nonnegative()
|
||||
})
|
||||
|
||||
export const catalogPageSchema = z.object({
|
||||
data: z.array(catalogProductSchema),
|
||||
meta: z.object({
|
||||
page: z.number().int().positive(),
|
||||
limit: z.number().int().positive(),
|
||||
total: z.number().int().nonnegative(),
|
||||
totalPages: z.number().int().nonnegative()
|
||||
})
|
||||
})
|
||||
|
||||
export const catalogCategoriesSchema = z.object({
|
||||
data: z.array(catalogCategorySchema)
|
||||
})
|
||||
|
||||
export const catalogProductResponseSchema = z.object({ data: catalogProductSchema })
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
simpleRestApi,
|
||||
toSimpleRestApiError
|
||||
} from 'infra/simple-rest-api'
|
||||
import { CatalogError } from '../errors/catalog.error'
|
||||
import type {
|
||||
CatalogProduct,
|
||||
CreateCatalogProduct,
|
||||
UpdateCatalogProduct
|
||||
} from '../types/catalog-product.type'
|
||||
import { mapCatalogError } from './map-catalog-error'
|
||||
import { catalogProductResponseSchema } from './catalog.schemas'
|
||||
|
||||
/**
|
||||
* Проверяет и адаптирует одиночный product response.
|
||||
*/
|
||||
const parseProductResponse = (response: unknown): CatalogProduct => {
|
||||
const parsedResponse = catalogProductResponseSchema.safeParse(response)
|
||||
|
||||
if (!parsedResponse.success) {
|
||||
throw new CatalogError('invalid-data', 'Simple API вернул продукт неизвестного формата.')
|
||||
}
|
||||
|
||||
return parsedResponse.data.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт продукт от имени администратора.
|
||||
*/
|
||||
export const createCatalogProduct = async (
|
||||
product: CreateCatalogProduct
|
||||
): Promise<CatalogProduct> => {
|
||||
try {
|
||||
const response = await simpleRestApi.products.simpleProductsCreate(product)
|
||||
return parseProductResponse(response)
|
||||
} catch (error) {
|
||||
throw mapCatalogError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет продукт с optimistic locking по последней прочитанной версии.
|
||||
*/
|
||||
export const updateCatalogProduct = async (
|
||||
productId: string,
|
||||
product: UpdateCatalogProduct
|
||||
): Promise<CatalogProduct> => {
|
||||
try {
|
||||
const response = await simpleRestApi.products.simpleProductsUpdate(
|
||||
{ id: productId },
|
||||
product
|
||||
)
|
||||
return parseProductResponse(response)
|
||||
} catch (error) {
|
||||
throw mapCatalogError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет продукт от имени администратора.
|
||||
*/
|
||||
export const deleteCatalogProduct = async (productId: string): Promise<void> => {
|
||||
try {
|
||||
await simpleRestApi.products.simpleProductsRemove({ id: productId })
|
||||
} catch (error) {
|
||||
const apiError = toSimpleRestApiError(error)
|
||||
throw mapCatalogError(apiError)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { mapCatalogError } from './map-catalog-error'
|
||||
|
||||
vi.mock('infra/simple-rest-api', () => ({
|
||||
toSimpleRestApiError: () => ({
|
||||
status: 409,
|
||||
code: 'PRODUCT_VERSION_CONFLICT',
|
||||
message: 'Source-specific message',
|
||||
requestId: 'req-test'
|
||||
})
|
||||
}))
|
||||
|
||||
describe('mapCatalogError', () => {
|
||||
it('turns a source optimistic-lock failure into a catalog outcome', () => {
|
||||
const catalogError = mapCatalogError(new Error('transport failure'))
|
||||
|
||||
expect(catalogError.code).toBe('version-conflict')
|
||||
expect(catalogError.message).not.toContain('Source-specific')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { toSimpleRestApiError } from 'infra/simple-rest-api'
|
||||
import { CatalogError } from '../errors/catalog.error'
|
||||
|
||||
/**
|
||||
* Преобразует REST failure в ожидаемый исход каталога.
|
||||
*/
|
||||
export const mapCatalogError = (error: unknown): CatalogError => {
|
||||
if (error instanceof CatalogError) {
|
||||
return error
|
||||
}
|
||||
|
||||
const apiError = toSimpleRestApiError(error)
|
||||
|
||||
if (apiError.status === 403) {
|
||||
return new CatalogError('forbidden', 'Управление каталогом доступно только администратору.')
|
||||
}
|
||||
|
||||
if (apiError.status === 404) {
|
||||
return new CatalogError('not-found', 'Продукт больше не существует.')
|
||||
}
|
||||
|
||||
if (apiError.code === 'PRODUCT_VERSION_CONFLICT') {
|
||||
return new CatalogError(
|
||||
'version-conflict',
|
||||
'Продукт уже изменён. Каталог обновлён, повторите редактирование.'
|
||||
)
|
||||
}
|
||||
|
||||
if (apiError.status === 400 || apiError.status === 422) {
|
||||
return new CatalogError('invalid-input', 'Проверьте поля продукта и выбранную категорию.')
|
||||
}
|
||||
|
||||
if (apiError.status === 429) {
|
||||
return new CatalogError('rate-limited', 'Simple API ограничил частоту запросов.')
|
||||
}
|
||||
|
||||
return new CatalogError('unavailable', 'Не удалось получить каталог из Simple API.')
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Категория продуктового каталога.
|
||||
*/
|
||||
export type CatalogCategory = {
|
||||
/** Стабильный идентификатор категории. */
|
||||
id: string
|
||||
/** Отображаемое название. */
|
||||
name: string
|
||||
/** URL-safe имя категории. */
|
||||
slug: string
|
||||
/** Число продуктов в категории. */
|
||||
productCount: number
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/** Порядок сортировки продуктов. */
|
||||
export type CatalogSort = 'newest' | 'price-asc' | 'price-desc' | 'name'
|
||||
|
||||
/**
|
||||
* Фильтры страницы каталога.
|
||||
*/
|
||||
export type CatalogFilters = {
|
||||
/** Номер текущей страницы. */
|
||||
page: number
|
||||
/** Максимальное число продуктов на странице. */
|
||||
limit: number
|
||||
/** Поисковая строка. */
|
||||
search?: string
|
||||
/** Выбранная категория. */
|
||||
categoryId?: string
|
||||
/** Порядок выдачи. */
|
||||
sort: CatalogSort
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { CatalogProduct } from './catalog-product.type'
|
||||
|
||||
/**
|
||||
* Страница продуктов и её pagination metadata.
|
||||
*/
|
||||
export type CatalogPage = {
|
||||
/** Продукты текущей страницы. */
|
||||
products: CatalogProduct[]
|
||||
/** Номер текущей страницы. */
|
||||
page: number
|
||||
/** Лимит элементов страницы. */
|
||||
limit: number
|
||||
/** Общее число продуктов после фильтрации. */
|
||||
total: number
|
||||
/** Общее число страниц. */
|
||||
totalPages: number
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/** Валюта продукта в Simple Store. */
|
||||
export type CatalogCurrency = 'USD' | 'EUR'
|
||||
|
||||
/**
|
||||
* Продукт каталога с данными, необходимыми для покупки и управления.
|
||||
*/
|
||||
export type CatalogProduct = {
|
||||
/** Стабильный идентификатор продукта. */
|
||||
id: string
|
||||
/** Отображаемое название. */
|
||||
name: string
|
||||
/** URL-safe имя продукта. */
|
||||
slug: string
|
||||
/** Пользовательское описание. */
|
||||
description: string
|
||||
/** Цена в минимальных единицах валюты. */
|
||||
priceCents: number
|
||||
/** Валюта цены. */
|
||||
currency: CatalogCurrency
|
||||
/** Идентификатор категории. */
|
||||
categoryId: string
|
||||
/** Доступный остаток. */
|
||||
stock: number
|
||||
/** Средняя оценка от нуля до пяти. */
|
||||
rating: number
|
||||
/** URL изображения продукта. */
|
||||
imageUrl: string
|
||||
/** ISO-дата создания. */
|
||||
createdAt: string
|
||||
/** Версия для optimistic locking. */
|
||||
version: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Данные администратора для создания продукта.
|
||||
*/
|
||||
export type CreateCatalogProduct = {
|
||||
/** Название длиной от двух символов. */
|
||||
name: string
|
||||
/** Описание длиной от десяти символов. */
|
||||
description: string
|
||||
/** Цена в минимальных единицах валюты. */
|
||||
priceCents: number
|
||||
/** Валюта цены. */
|
||||
currency: CatalogCurrency
|
||||
/** Идентификатор существующей категории. */
|
||||
categoryId: string
|
||||
/** Начальный доступный остаток. */
|
||||
stock: number
|
||||
/** HTTPS URL изображения на picsum.photos. */
|
||||
imageUrl: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Изменяемые данные продукта вместе с прочитанной версией.
|
||||
*/
|
||||
export type UpdateCatalogProduct = CreateCatalogProduct & {
|
||||
/** Версия, прочитанная перед редактированием. */
|
||||
version: number
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { useDeferredValue, useState } from 'react'
|
||||
import cl from 'clsx'
|
||||
|
||||
import { formatCurrency } from 'shared/lib/format'
|
||||
import { isEmptyArray, isNonEmptyArray } from 'shared/lib/value-predicates'
|
||||
import { Button } from 'ui/button'
|
||||
import { CatalogError } from '../../errors/catalog.error'
|
||||
import { useCatalog } from '../../hooks/use-catalog.hook'
|
||||
import {
|
||||
createCatalogProduct,
|
||||
deleteCatalogProduct,
|
||||
updateCatalogProduct
|
||||
} from '../../source/catalog.source'
|
||||
import type { CatalogProduct, CreateCatalogProduct } from '../../types/catalog-product.type'
|
||||
import type { CatalogSort } from '../../types/catalog-filters.type'
|
||||
import { ProductForm } from '../product-form/product-form'
|
||||
import type { CatalogPanelProps } from './types/catalog-panel-props.type'
|
||||
import styles from './styles/catalog-panel.module.css'
|
||||
|
||||
/**
|
||||
* Каталог продуктов с поиском, покупкой и административными mutations.
|
||||
*
|
||||
* Используется для:
|
||||
* - выбора актуального product snapshot для draft order
|
||||
* - управления продуктами пользователем с ролью admin
|
||||
*/
|
||||
export const CatalogPanel = (props: CatalogPanelProps) => {
|
||||
const { isAdmin, onAddProduct, className, ...rootAttrs } = props
|
||||
const [search, setSearch] = useState('')
|
||||
const deferredSearch = useDeferredValue(search)
|
||||
const [categoryId, setCategoryId] = useState('')
|
||||
const [sort, setSort] = useState<CatalogSort>('newest')
|
||||
const [pageNumber, setPageNumber] = useState(1)
|
||||
const [editorProduct, setEditorProduct] = useState<CatalogProduct | null | undefined>(undefined)
|
||||
const [mutationError, setMutationError] = useState<string | null>(null)
|
||||
const [isMutating, setIsMutating] = useState(false)
|
||||
const { page, categories, isLoading, error, refresh } = useCatalog({
|
||||
page: pageNumber,
|
||||
limit: 6,
|
||||
search: deferredSearch || undefined,
|
||||
categoryId: categoryId || undefined,
|
||||
sort
|
||||
})
|
||||
|
||||
/**
|
||||
* Создаёт или обновляет продукт в зависимости от открытого editor state.
|
||||
*/
|
||||
const handleProductSubmit = async (input: CreateCatalogProduct): Promise<void> => {
|
||||
setMutationError(null)
|
||||
setIsMutating(true)
|
||||
|
||||
try {
|
||||
if (editorProduct) {
|
||||
await updateCatalogProduct(editorProduct.id, {
|
||||
...input,
|
||||
version: editorProduct.version
|
||||
})
|
||||
} else {
|
||||
await createCatalogProduct(input)
|
||||
}
|
||||
|
||||
setEditorProduct(undefined)
|
||||
await refresh()
|
||||
} catch (mutationFailure) {
|
||||
const message =
|
||||
mutationFailure instanceof CatalogError
|
||||
? mutationFailure.message
|
||||
: 'Не удалось изменить продукт.'
|
||||
setMutationError(message)
|
||||
|
||||
if (mutationFailure instanceof CatalogError && mutationFailure.code === 'version-conflict') {
|
||||
await refresh()
|
||||
}
|
||||
} finally {
|
||||
setIsMutating(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Подтверждает удаление и обновляет текущую страницу каталога.
|
||||
*/
|
||||
const handleDelete = async (product: CatalogProduct): Promise<void> => {
|
||||
const shouldDelete = window.confirm(`Удалить «${product.name}» из каталога?`)
|
||||
|
||||
if (!shouldDelete) {
|
||||
return
|
||||
}
|
||||
|
||||
setMutationError(null)
|
||||
setIsMutating(true)
|
||||
|
||||
try {
|
||||
await deleteCatalogProduct(product.id)
|
||||
await refresh()
|
||||
} catch (mutationFailure) {
|
||||
const message =
|
||||
mutationFailure instanceof CatalogError
|
||||
? mutationFailure.message
|
||||
: 'Не удалось удалить продукт.'
|
||||
setMutationError(message)
|
||||
} finally {
|
||||
setIsMutating(false)
|
||||
}
|
||||
}
|
||||
|
||||
let productsContent = (
|
||||
<div className={styles.state} aria-live="polite">
|
||||
Загружаем актуальный каталог…
|
||||
</div>
|
||||
)
|
||||
|
||||
if (error) {
|
||||
productsContent = (
|
||||
<div className={styles.state} role="alert">
|
||||
<strong>Каталог недоступен</strong>
|
||||
<span>{error.message}</span>
|
||||
<Button variant="secondary" size="small" onClick={() => void refresh()}>
|
||||
Повторить
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isLoading && page && isEmptyArray(page.products)) {
|
||||
productsContent = (
|
||||
<div className={styles.state}>
|
||||
<strong>Ничего не найдено</strong>
|
||||
<span>Измените запрос или категорию.</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (page && isNonEmptyArray(page.products)) {
|
||||
productsContent = (
|
||||
<div className={styles.grid}>
|
||||
{page.products.map((product) => {
|
||||
const category = categories.find((item) => item.id === product.categoryId)
|
||||
const isOutOfStock = product.stock === 0
|
||||
const addLabel = isOutOfStock ? 'Нет в наличии' : 'В заказ'
|
||||
|
||||
return (
|
||||
<article key={product.id} className={styles.card}>
|
||||
<div className={styles.imageFrame}>
|
||||
<img src={product.imageUrl} alt="" loading="lazy" />
|
||||
<span className={styles.rating}>★ {product.rating.toFixed(1)}</span>
|
||||
</div>
|
||||
<div className={styles.cardBody}>
|
||||
<div className={styles.cardMeta}>
|
||||
<span>{category?.name ?? 'Без категории'}</span>
|
||||
<span>{product.stock} шт.</span>
|
||||
</div>
|
||||
<h3>{product.name}</h3>
|
||||
<p>{product.description}</p>
|
||||
<div className={styles.cardFooter}>
|
||||
<strong>{formatCurrency(product.priceCents, product.currency)}</strong>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={isOutOfStock}
|
||||
onClick={() => onAddProduct(product)}
|
||||
>
|
||||
{addLabel}
|
||||
</Button>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className={styles.adminActions}>
|
||||
<Button variant="ghost" size="small" onClick={() => setEditorProduct(product)}>
|
||||
Изменить v{product.version}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="small"
|
||||
disabled={isMutating}
|
||||
onClick={() => void handleDelete(product)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hasPreviousPage = Boolean(page && page.page > 1)
|
||||
const hasNextPage = Boolean(page && page.page < page.totalPages)
|
||||
|
||||
return (
|
||||
<section {...rootAttrs} className={cl(styles.root, className)}>
|
||||
<div className={styles.heading}>
|
||||
<div>
|
||||
<span className={styles.eyebrow}>Deterministic catalog</span>
|
||||
<h2>Рабочее место с товарами</h2>
|
||||
<p>Фильтры читают Simple API, корзина фиксирует версию и цену перед checkout.</p>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<Button variant="secondary" onClick={() => setEditorProduct(null)}>
|
||||
+ Новый продукт
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.filters}>
|
||||
<label className={styles.searchField}>
|
||||
<span>Поиск</span>
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
placeholder="Клавиатура, книга…"
|
||||
onChange={(event) => {
|
||||
setSearch(event.currentTarget.value)
|
||||
setPageNumber(1)
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.selectField}>
|
||||
<span>Категория</span>
|
||||
<select
|
||||
value={categoryId}
|
||||
onChange={(event) => {
|
||||
setCategoryId(event.currentTarget.value)
|
||||
setPageNumber(1)
|
||||
}}
|
||||
>
|
||||
<option value="">Все категории</option>
|
||||
{categories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name} · {category.productCount}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className={styles.selectField}>
|
||||
<span>Сортировка</span>
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(event) => {
|
||||
setSort(event.currentTarget.value as CatalogSort)
|
||||
setPageNumber(1)
|
||||
}}
|
||||
>
|
||||
<option value="newest">Сначала новые</option>
|
||||
<option value="price-asc">Цена по возрастанию</option>
|
||||
<option value="price-desc">Цена по убыванию</option>
|
||||
<option value="name">По названию</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{editorProduct !== undefined && (
|
||||
<ProductForm
|
||||
key={editorProduct?.id ?? 'new-product'}
|
||||
product={editorProduct}
|
||||
categories={categories}
|
||||
errorMessage={mutationError}
|
||||
isSubmitting={isMutating}
|
||||
onCancel={() => {
|
||||
setEditorProduct(undefined)
|
||||
setMutationError(null)
|
||||
}}
|
||||
onSubmit={handleProductSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{productsContent}
|
||||
|
||||
{page && page.totalPages > 1 && (
|
||||
<div className={styles.pagination}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
disabled={!hasPreviousPage}
|
||||
onClick={() => setPageNumber((currentPage) => currentPage - 1)}
|
||||
>
|
||||
Назад
|
||||
</Button>
|
||||
<span>
|
||||
{page.page} / {page.totalPages} · {page.total} товаров
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
disabled={!hasNextPage}
|
||||
onClick={() => setPageNumber((currentPage) => currentPage + 1)}
|
||||
>
|
||||
Далее
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CatalogPanel } from './catalog-panel'
|
||||
export type { CatalogPanelProps } from './types/catalog-panel-props.type'
|
||||
@@ -0,0 +1,235 @@
|
||||
.root {
|
||||
display: grid;
|
||||
gap: 1.4rem;
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.heading h2 {
|
||||
margin: 0.22rem 0 0;
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-display);
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.heading h2 {
|
||||
font-size: clamp(1.75rem, 4vw, 2.65rem);
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.heading p {
|
||||
max-width: 42rem;
|
||||
margin: 0.65rem 0 0;
|
||||
color: var(--color-ink-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--color-accent-strong);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.69rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.13em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(14rem, 1fr) repeat(2, minmax(10rem, 0.42fr));
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 255 255 / 58%);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.searchField,
|
||||
.selectField,
|
||||
.textareaField {
|
||||
display: grid;
|
||||
gap: 0.38rem;
|
||||
color: var(--color-ink);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.searchField input,
|
||||
.selectField select,
|
||||
.textareaField textarea {
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
box-sizing: border-box;
|
||||
padding: 0.68rem 0.8rem;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 0.72rem;
|
||||
color: var(--color-ink);
|
||||
background: rgb(255 255 255 / 76%);
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.searchField input:focus,
|
||||
.selectField select:focus,
|
||||
.textareaField textarea:focus {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 16%, transparent);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 1.2rem;
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-card);
|
||||
transition:
|
||||
transform 180ms ease,
|
||||
box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: var(--shadow-card-hover);
|
||||
}
|
||||
|
||||
.imageFrame {
|
||||
position: relative;
|
||||
aspect-ratio: 4 / 2.75;
|
||||
overflow: hidden;
|
||||
background: var(--color-surface-strong);
|
||||
}
|
||||
|
||||
.imageFrame img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 350ms ease;
|
||||
}
|
||||
|
||||
.card:hover .imageFrame img {
|
||||
transform: scale(1.035);
|
||||
}
|
||||
|
||||
.rating {
|
||||
position: absolute;
|
||||
right: 0.7rem;
|
||||
bottom: 0.7rem;
|
||||
padding: 0.36rem 0.58rem;
|
||||
border-radius: 999px;
|
||||
color: var(--color-ink);
|
||||
background: rgb(255 250 240 / 88%);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.cardBody {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.cardMeta,
|
||||
.cardFooter,
|
||||
.adminActions,
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.cardMeta {
|
||||
color: var(--color-ink-faint);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.66rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin: 0;
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.3rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.card p {
|
||||
min-height: 3.8rem;
|
||||
margin: 0;
|
||||
color: var(--color-ink-muted);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.52;
|
||||
}
|
||||
|
||||
.cardFooter strong {
|
||||
color: var(--color-ink);
|
||||
font-size: 1.04rem;
|
||||
}
|
||||
|
||||
.adminActions {
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px dashed var(--color-line);
|
||||
}
|
||||
|
||||
.state {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 0.55rem;
|
||||
min-height: 14rem;
|
||||
padding: 2rem;
|
||||
border: 1px dashed var(--color-line-strong);
|
||||
border-radius: 1.2rem;
|
||||
color: var(--color-ink-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.state strong {
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
justify-content: center;
|
||||
color: var(--color-ink-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
@media (max-width: 940px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.filters {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.heading {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.grid,
|
||||
.filters {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
import type { CatalogProduct } from '../../../types/catalog-product.type'
|
||||
|
||||
/**
|
||||
* Собственные параметры CatalogPanel.
|
||||
*/
|
||||
export type CatalogPanelParams = {
|
||||
/** Разрешает административные mutations каталога. */
|
||||
isAdmin: boolean
|
||||
/** Передаёт выбранный продукт владельцу draft order. */
|
||||
onAddProduct: (product: CatalogProduct) => void
|
||||
}
|
||||
|
||||
/** Атрибуты корневой section без children. */
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'section'>, 'children'>
|
||||
|
||||
/** Props интерактивной панели каталога. */
|
||||
export type CatalogPanelProps = RootAttrs & CatalogPanelParams
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
|
||||
import { Button } from 'ui/button'
|
||||
import { Field } from 'ui/field'
|
||||
import type { CatalogCurrency } from '../../types/catalog-product.type'
|
||||
import type { ProductFormProps } from './types/product-form-props.type'
|
||||
import styles from './styles/product-form.module.css'
|
||||
|
||||
/**
|
||||
* Внутренняя форма административного product mutation.
|
||||
*
|
||||
* Используется для:
|
||||
* - создания продукта в выбранной категории
|
||||
* - редактирования последней прочитанной версии продукта
|
||||
*/
|
||||
export const ProductForm = (props: ProductFormProps) => {
|
||||
const { product, categories, errorMessage, isSubmitting, onCancel, onSubmit } = props
|
||||
const [name, setName] = useState(product?.name ?? '')
|
||||
const [description, setDescription] = useState(product?.description ?? '')
|
||||
const [price, setPrice] = useState(product ? String(product.priceCents / 100) : '')
|
||||
const [currency, setCurrency] = useState<CatalogCurrency>(product?.currency ?? 'USD')
|
||||
const [categoryId, setCategoryId] = useState(product?.categoryId ?? categories[0]?.id ?? '')
|
||||
const [stock, setStock] = useState(product ? String(product.stock) : '')
|
||||
const [imageUrl, setImageUrl] = useState(
|
||||
product?.imageUrl ?? 'https://picsum.photos/seed/new-product/640/480'
|
||||
)
|
||||
|
||||
/**
|
||||
* Нормализует значения HTML-формы в product contract.
|
||||
*/
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault()
|
||||
await onSubmit({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
priceCents: Math.round(Number(price) * 100),
|
||||
currency,
|
||||
categoryId,
|
||||
stock: Number(stock),
|
||||
imageUrl: imageUrl.trim()
|
||||
})
|
||||
}
|
||||
|
||||
const title = product ? `Редактирование · v${product.version}` : 'Новый продукт'
|
||||
const submitLabel = product ? 'Сохранить версию' : 'Добавить продукт'
|
||||
|
||||
return (
|
||||
<form className={styles.editor} onSubmit={handleSubmit}>
|
||||
<div className={styles.editorHeader}>
|
||||
<div>
|
||||
<span className={styles.eyebrow}>Admin workspace</span>
|
||||
<h3>{title}</h3>
|
||||
</div>
|
||||
<Button variant="ghost" size="small" onClick={onCancel}>
|
||||
Закрыть
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.editorGrid}>
|
||||
<Field
|
||||
label="Название"
|
||||
inputProps={{
|
||||
value: name,
|
||||
minLength: 2,
|
||||
maxLength: 120,
|
||||
onChange: (event) => setName(event.currentTarget.value),
|
||||
required: true
|
||||
}}
|
||||
/>
|
||||
<Field
|
||||
label="Цена"
|
||||
inputProps={{
|
||||
value: price,
|
||||
type: 'number',
|
||||
min: 0,
|
||||
step: '0.01',
|
||||
onChange: (event) => setPrice(event.currentTarget.value),
|
||||
required: true
|
||||
}}
|
||||
/>
|
||||
<label className={styles.selectField}>
|
||||
<span>Валюта</span>
|
||||
<select value={currency} onChange={(event) => setCurrency(event.currentTarget.value as CatalogCurrency)}>
|
||||
<option value="USD">USD</option>
|
||||
<option value="EUR">EUR</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className={styles.selectField}>
|
||||
<span>Категория</span>
|
||||
<select value={categoryId} onChange={(event) => setCategoryId(event.currentTarget.value)} required>
|
||||
{categories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<Field
|
||||
label="Остаток"
|
||||
inputProps={{
|
||||
value: stock,
|
||||
type: 'number',
|
||||
min: 0,
|
||||
step: 1,
|
||||
onChange: (event) => setStock(event.currentTarget.value),
|
||||
required: true
|
||||
}}
|
||||
/>
|
||||
<Field
|
||||
label="Изображение"
|
||||
inputProps={{
|
||||
value: imageUrl,
|
||||
type: 'url',
|
||||
pattern: 'https://picsum\\.photos/.*',
|
||||
onChange: (event) => setImageUrl(event.currentTarget.value),
|
||||
required: true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className={styles.textareaField}>
|
||||
<span>Описание</span>
|
||||
<textarea
|
||||
value={description}
|
||||
minLength={10}
|
||||
maxLength={1000}
|
||||
rows={3}
|
||||
onChange={(event) => setDescription(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
{errorMessage && (
|
||||
<p className={styles.formError} role="alert">
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={styles.editorActions}>
|
||||
<Button type="submit" isLoading={isSubmitting}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
.editor {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 1.1rem;
|
||||
border: 1px solid color-mix(in srgb, var(--color-accent) 32%, var(--color-line));
|
||||
border-radius: 1.1rem;
|
||||
background: var(--color-accent-soft);
|
||||
}
|
||||
|
||||
.editorHeader,
|
||||
.editorActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.editorHeader h3 {
|
||||
margin: 0.22rem 0 0;
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-display);
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--color-accent-strong);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.69rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.13em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.editorGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.selectField,
|
||||
.textareaField {
|
||||
display: grid;
|
||||
gap: 0.38rem;
|
||||
color: var(--color-ink);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.selectField select,
|
||||
.textareaField textarea {
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
box-sizing: border-box;
|
||||
padding: 0.68rem 0.8rem;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 0.72rem;
|
||||
color: var(--color-ink);
|
||||
background: rgb(255 255 255 / 76%);
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.selectField select:focus,
|
||||
.textareaField textarea:focus {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 16%, transparent);
|
||||
}
|
||||
|
||||
.textareaField textarea {
|
||||
min-height: 6.5rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.editorActions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.formError {
|
||||
margin: 0;
|
||||
padding: 0.7rem;
|
||||
border-radius: 0.7rem;
|
||||
color: var(--color-danger);
|
||||
background: var(--color-danger-soft);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
@media (max-width: 940px) {
|
||||
.editorGrid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.editorGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { CatalogCategory } from '../../../types/catalog-category.type'
|
||||
import type {
|
||||
CatalogProduct,
|
||||
CreateCatalogProduct
|
||||
} from '../../../types/catalog-product.type'
|
||||
|
||||
/**
|
||||
* Props внутренней формы создания и редактирования продукта.
|
||||
*/
|
||||
export type ProductFormProps = {
|
||||
/** Редактируемый продукт или null для создания. */
|
||||
product: CatalogProduct | null
|
||||
/** Доступные категории каталога. */
|
||||
categories: CatalogCategory[]
|
||||
/** Ожидаемая ошибка последней mutation. */
|
||||
errorMessage: string | null
|
||||
/** Выполняется ли mutation. */
|
||||
isSubmitting: boolean
|
||||
/** Отменяет редактирование без mutation. */
|
||||
onCancel: () => void
|
||||
/** Передаёт проверенные значения владельцу mutation. */
|
||||
onSubmit: (product: CreateCatalogProduct) => Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
import type { OrdersContextValue } from '../types/orders-context-value.type'
|
||||
|
||||
/** React-контекст draft order в storefront scope. */
|
||||
export const OrdersContext = createContext<OrdersContextValue | null>(null)
|
||||
25
examples/react-vite/src/domains/orders/errors/order.error.ts
Normal file
25
examples/react-vite/src/domains/orders/errors/order.error.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/** Ожидаемый неуспешный исход заказа. */
|
||||
export type OrderErrorCode =
|
||||
| 'product-changed'
|
||||
| 'insufficient-stock'
|
||||
| 'unsupported-currency'
|
||||
| 'cannot-cancel'
|
||||
| 'not-found'
|
||||
| 'invalid-order'
|
||||
| 'rate-limited'
|
||||
| 'invalid-data'
|
||||
| 'unavailable'
|
||||
|
||||
/**
|
||||
* Доменная ошибка draft checkout или истории заказов.
|
||||
*/
|
||||
export class OrderError extends Error {
|
||||
/** Стабильный код ожидаемого исхода. */
|
||||
readonly code: OrderErrorCode
|
||||
|
||||
constructor(code: OrderErrorCode, message: string) {
|
||||
super(message)
|
||||
this.name = 'OrderError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useOrders } from './use-orders.hook'
|
||||
import type { DraftOrderProduct } from '../types/draft-order.type'
|
||||
|
||||
/**
|
||||
* Возвращает минимальную capability добавления product snapshot в draft order.
|
||||
*/
|
||||
export const useAddProductToOrder = (): ((product: DraftOrderProduct) => void) => {
|
||||
return useOrders().addProduct
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useGetOrderList } from 'infra/simple-rest-api'
|
||||
import { OrderError } from '../errors/order.error'
|
||||
import { mapOrderError } from '../source/map-order-error'
|
||||
import { orderPageSchema } from '../source/order.schemas'
|
||||
import type { OrderPage } from '../types/order-page.type'
|
||||
|
||||
/**
|
||||
* Результат чтения истории заказов.
|
||||
*/
|
||||
export type OrderHistoryQuery = {
|
||||
/** Валидированная страница заказов или null до ответа. */
|
||||
page: OrderPage | null
|
||||
/** Выполняется ли первый запрос. */
|
||||
isLoading: boolean
|
||||
/** Ожидаемая ошибка чтения или null. */
|
||||
error: OrderError | null
|
||||
/** Повторно получает текущую страницу. */
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет wire response истории заказов.
|
||||
*/
|
||||
const parseOrderPage = (response: unknown): OrderPage => {
|
||||
const parsedResponse = orderPageSchema.safeParse(response)
|
||||
|
||||
if (!parsedResponse.success) {
|
||||
throw new OrderError('invalid-data', 'Simple API вернул историю неизвестного формата.')
|
||||
}
|
||||
|
||||
return {
|
||||
orders: parsedResponse.data.data,
|
||||
...parsedResponse.data.meta
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Повторно запускает GET текущей страницы заказов.
|
||||
*/
|
||||
const refreshOrderQuery = async (mutateOrders: () => Promise<unknown>): Promise<void> => {
|
||||
await mutateOrders()
|
||||
}
|
||||
|
||||
/**
|
||||
* Предоставляет domain UI валидированную историю заказов.
|
||||
*/
|
||||
export const useOrderHistory = (): OrderHistoryQuery => {
|
||||
const orderQuery = useGetOrderList({ page: 1, limit: 20 })
|
||||
let page: OrderPage | null = null
|
||||
let error: OrderError | null = null
|
||||
|
||||
try {
|
||||
if (orderQuery.data) {
|
||||
page = parseOrderPage(orderQuery.data)
|
||||
}
|
||||
} catch (parseError) {
|
||||
error = mapOrderError(parseError)
|
||||
}
|
||||
|
||||
if (orderQuery.error) {
|
||||
error = mapOrderError(orderQuery.error)
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет историю после checkout или cancel mutation.
|
||||
*/
|
||||
const refresh = async (): Promise<void> => {
|
||||
await refreshOrderQuery(orderQuery.mutate)
|
||||
}
|
||||
|
||||
return {
|
||||
page,
|
||||
isLoading: !orderQuery.data && !orderQuery.error,
|
||||
error,
|
||||
refresh
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { use } from 'react'
|
||||
|
||||
import { OrdersContext } from '../context/orders.context'
|
||||
import type { OrdersContextValue } from '../types/orders-context-value.type'
|
||||
|
||||
/**
|
||||
* Возвращает публичные возможности draft order и checkout.
|
||||
*/
|
||||
export const useOrders = (): OrdersContextValue => {
|
||||
const orders = use(OrdersContext)
|
||||
|
||||
if (!orders) {
|
||||
throw new Error('useOrders must be used inside OrdersProvider')
|
||||
}
|
||||
|
||||
return orders
|
||||
}
|
||||
9
examples/react-vite/src/domains/orders/index.ts
Normal file
9
examples/react-vite/src/domains/orders/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { useAddProductToOrder } from './hooks/use-add-product-to-order.hook'
|
||||
export { OrdersProvider } from './orders.provider'
|
||||
export { CartPanel } from './ui/cart-panel'
|
||||
export { OrderHistory } from './ui/order-history'
|
||||
export type { CartPanelProps } from './ui/cart-panel'
|
||||
export type { OrderHistoryProps } from './ui/order-history'
|
||||
export type { DraftOrderProduct } from './types/draft-order.type'
|
||||
export type { Order, OrderCurrency, OrderItem, OrderStatus } from './types/order.type'
|
||||
export type { OrdersProviderProps } from './types/orders-provider-props.type'
|
||||
176
examples/react-vite/src/domains/orders/orders.provider.tsx
Normal file
176
examples/react-vite/src/domains/orders/orders.provider.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useState } from 'react'
|
||||
import { useSWRConfig } from 'swr'
|
||||
|
||||
import { getOrderListKey } from 'infra/simple-rest-api'
|
||||
import { isEmptyArray } from 'shared/lib/value-predicates'
|
||||
import { OrderError } from './errors/order.error'
|
||||
import { OrdersContext } from './context/orders.context'
|
||||
import { createOrder } from './source/orders.source'
|
||||
import type { DraftOrderItem, DraftOrderProduct } from './types/draft-order.type'
|
||||
import type { Order } from './types/order.type'
|
||||
import type { OrdersProviderProps } from './types/orders-provider-props.type'
|
||||
|
||||
const ORDER_LIMIT_PER_PRODUCT = 20
|
||||
|
||||
/**
|
||||
* Владелец draft order и checkout lifecycle внутри storefront.
|
||||
*
|
||||
* Используется для:
|
||||
* - фиксации product snapshots до подтверждения заказа
|
||||
* - единственной координации локального draft и server mutation
|
||||
*/
|
||||
export const OrdersProvider = (props: OrdersProviderProps) => {
|
||||
const { children } = props
|
||||
const { mutate } = useSWRConfig()
|
||||
const [items, setItems] = useState<DraftOrderItem[]>([])
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
const [createdOrder, setCreatedOrder] = useState<Order | null>(null)
|
||||
const [isCheckingOut, setIsCheckingOut] = useState(false)
|
||||
|
||||
/**
|
||||
* Добавляет USD product snapshot либо объясняет неподдерживаемый исход.
|
||||
*/
|
||||
const addProduct = (product: DraftOrderProduct): void => {
|
||||
setNotice(null)
|
||||
setCreatedOrder(null)
|
||||
|
||||
if (product.currency !== 'USD') {
|
||||
setNotice('Simple API оформляет только продукты в USD.')
|
||||
return
|
||||
}
|
||||
|
||||
if (product.stock === 0) {
|
||||
setNotice('Товар закончился на складе.')
|
||||
return
|
||||
}
|
||||
|
||||
setItems((currentItems) => {
|
||||
const existingItem = currentItems.find((item) => item.productId === product.id)
|
||||
|
||||
if (!existingItem) {
|
||||
return [
|
||||
...currentItems,
|
||||
{
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
quantity: 1,
|
||||
unitPriceCents: product.priceCents,
|
||||
currency: 'USD',
|
||||
expectedVersion: product.version,
|
||||
availableStock: product.stock,
|
||||
imageUrl: product.imageUrl
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const maxQuantity = Math.min(product.stock, ORDER_LIMIT_PER_PRODUCT)
|
||||
|
||||
if (existingItem.quantity >= maxQuantity) {
|
||||
setNotice(`Для «${product.name}» достигнут доступный лимит.`)
|
||||
return currentItems
|
||||
}
|
||||
|
||||
return currentItems.map((item) => {
|
||||
if (item.productId !== product.id) {
|
||||
return item
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
quantity: item.quantity + 1,
|
||||
unitPriceCents: product.priceCents,
|
||||
expectedVersion: product.version,
|
||||
availableStock: product.stock
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Изменяет количество строки в допустимом диапазоне.
|
||||
*/
|
||||
const setQuantity = (productId: string, quantity: number): void => {
|
||||
setNotice(null)
|
||||
setItems((currentItems) =>
|
||||
currentItems.map((item) => {
|
||||
if (item.productId !== productId) {
|
||||
return item
|
||||
}
|
||||
|
||||
const maxQuantity = Math.min(item.availableStock, ORDER_LIMIT_PER_PRODUCT)
|
||||
const safeQuantity = Math.max(1, Math.min(quantity, maxQuantity))
|
||||
|
||||
return { ...item, quantity: safeQuantity }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет одну строку из draft order.
|
||||
*/
|
||||
const removeProduct = (productId: string): void => {
|
||||
setNotice(null)
|
||||
setItems((currentItems) => currentItems.filter((item) => item.productId !== productId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Полностью очищает draft order и checkout feedback.
|
||||
*/
|
||||
const clearDraft = (): void => {
|
||||
setItems([])
|
||||
setNotice(null)
|
||||
setCreatedOrder(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Подтверждает product snapshots на backend и создаёт заказ.
|
||||
*/
|
||||
const checkout = async (): Promise<void> => {
|
||||
if (isEmptyArray(items)) {
|
||||
setNotice('Добавьте хотя бы один продукт.')
|
||||
return
|
||||
}
|
||||
|
||||
setNotice(null)
|
||||
setCreatedOrder(null)
|
||||
setIsCheckingOut(true)
|
||||
|
||||
try {
|
||||
const order = await createOrder(items)
|
||||
setItems([])
|
||||
setCreatedOrder(order)
|
||||
await mutate(getOrderListKey({ page: 1, limit: 20 }))
|
||||
} catch (error) {
|
||||
const message = error instanceof OrderError ? error.message : 'Не удалось создать заказ.'
|
||||
setNotice(message)
|
||||
} finally {
|
||||
setIsCheckingOut(false)
|
||||
}
|
||||
}
|
||||
|
||||
const itemCount = items.reduce((total, item) => total + item.quantity, 0)
|
||||
const totalCents = items.reduce(
|
||||
(total, item) => total + item.unitPriceCents * item.quantity,
|
||||
0
|
||||
)
|
||||
|
||||
return (
|
||||
<OrdersContext
|
||||
value={{
|
||||
items,
|
||||
itemCount,
|
||||
totalCents,
|
||||
notice,
|
||||
createdOrder,
|
||||
isCheckingOut,
|
||||
addProduct,
|
||||
setQuantity,
|
||||
removeProduct,
|
||||
clearDraft,
|
||||
checkout
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</OrdersContext>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { mapOrderError } from './map-order-error'
|
||||
|
||||
vi.mock('infra/simple-rest-api', () => ({
|
||||
toSimpleRestApiError: () => ({
|
||||
status: 422,
|
||||
code: 'UNSUPPORTED_ORDER_CURRENCY',
|
||||
message: 'Source-specific message',
|
||||
requestId: 'req-test'
|
||||
})
|
||||
}))
|
||||
|
||||
describe('mapOrderError', () => {
|
||||
it('turns a source currency failure into an orders outcome', () => {
|
||||
const orderError = mapOrderError(new Error('transport failure'))
|
||||
|
||||
expect(orderError.code).toBe('unsupported-currency')
|
||||
expect(orderError.message).not.toContain('Source-specific')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { toSimpleRestApiError } from 'infra/simple-rest-api'
|
||||
import { OrderError } from '../errors/order.error'
|
||||
|
||||
/**
|
||||
* Преобразует REST failure в ожидаемый исход домена заказов.
|
||||
*/
|
||||
export const mapOrderError = (error: unknown): OrderError => {
|
||||
if (error instanceof OrderError) {
|
||||
return error
|
||||
}
|
||||
|
||||
const apiError = toSimpleRestApiError(error)
|
||||
|
||||
if (apiError.code === 'PRODUCT_CHANGED') {
|
||||
return new OrderError('product-changed', 'Цена или версия продукта изменилась. Обновите каталог и корзину.')
|
||||
}
|
||||
|
||||
if (apiError.code === 'INSUFFICIENT_STOCK') {
|
||||
return new OrderError('insufficient-stock', 'Товара уже недостаточно на складе.')
|
||||
}
|
||||
|
||||
if (apiError.code === 'UNSUPPORTED_ORDER_CURRENCY') {
|
||||
return new OrderError('unsupported-currency', 'Checkout Simple API принимает только продукты в USD.')
|
||||
}
|
||||
|
||||
if (apiError.code === 'ORDER_CANNOT_BE_CANCELLED') {
|
||||
return new OrderError('cannot-cancel', 'Заказ в этом статусе уже нельзя отменить.')
|
||||
}
|
||||
|
||||
if (apiError.status === 404) {
|
||||
return new OrderError('not-found', 'Заказ или продукт больше не существует.')
|
||||
}
|
||||
|
||||
if (apiError.status === 400 || apiError.status === 422) {
|
||||
return new OrderError('invalid-order', 'Состав заказа не соответствует правилам checkout.')
|
||||
}
|
||||
|
||||
if (apiError.status === 429) {
|
||||
return new OrderError('rate-limited', 'Simple API ограничил частоту запросов.')
|
||||
}
|
||||
|
||||
return new OrderError('unavailable', 'Не удалось выполнить операцию с заказом.')
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const orderSchema = z.object({
|
||||
id: z.string(),
|
||||
userId: z.string(),
|
||||
status: z.enum(['pending', 'paid', 'shipped', 'cancelled']),
|
||||
items: z.array(
|
||||
z.object({
|
||||
productId: z.string(),
|
||||
productName: z.string(),
|
||||
quantity: z.number().int().positive(),
|
||||
unitPriceCents: z.number().int().nonnegative()
|
||||
})
|
||||
),
|
||||
totalCents: z.number().int().nonnegative(),
|
||||
currency: z.enum(['USD', 'EUR']),
|
||||
createdAt: z.iso.datetime()
|
||||
})
|
||||
|
||||
export const orderResponseSchema = z.object({ data: orderSchema })
|
||||
|
||||
export const orderPageSchema = z.object({
|
||||
data: z.array(orderSchema),
|
||||
meta: z.object({
|
||||
page: z.number().int().positive(),
|
||||
limit: z.number().int().positive(),
|
||||
total: z.number().int().nonnegative(),
|
||||
totalPages: z.number().int().nonnegative()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { simpleRestApi } from 'infra/simple-rest-api'
|
||||
import { OrderError } from '../errors/order.error'
|
||||
import type { DraftOrderItem } from '../types/draft-order.type'
|
||||
import type { Order } from '../types/order.type'
|
||||
import { mapOrderError } from './map-order-error'
|
||||
import { orderResponseSchema } from './order.schemas'
|
||||
|
||||
/**
|
||||
* Проверяет wire response одиночного заказа.
|
||||
*/
|
||||
const parseOrderResponse = (response: unknown): Order => {
|
||||
const parsedResponse = orderResponseSchema.safeParse(response)
|
||||
|
||||
if (!parsedResponse.success) {
|
||||
throw new OrderError('invalid-data', 'Simple API вернул заказ неизвестного формата.')
|
||||
}
|
||||
|
||||
return parsedResponse.data.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт заказ из зафиксированных строк draft order.
|
||||
*/
|
||||
export const createOrder = async (items: DraftOrderItem[]): Promise<Order> => {
|
||||
const body = {
|
||||
items: items.map((item) => ({
|
||||
productId: item.productId,
|
||||
quantity: item.quantity,
|
||||
expectedVersion: item.expectedVersion,
|
||||
expectedUnitPriceCents: item.unitPriceCents
|
||||
}))
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await simpleRestApi.orders.simpleOrdersCreate(body)
|
||||
return parseOrderResponse(response)
|
||||
} catch (error) {
|
||||
throw mapOrderError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отменяет доступный пользователю заказ.
|
||||
*/
|
||||
export const cancelOrder = async (orderId: string): Promise<Order> => {
|
||||
try {
|
||||
const response = await simpleRestApi.orders.simpleOrdersCancel({ id: orderId })
|
||||
return parseOrderResponse(response)
|
||||
} catch (error) {
|
||||
throw mapOrderError(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Минимальный product snapshot, принимаемый владельцем draft order.
|
||||
*/
|
||||
export type DraftOrderProduct = {
|
||||
/** Идентификатор продукта. */
|
||||
id: string
|
||||
/** Название продукта. */
|
||||
name: string
|
||||
/** Цена в минимальных единицах. */
|
||||
priceCents: number
|
||||
/** Валюта продукта. */
|
||||
currency: 'USD' | 'EUR'
|
||||
/** Версия product snapshot. */
|
||||
version: number
|
||||
/** Доступный остаток. */
|
||||
stock: number
|
||||
/** URL изображения. */
|
||||
imageUrl: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Строка draft order до checkout.
|
||||
*/
|
||||
export type DraftOrderItem = {
|
||||
/** Идентификатор продукта. */
|
||||
productId: string
|
||||
/** Название продукта. */
|
||||
productName: string
|
||||
/** Выбранное количество. */
|
||||
quantity: number
|
||||
/** Цена из product snapshot. */
|
||||
unitPriceCents: number
|
||||
/** Валюта продукта. */
|
||||
currency: 'USD'
|
||||
/** Версия из product snapshot. */
|
||||
expectedVersion: number
|
||||
/** Остаток, ограничивающий количество. */
|
||||
availableStock: number
|
||||
/** URL изображения продукта. */
|
||||
imageUrl: string
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Order } from './order.type'
|
||||
|
||||
/**
|
||||
* Страница доступных пользователю заказов.
|
||||
*/
|
||||
export type OrderPage = {
|
||||
/** Заказы текущей страницы. */
|
||||
orders: Order[]
|
||||
/** Номер текущей страницы. */
|
||||
page: number
|
||||
/** Лимит страницы. */
|
||||
limit: number
|
||||
/** Общее число доступных заказов. */
|
||||
total: number
|
||||
/** Общее число страниц. */
|
||||
totalPages: number
|
||||
}
|
||||
39
examples/react-vite/src/domains/orders/types/order.type.ts
Normal file
39
examples/react-vite/src/domains/orders/types/order.type.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/** Статус заказа Simple Store. */
|
||||
export type OrderStatus = 'pending' | 'paid' | 'shipped' | 'cancelled'
|
||||
|
||||
/** Валюта заказа. */
|
||||
export type OrderCurrency = 'USD' | 'EUR'
|
||||
|
||||
/**
|
||||
* Зафиксированная строка оформленного заказа.
|
||||
*/
|
||||
export type OrderItem = {
|
||||
/** Идентификатор купленного продукта. */
|
||||
productId: string
|
||||
/** Название продукта на момент оформления. */
|
||||
productName: string
|
||||
/** Купленное количество. */
|
||||
quantity: number
|
||||
/** Цена единицы на момент оформления. */
|
||||
unitPriceCents: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Заказ, доступный текущему пользователю.
|
||||
*/
|
||||
export type Order = {
|
||||
/** Стабильный идентификатор заказа. */
|
||||
id: string
|
||||
/** Идентификатор владельца заказа. */
|
||||
userId: string
|
||||
/** Текущее состояние заказа. */
|
||||
status: OrderStatus
|
||||
/** Зафиксированные строки заказа. */
|
||||
items: OrderItem[]
|
||||
/** Итоговая стоимость в минимальных единицах. */
|
||||
totalCents: number
|
||||
/** Валюта всего заказа. */
|
||||
currency: OrderCurrency
|
||||
/** ISO-дата оформления. */
|
||||
createdAt: string
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { DraftOrderItem, DraftOrderProduct } from './draft-order.type'
|
||||
import type { Order } from './order.type'
|
||||
|
||||
/**
|
||||
* Публичные возможности draft order и checkout.
|
||||
*/
|
||||
export type OrdersContextValue = {
|
||||
/** Текущие строки draft order. */
|
||||
items: DraftOrderItem[]
|
||||
/** Общее количество единиц в draft order. */
|
||||
itemCount: number
|
||||
/** Итоговая стоимость draft order в USD cents. */
|
||||
totalCents: number
|
||||
/** Последнее пользовательское сообщение checkout. */
|
||||
notice: string | null
|
||||
/** Последний успешно созданный заказ. */
|
||||
createdOrder: Order | null
|
||||
/** Выполняется ли checkout. */
|
||||
isCheckingOut: boolean
|
||||
/** Добавляет актуальный product snapshot в draft order. */
|
||||
addProduct: (product: DraftOrderProduct) => void
|
||||
/** Изменяет количество строки с учётом stock и API-лимита. */
|
||||
setQuantity: (productId: string, quantity: number) => void
|
||||
/** Удаляет продукт из draft order. */
|
||||
removeProduct: (productId: string) => void
|
||||
/** Очищает draft order. */
|
||||
clearDraft: () => void
|
||||
/** Проверяет snapshot на backend и создаёт заказ. */
|
||||
checkout: () => Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Props владельца draft order.
|
||||
*/
|
||||
export type OrdersProviderProps = {
|
||||
/** Storefront scope, внутри которого живёт draft order. */
|
||||
children: ReactNode
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import cl from 'clsx'
|
||||
|
||||
import { formatCurrency } from 'shared/lib/format'
|
||||
import { isEmptyArray, isNonEmptyArray } from 'shared/lib/value-predicates'
|
||||
import { Button } from 'ui/button'
|
||||
import { useOrders } from '../../hooks/use-orders.hook'
|
||||
import type { CartPanelProps } from './types/cart-panel-props.type'
|
||||
import styles from './styles/cart-panel.module.css'
|
||||
|
||||
/**
|
||||
* Панель draft order с количеством и checkout.
|
||||
*
|
||||
* Используется для:
|
||||
* - проверки выбранных product snapshots перед отправкой
|
||||
* - запуска единственного checkout-сценария домена orders
|
||||
*/
|
||||
export const CartPanel = (props: CartPanelProps) => {
|
||||
const { className, ...rootAttrs } = props
|
||||
const {
|
||||
items,
|
||||
itemCount,
|
||||
totalCents,
|
||||
notice,
|
||||
createdOrder,
|
||||
isCheckingOut,
|
||||
setQuantity,
|
||||
removeProduct,
|
||||
clearDraft,
|
||||
checkout
|
||||
} = useOrders()
|
||||
|
||||
return (
|
||||
<aside {...rootAttrs} className={cl(styles.root, className)}>
|
||||
<div className={styles.heading}>
|
||||
<div>
|
||||
<span className={styles.eyebrow}>Draft order</span>
|
||||
<h2>Корзина</h2>
|
||||
</div>
|
||||
<span className={styles.count}>{itemCount}</span>
|
||||
</div>
|
||||
|
||||
{isEmptyArray(items) && !createdOrder && (
|
||||
<div className={styles.empty}>
|
||||
<span className={styles.emptyMark}>+</span>
|
||||
<p>Выберите продукты в каталоге. Версия и цена сохранятся до checkout.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isNonEmptyArray(items) && (
|
||||
<div className={styles.items}>
|
||||
{items.map((item) => (
|
||||
<article key={item.productId} className={styles.item}>
|
||||
<img src={item.imageUrl} alt="" />
|
||||
<div className={styles.itemInfo}>
|
||||
<strong>{item.productName}</strong>
|
||||
<span>
|
||||
{formatCurrency(item.unitPriceCents, item.currency)} · v{item.expectedVersion}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.quantity}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Уменьшить количество ${item.productName}`}
|
||||
onClick={() => setQuantity(item.productId, item.quantity - 1)}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span>{item.quantity}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Увеличить количество ${item.productName}`}
|
||||
onClick={() => setQuantity(item.productId, item.quantity + 1)}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.remove}
|
||||
onClick={() => removeProduct(item.productId)}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notice && (
|
||||
<p className={styles.notice} role="alert">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{createdOrder && (
|
||||
<div className={styles.success} role="status">
|
||||
<span>Заказ создан</span>
|
||||
<strong>{createdOrder.id}</strong>
|
||||
<small>{formatCurrency(createdOrder.totalCents, createdOrder.currency)}</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isNonEmptyArray(items) && (
|
||||
<div className={styles.summary}>
|
||||
<div>
|
||||
<span>Итого</span>
|
||||
<strong>{formatCurrency(totalCents, 'USD')}</strong>
|
||||
</div>
|
||||
<Button isLoading={isCheckingOut} onClick={() => void checkout()}>
|
||||
Оформить заказ
|
||||
</Button>
|
||||
<Button variant="ghost" size="small" onClick={clearDraft}>
|
||||
Очистить
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CartPanel } from './cart-panel'
|
||||
export type { CartPanelProps } from './types/cart-panel-props.type'
|
||||
@@ -0,0 +1,196 @@
|
||||
.root {
|
||||
position: sticky;
|
||||
top: 5.8rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
align-self: start;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 1.2rem;
|
||||
background: var(--color-ink);
|
||||
box-shadow: 0 24px 60px rgb(22 37 30 / 16%);
|
||||
}
|
||||
|
||||
.heading,
|
||||
.summary > div,
|
||||
.item,
|
||||
.quantity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.heading,
|
||||
.summary > div {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.heading h2 {
|
||||
margin: 0.15rem 0 0;
|
||||
color: #fffaf0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.7rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--color-accent-light);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.63rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.count {
|
||||
display: grid;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
color: var(--color-ink);
|
||||
background: var(--color-accent-light);
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 0.65rem;
|
||||
min-height: 10rem;
|
||||
padding: 1rem;
|
||||
border: 1px dashed rgb(255 255 255 / 20%);
|
||||
border-radius: 0.9rem;
|
||||
color: rgb(255 250 240 / 62%);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.emptyMark {
|
||||
display: grid;
|
||||
width: 2.6rem;
|
||||
height: 2.6rem;
|
||||
place-items: center;
|
||||
border: 1px solid rgb(255 255 255 / 20%);
|
||||
border-radius: 50%;
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
|
||||
.items {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
max-height: 22rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: grid;
|
||||
grid-template-columns: 2.7rem minmax(0, 1fr) auto;
|
||||
gap: 0.6rem;
|
||||
padding: 0.65rem;
|
||||
border-radius: 0.85rem;
|
||||
background: rgb(255 255 255 / 7%);
|
||||
}
|
||||
|
||||
.item img {
|
||||
width: 2.7rem;
|
||||
height: 2.7rem;
|
||||
border-radius: 0.65rem;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.itemInfo {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
color: rgb(255 250 240 / 58%);
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.itemInfo strong {
|
||||
overflow: hidden;
|
||||
color: #fffaf0;
|
||||
font-size: 0.77rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quantity {
|
||||
gap: 0.4rem;
|
||||
color: #fffaf0;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.quantity button {
|
||||
display: grid;
|
||||
width: 1.45rem;
|
||||
height: 1.45rem;
|
||||
place-items: center;
|
||||
border: 1px solid rgb(255 255 255 / 18%);
|
||||
border-radius: 50%;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.remove {
|
||||
grid-column: 2 / -1;
|
||||
justify-self: start;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
color: #f5a9a1;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 0.66rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.notice,
|
||||
.success {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.notice {
|
||||
color: #ffd5cf;
|
||||
background: rgb(180 61 47 / 24%);
|
||||
}
|
||||
|
||||
.success {
|
||||
display: grid;
|
||||
color: var(--color-accent-light);
|
||||
background: rgb(92 194 148 / 12%);
|
||||
}
|
||||
|
||||
.success strong {
|
||||
color: #fffaf0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
padding-top: 0.9rem;
|
||||
border-top: 1px solid rgb(255 255 255 / 12%);
|
||||
}
|
||||
|
||||
.summary span {
|
||||
color: rgb(255 250 240 / 58%);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.summary strong {
|
||||
color: #fffaf0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.root {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
/** Собственные параметры CartPanel. */
|
||||
export type CartPanelParams = object
|
||||
|
||||
/** Атрибуты корневого aside без children. */
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'aside'>, 'children'>
|
||||
|
||||
/** Props панели draft order. */
|
||||
export type CartPanelProps = RootAttrs & CartPanelParams
|
||||
@@ -0,0 +1,2 @@
|
||||
export { OrderHistory } from './order-history'
|
||||
export type { OrderHistoryProps } from './types/order-history-props.type'
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useState } from 'react'
|
||||
import cl from 'clsx'
|
||||
|
||||
import { formatCurrency, formatDate } from 'shared/lib/format'
|
||||
import { isEmptyArray, isNonEmptyArray } from 'shared/lib/value-predicates'
|
||||
import { Button } from 'ui/button'
|
||||
import { OrderError } from '../../errors/order.error'
|
||||
import { useOrderHistory } from '../../hooks/use-order-history.hook'
|
||||
import { cancelOrder } from '../../source/orders.source'
|
||||
import type { OrderHistoryProps } from './types/order-history-props.type'
|
||||
import styles from './styles/order-history.module.css'
|
||||
|
||||
const STATUS_LABELS = {
|
||||
pending: 'Ожидает оплаты',
|
||||
paid: 'Оплачен',
|
||||
shipped: 'Отправлен',
|
||||
cancelled: 'Отменён'
|
||||
} as const
|
||||
|
||||
/**
|
||||
* История доступных пользователю заказов и допустимая отмена.
|
||||
*
|
||||
* Используется для:
|
||||
* - просмотра customer-owned или всех admin-заказов
|
||||
* - выполнения разрешённого status transition в cancelled
|
||||
*/
|
||||
export const OrderHistory = (props: OrderHistoryProps) => {
|
||||
const { className, ...rootAttrs } = props
|
||||
const { page, isLoading, error, refresh } = useOrderHistory()
|
||||
const [mutationError, setMutationError] = useState<string | null>(null)
|
||||
const [cancellingOrderId, setCancellingOrderId] = useState<string | null>(null)
|
||||
|
||||
/**
|
||||
* Отменяет заказ и повторно получает серверную историю.
|
||||
*/
|
||||
const handleCancel = async (orderId: string): Promise<void> => {
|
||||
setMutationError(null)
|
||||
setCancellingOrderId(orderId)
|
||||
|
||||
try {
|
||||
await cancelOrder(orderId)
|
||||
await refresh()
|
||||
} catch (cancelError) {
|
||||
const message =
|
||||
cancelError instanceof OrderError ? cancelError.message : 'Не удалось отменить заказ.'
|
||||
setMutationError(message)
|
||||
} finally {
|
||||
setCancellingOrderId(null)
|
||||
}
|
||||
}
|
||||
|
||||
let content = <div className={styles.state}>Загружаем историю…</div>
|
||||
|
||||
if (error) {
|
||||
content = (
|
||||
<div className={styles.state} role="alert">
|
||||
<strong>История недоступна</strong>
|
||||
<span>{error.message}</span>
|
||||
<Button variant="secondary" size="small" onClick={() => void refresh()}>
|
||||
Повторить
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isLoading && page && isEmptyArray(page.orders)) {
|
||||
content = <div className={styles.state}>Заказов пока нет. Соберите первый draft.</div>
|
||||
}
|
||||
|
||||
if (page && isNonEmptyArray(page.orders)) {
|
||||
content = (
|
||||
<div className={styles.list}>
|
||||
{page.orders.map((order) => {
|
||||
const canCancel = order.status === 'pending' || order.status === 'paid'
|
||||
const itemSummary = order.items
|
||||
.map((item) => `${item.productName} × ${item.quantity}`)
|
||||
.join(', ')
|
||||
|
||||
return (
|
||||
<article key={order.id} className={styles.order}>
|
||||
<div className={styles.orderTopline}>
|
||||
<div>
|
||||
<span className={styles.orderId}>{order.id}</span>
|
||||
<span className={cl(styles.status, styles[order.status])}>
|
||||
{STATUS_LABELS[order.status]}
|
||||
</span>
|
||||
</div>
|
||||
<strong>{formatCurrency(order.totalCents, order.currency)}</strong>
|
||||
</div>
|
||||
<p>{itemSummary}</p>
|
||||
<div className={styles.orderFooter}>
|
||||
<span>{formatDate(order.createdAt)}</span>
|
||||
<span>Владелец: {order.userId}</span>
|
||||
{canCancel && (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="small"
|
||||
isLoading={cancellingOrderId === order.id}
|
||||
onClick={() => void handleCancel(order.id)}
|
||||
>
|
||||
Отменить
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section {...rootAttrs} className={cl(styles.root, className)}>
|
||||
<div className={styles.heading}>
|
||||
<div>
|
||||
<span className={styles.eyebrow}>Protected resource</span>
|
||||
<h2>История заказов</h2>
|
||||
</div>
|
||||
{page && <span>{page.total} записей</span>}
|
||||
</div>
|
||||
{mutationError && (
|
||||
<p className={styles.error} role="alert">
|
||||
{mutationError}
|
||||
</p>
|
||||
)}
|
||||
{content}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
.root {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.heading,
|
||||
.orderTopline,
|
||||
.orderFooter,
|
||||
.orderTopline > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.heading h2 {
|
||||
margin: 0.15rem 0 0;
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.8rem;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
|
||||
.heading > span,
|
||||
.eyebrow,
|
||||
.orderId {
|
||||
color: var(--color-ink-faint);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.67rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.order {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 255 255 / 62%);
|
||||
}
|
||||
|
||||
.orderTopline strong {
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.12rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 0.28rem 0.48rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.pending {
|
||||
color: #8a5e10;
|
||||
background: #fff1c7;
|
||||
}
|
||||
|
||||
.paid {
|
||||
color: #176042;
|
||||
background: #dff5e9;
|
||||
}
|
||||
|
||||
.shipped {
|
||||
color: #20598a;
|
||||
background: #e0f0ff;
|
||||
}
|
||||
|
||||
.cancelled {
|
||||
color: var(--color-ink-muted);
|
||||
background: var(--color-surface-strong);
|
||||
}
|
||||
|
||||
.order p {
|
||||
min-height: 2.5rem;
|
||||
margin: 0;
|
||||
color: var(--color-ink-muted);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.orderFooter {
|
||||
justify-content: flex-start;
|
||||
color: var(--color-ink-faint);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.orderFooter button {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.state {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 8rem;
|
||||
padding: 1rem;
|
||||
border: 1px dashed var(--color-line-strong);
|
||||
border-radius: 1rem;
|
||||
color: var(--color-ink-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.state strong {
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
padding: 0.7rem;
|
||||
border-radius: 0.7rem;
|
||||
color: var(--color-danger);
|
||||
background: var(--color-danger-soft);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
@media (max-width: 740px) {
|
||||
.list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.orderFooter {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.orderFooter button {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
/** Собственные параметры OrderHistory. */
|
||||
export type OrderHistoryParams = object
|
||||
|
||||
/** Атрибуты корневой section без children. */
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'section'>, 'children'>
|
||||
|
||||
/** Props истории доступных заказов. */
|
||||
export type OrderHistoryProps = RootAttrs & OrderHistoryParams
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
import type { SessionContextValue } from '../types/session-context-value.type'
|
||||
|
||||
/** React-контекст application-scoped пользовательской сессии. */
|
||||
export const SessionContext = createContext<SessionContextValue | null>(null)
|
||||
@@ -0,0 +1,20 @@
|
||||
/** Ожидаемый неуспешный исход сценария сессии. */
|
||||
export type SessionErrorCode =
|
||||
| 'invalid-credentials'
|
||||
| 'rate-limited'
|
||||
| 'expired'
|
||||
| 'unavailable'
|
||||
|
||||
/**
|
||||
* Доменная ошибка входа или восстановления сессии.
|
||||
*/
|
||||
export class SessionError extends Error {
|
||||
/** Стабильный код ожидаемого исхода. */
|
||||
readonly code: SessionErrorCode
|
||||
|
||||
constructor(code: SessionErrorCode, message: string) {
|
||||
super(message)
|
||||
this.name = 'SessionError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useSession } from './use-session.hook'
|
||||
import type { SessionState } from '../types/session-state.type'
|
||||
|
||||
/**
|
||||
* Возвращает read-only состояние сессии для app и compositions.
|
||||
*/
|
||||
export const useSessionState = (): SessionState => {
|
||||
const { user, status } = useSession()
|
||||
|
||||
return { user, status }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { use } from 'react'
|
||||
|
||||
import { SessionContext } from '../context/session.context'
|
||||
import type { SessionContextValue } from '../types/session-context-value.type'
|
||||
|
||||
/**
|
||||
* Возвращает публичные возможности текущей пользовательской сессии.
|
||||
*/
|
||||
export const useSession = (): SessionContextValue => {
|
||||
const session = use(SessionContext)
|
||||
|
||||
if (!session) {
|
||||
throw new Error('useSession must be used inside SessionProvider')
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
7
examples/react-vite/src/domains/session/index.ts
Normal file
7
examples/react-vite/src/domains/session/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { useSessionState } from './hooks/use-session-state.hook'
|
||||
export { SessionProvider } from './session.provider'
|
||||
export { SessionBadge } from './ui/session-badge'
|
||||
export { SignInForm } from './ui/sign-in-form'
|
||||
export type { SessionProviderProps } from './types/session-provider-props.type'
|
||||
export type { SessionState } from './types/session-state.type'
|
||||
export type { SessionRole, SessionUser } from './types/session-user.type'
|
||||
87
examples/react-vite/src/domains/session/session.provider.tsx
Normal file
87
examples/react-vite/src/domains/session/session.provider.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useEffectEvent, useState } from 'react'
|
||||
|
||||
import { subscribeSimpleRestApiSessionExpired } from 'infra/simple-rest-api'
|
||||
import { SessionContext } from './context/session.context'
|
||||
import { loginSession, logoutSession, restoreSession } from './source/session.source'
|
||||
import type { SessionCredentials } from './types/session-credentials.type'
|
||||
import type { SessionProviderProps } from './types/session-provider-props.type'
|
||||
import type { SessionStatus } from './types/session-context-value.type'
|
||||
import type { SessionUser } from './types/session-user.type'
|
||||
|
||||
/**
|
||||
* Владелец application-scoped пользовательской сессии.
|
||||
*
|
||||
* Используется для:
|
||||
* - восстановления пользователя при загрузке browser-приложения
|
||||
* - синхронизации login, logout и окончательного истечения credentials
|
||||
*/
|
||||
export const SessionProvider = (props: SessionProviderProps) => {
|
||||
const { children, onSessionClosed } = props
|
||||
const [user, setUser] = useState<SessionUser | null>(null)
|
||||
const [status, setStatus] = useState<SessionStatus>('restoring')
|
||||
|
||||
const handleSessionExpired = useEffectEvent(() => {
|
||||
setUser(null)
|
||||
setStatus('anonymous')
|
||||
onSessionClosed?.()
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true
|
||||
|
||||
void restoreSession()
|
||||
.then((restoredUser) => {
|
||||
if (!isActive) {
|
||||
return
|
||||
}
|
||||
|
||||
setUser(restoredUser)
|
||||
setStatus(restoredUser ? 'authenticated' : 'anonymous')
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isActive) {
|
||||
return
|
||||
}
|
||||
|
||||
handleSessionExpired()
|
||||
})
|
||||
|
||||
return () => {
|
||||
isActive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeSimpleRestApiSessionExpired(handleSessionExpired)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Выполняет вход и публикует нового пользователя в session context.
|
||||
*/
|
||||
const login = async (credentials: SessionCredentials): Promise<void> => {
|
||||
const authenticatedUser = await loginSession(credentials)
|
||||
setUser(authenticatedUser)
|
||||
setStatus('authenticated')
|
||||
}
|
||||
|
||||
/**
|
||||
* Завершает сессию независимо от доступности logout endpoint.
|
||||
*/
|
||||
const logout = async (): Promise<void> => {
|
||||
try {
|
||||
await logoutSession()
|
||||
} catch {
|
||||
// Local session still closes when the idempotent revoke endpoint is unavailable.
|
||||
} finally {
|
||||
setUser(null)
|
||||
setStatus('anonymous')
|
||||
onSessionClosed?.()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionContext value={{ user, status, login, logout }}>
|
||||
{children}
|
||||
</SessionContext>
|
||||
)
|
||||
}
|
||||
104
examples/react-vite/src/domains/session/source/session.source.ts
Normal file
104
examples/react-vite/src/domains/session/source/session.source.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
clearSimpleRestApiTokens,
|
||||
getSimpleRestApiRefreshToken,
|
||||
hasSimpleRestApiRefreshToken,
|
||||
setSimpleRestApiTokens,
|
||||
simpleRestApi,
|
||||
toSimpleRestApiError
|
||||
} from 'infra/simple-rest-api'
|
||||
import type { SessionCredentials } from '../types/session-credentials.type'
|
||||
import type { SessionUser } from '../types/session-user.type'
|
||||
import { SessionError } from '../errors/session.error'
|
||||
|
||||
const sessionUserSchema = z.object({
|
||||
id: z.string(),
|
||||
email: z.email(),
|
||||
name: z.string(),
|
||||
role: z.enum(['admin', 'customer']),
|
||||
avatarUrl: z.url().nullable()
|
||||
})
|
||||
|
||||
const authResponseSchema = z.object({
|
||||
data: z.object({
|
||||
tokens: z.object({
|
||||
accessToken: z.string(),
|
||||
refreshToken: z.string(),
|
||||
expiresIn: z.number(),
|
||||
tokenType: z.literal('Bearer')
|
||||
}),
|
||||
user: sessionUserSchema
|
||||
})
|
||||
})
|
||||
|
||||
const userResponseSchema = z.object({ data: sessionUserSchema })
|
||||
|
||||
/**
|
||||
* Преобразует source failure в ожидаемый исход домена сессии.
|
||||
*/
|
||||
const mapSessionError = (error: unknown): SessionError => {
|
||||
const apiError = toSimpleRestApiError(error)
|
||||
|
||||
if (apiError.code === 'INVALID_CREDENTIALS') {
|
||||
return new SessionError('invalid-credentials', 'Неверная почта или пароль.')
|
||||
}
|
||||
|
||||
if (apiError.status === 429) {
|
||||
return new SessionError('rate-limited', 'Слишком много попыток. Повторите через несколько секунд.')
|
||||
}
|
||||
|
||||
if (apiError.status === 401) {
|
||||
return new SessionError('expired', 'Сессия истекла. Войдите снова.')
|
||||
}
|
||||
|
||||
return new SessionError('unavailable', 'Simple API недоступен. Проверьте, запущен ли demo-backend.')
|
||||
}
|
||||
|
||||
/**
|
||||
* Открывает сессию по demo-credentials и сохраняет transport tokens.
|
||||
*/
|
||||
export const loginSession = async (credentials: SessionCredentials): Promise<SessionUser> => {
|
||||
try {
|
||||
const response = await simpleRestApi.auth.simpleAuthLogin(credentials)
|
||||
const parsedResponse = authResponseSchema.parse(response)
|
||||
|
||||
setSimpleRestApiTokens(parsedResponse.data.tokens)
|
||||
|
||||
return parsedResponse.data.user
|
||||
} catch (error) {
|
||||
throw mapSessionError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Восстанавливает пользователя через сохранённый refresh token.
|
||||
*/
|
||||
export const restoreSession = async (): Promise<SessionUser | null> => {
|
||||
if (!hasSimpleRestApiRefreshToken()) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await simpleRestApi.users.simpleUsersMe()
|
||||
return userResponseSchema.parse(response).data
|
||||
} catch (error) {
|
||||
clearSimpleRestApiTokens()
|
||||
throw mapSessionError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отзывает refresh token и всегда очищает локальные credentials.
|
||||
*/
|
||||
export const logoutSession = async (): Promise<void> => {
|
||||
const refreshToken = getSimpleRestApiRefreshToken()
|
||||
|
||||
try {
|
||||
if (refreshToken) {
|
||||
await simpleRestApi.auth.simpleAuthLogout({ refreshToken })
|
||||
}
|
||||
} finally {
|
||||
clearSimpleRestApiTokens()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { SessionCredentials } from './session-credentials.type'
|
||||
import type { SessionUser } from './session-user.type'
|
||||
|
||||
/** Состояние восстановления пользовательской сессии. */
|
||||
export type SessionStatus = 'restoring' | 'authenticated' | 'anonymous'
|
||||
|
||||
/**
|
||||
* Публичные возможности домена пользовательской сессии.
|
||||
*/
|
||||
export type SessionContextValue = {
|
||||
/** Текущий пользователь или null вне авторизованной сессии. */
|
||||
user: SessionUser | null
|
||||
/** Текущее состояние lifecycle сессии. */
|
||||
status: SessionStatus
|
||||
/** Выполняет вход и открывает новую пользовательскую сессию. */
|
||||
login: (credentials: SessionCredentials) => Promise<void>
|
||||
/** Завершает пользовательскую сессию и отзывает refresh token. */
|
||||
logout: () => Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Credentials формы входа.
|
||||
*/
|
||||
export type SessionCredentials = {
|
||||
/** Email demo-пользователя. */
|
||||
email: string
|
||||
/** Пароль demo-пользователя. */
|
||||
password: string
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Props application-scoped SessionProvider.
|
||||
*/
|
||||
export type SessionProviderProps = {
|
||||
/** Browser-приложение, использующее одну пользовательскую сессию. */
|
||||
children: ReactNode
|
||||
/** Сообщает app assembly, что session-scoped технические данные нужно очистить. */
|
||||
onSessionClosed?: () => void
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { SessionStatus } from './session-context-value.type'
|
||||
import type { SessionUser } from './session-user.type'
|
||||
|
||||
/**
|
||||
* Публичное read-only состояние пользовательской сессии.
|
||||
*/
|
||||
export type SessionState = {
|
||||
/** Текущий пользователь или null вне авторизованной сессии. */
|
||||
user: SessionUser | null
|
||||
/** Текущее состояние session lifecycle. */
|
||||
status: SessionStatus
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/** Роль пользователя в Simple Store. */
|
||||
export type SessionRole = 'admin' | 'customer'
|
||||
|
||||
/**
|
||||
* Пользователь, с которым связана текущая browser-сессия.
|
||||
*/
|
||||
export type SessionUser = {
|
||||
/** Стабильный идентификатор пользователя. */
|
||||
id: string
|
||||
/** Email для входа и отображения. */
|
||||
email: string
|
||||
/** Отображаемое имя. */
|
||||
name: string
|
||||
/** Роль, определяющая доступные продуктовые действия. */
|
||||
role: SessionRole
|
||||
/** URL аватара или null для текстового fallback. */
|
||||
avatarUrl: string | null
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SessionBadge } from './session-badge'
|
||||
export type { SessionBadgeProps } from './types/session-badge-props.type'
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState } from 'react'
|
||||
import cl from 'clsx'
|
||||
|
||||
import { Button } from 'ui/button'
|
||||
import { useSession } from '../../hooks/use-session.hook'
|
||||
import type { SessionBadgeProps } from './types/session-badge-props.type'
|
||||
import styles from './styles/session-badge.module.css'
|
||||
|
||||
/**
|
||||
* Краткое представление пользователя и действие выхода.
|
||||
*
|
||||
* Используется для:
|
||||
* - отображения активной роли в application shell
|
||||
* - завершения пользовательской сессии
|
||||
*/
|
||||
export const SessionBadge = (props: SessionBadgeProps) => {
|
||||
const { className, ...rootAttrs } = props
|
||||
const { user, logout } = useSession()
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false)
|
||||
|
||||
/**
|
||||
* Завершает сессию и блокирует повторное действие до очистки контекста.
|
||||
*/
|
||||
const handleLogout = async (): Promise<void> => {
|
||||
setIsLoggingOut(true)
|
||||
await logout()
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null
|
||||
}
|
||||
|
||||
const roleLabel = user.role === 'admin' ? 'Администратор' : 'Покупатель'
|
||||
const initials = user.name
|
||||
.split(' ')
|
||||
.map((part) => part.slice(0, 1))
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
|
||||
let avatar = <span className={styles.avatarFallback}>{initials}</span>
|
||||
|
||||
if (user.avatarUrl) {
|
||||
avatar = <img className={styles.avatar} src={user.avatarUrl} alt="" />
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...rootAttrs} className={cl(styles.root, className)}>
|
||||
{avatar}
|
||||
<span className={styles.identity}>
|
||||
<strong>{user.name}</strong>
|
||||
<span>{roleLabel}</span>
|
||||
</span>
|
||||
<Button variant="ghost" size="small" isLoading={isLoggingOut} onClick={handleLogout}>
|
||||
Выйти
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
.root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.avatar,
|
||||
.avatarFallback {
|
||||
width: 2.35rem;
|
||||
height: 2.35rem;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatarFallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
background: var(--color-accent-strong);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: grid;
|
||||
min-width: 8rem;
|
||||
color: var(--color-ink-muted);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.identity strong {
|
||||
overflow: hidden;
|
||||
color: var(--color-ink);
|
||||
font-size: 0.84rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.identity {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
/** Собственные параметры SessionBadge. */
|
||||
export type SessionBadgeParams = object
|
||||
|
||||
/** Атрибуты корневого div без children. */
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'div'>, 'children'>
|
||||
|
||||
/** Props краткого представления текущей сессии. */
|
||||
export type SessionBadgeProps = RootAttrs & SessionBadgeParams
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SignInForm } from './sign-in-form'
|
||||
export type { SignInFormProps } from './types/sign-in-form-props.type'
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import cl from 'clsx'
|
||||
|
||||
import { Button } from 'ui/button'
|
||||
import { Field } from 'ui/field'
|
||||
import { SessionError } from '../../errors/session.error'
|
||||
import { useSession } from '../../hooks/use-session.hook'
|
||||
import type { SignInFormProps } from './types/sign-in-form-props.type'
|
||||
import styles from './styles/sign-in-form.module.css'
|
||||
|
||||
const DEMO_PASSWORD = 'demo1234'
|
||||
|
||||
/**
|
||||
* Форма входа в Simple Store с быстрым выбором demo-роли.
|
||||
*
|
||||
* Используется для:
|
||||
* - входа администратора для управления каталогом
|
||||
* - входа покупателя для оформления и просмотра заказов
|
||||
*/
|
||||
export const SignInForm = (props: SignInFormProps) => {
|
||||
const { className, ...rootAttrs } = props
|
||||
const { login } = useSession()
|
||||
const [email, setEmail] = useState('admin@demo.local')
|
||||
const [password, setPassword] = useState(DEMO_PASSWORD)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
/**
|
||||
* Проверяет credentials через session domain и показывает ожидаемый исход.
|
||||
*/
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault()
|
||||
setErrorMessage(null)
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
await login({ email, password })
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof SessionError ? error.message : 'Не удалось выполнить вход.'
|
||||
setErrorMessage(message)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
{...rootAttrs}
|
||||
className={cl(styles.root, className)}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div className={styles.accounts} aria-label="Demo accounts">
|
||||
<button
|
||||
type="button"
|
||||
className={cl(styles.account, email === 'admin@demo.local' && styles.accountActive)}
|
||||
onClick={() => setEmail('admin@demo.local')}
|
||||
>
|
||||
<span className={styles.accountRole}>Администратор</span>
|
||||
<span>Каталог и заказы</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cl(styles.account, email === 'customer@demo.local' && styles.accountActive)}
|
||||
onClick={() => setEmail('customer@demo.local')}
|
||||
>
|
||||
<span className={styles.accountRole}>Покупатель</span>
|
||||
<span>Покупки и история</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="Email"
|
||||
inputProps={{
|
||||
type: 'email',
|
||||
name: 'email',
|
||||
autoComplete: 'username',
|
||||
value: email,
|
||||
onChange: (event) => setEmail(event.currentTarget.value),
|
||||
required: true
|
||||
}}
|
||||
/>
|
||||
<Field
|
||||
label="Пароль"
|
||||
hint="Для обеих demo-учётных записей: demo1234"
|
||||
inputProps={{
|
||||
type: 'password',
|
||||
name: 'password',
|
||||
autoComplete: 'current-password',
|
||||
value: password,
|
||||
minLength: 8,
|
||||
onChange: (event) => setPassword(event.currentTarget.value),
|
||||
required: true
|
||||
}}
|
||||
/>
|
||||
|
||||
{errorMessage && (
|
||||
<p className={styles.error} role="alert">
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" isLoading={isSubmitting}>
|
||||
Войти в магазин
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
.root {
|
||||
display: grid;
|
||||
gap: 1.1rem;
|
||||
}
|
||||
|
||||
.accounts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.account {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 0.9rem;
|
||||
color: var(--color-ink-muted);
|
||||
background: rgb(255 255 255 / 55%);
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.account:hover,
|
||||
.account:focus-visible {
|
||||
border-color: var(--color-accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.accountActive {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--color-accent);
|
||||
}
|
||||
|
||||
.accountRole {
|
||||
color: var(--color-ink);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
padding: 0.8rem 0.9rem;
|
||||
border-radius: 0.75rem;
|
||||
color: var(--color-danger);
|
||||
background: var(--color-danger-soft);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.accounts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
/** Собственные параметры SignInForm. */
|
||||
export type SignInFormParams = object
|
||||
|
||||
/** Атрибуты корневой form. */
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'form'>, 'children' | 'onSubmit'>
|
||||
|
||||
/** Props формы входа. */
|
||||
export type SignInFormProps = RootAttrs & SignInFormParams
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SimpleRestApiError } from './simple-rest-api.error'
|
||||
export { toSimpleRestApiError } from './to-simple-rest-api-error'
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user