mirror of
https://github.com/gromlab-ru/slm-design.git
synced 2026-08-22 07:30:16 +03:00
feat: add example
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
"deleteOutDir": false,
|
||||
"tsConfigPath": "tsconfig.build.json"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,9 +40,7 @@
|
||||
}
|
||||
},
|
||||
"summary": "Check Simple API availability",
|
||||
"tags": [
|
||||
"Health"
|
||||
]
|
||||
"tags": ["Health"]
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/login": {
|
||||
@@ -124,9 +122,7 @@
|
||||
}
|
||||
},
|
||||
"summary": "Login and receive JWT access/refresh tokens",
|
||||
"tags": [
|
||||
"Auth"
|
||||
]
|
||||
"tags": ["Auth"]
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/refresh": {
|
||||
@@ -228,9 +224,7 @@
|
||||
}
|
||||
},
|
||||
"summary": "Rotate a refresh token and issue a new token pair",
|
||||
"tags": [
|
||||
"Auth"
|
||||
]
|
||||
"tags": ["Auth"]
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/logout": {
|
||||
@@ -305,9 +299,7 @@
|
||||
}
|
||||
},
|
||||
"summary": "Revoke a refresh token; the operation is idempotent",
|
||||
"tags": [
|
||||
"Auth"
|
||||
]
|
||||
"tags": ["Auth"]
|
||||
}
|
||||
},
|
||||
"/api/v1/users/me": {
|
||||
@@ -404,9 +396,7 @@
|
||||
}
|
||||
],
|
||||
"summary": "Get the authenticated user",
|
||||
"tags": [
|
||||
"Users"
|
||||
]
|
||||
"tags": ["Users"]
|
||||
}
|
||||
},
|
||||
"/api/v1/products": {
|
||||
@@ -488,12 +478,7 @@
|
||||
"schema": {
|
||||
"default": "newest",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"newest",
|
||||
"price-asc",
|
||||
"price-desc",
|
||||
"name"
|
||||
]
|
||||
"enum": ["newest", "price-asc", "price-desc", "name"]
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -540,9 +525,7 @@
|
||||
}
|
||||
},
|
||||
"summary": "List products using offset pagination, filters and sorting",
|
||||
"tags": [
|
||||
"Products"
|
||||
]
|
||||
"tags": ["Products"]
|
||||
},
|
||||
"post": {
|
||||
"operationId": "SimpleProducts_create",
|
||||
@@ -647,9 +630,7 @@
|
||||
}
|
||||
],
|
||||
"summary": "Create a product as an administrator",
|
||||
"tags": [
|
||||
"Products"
|
||||
]
|
||||
"tags": ["Products"]
|
||||
}
|
||||
},
|
||||
"/api/v1/products/{id}": {
|
||||
@@ -742,9 +723,7 @@
|
||||
}
|
||||
},
|
||||
"summary": "Get one product with ETag support",
|
||||
"tags": [
|
||||
"Products"
|
||||
]
|
||||
"tags": ["Products"]
|
||||
},
|
||||
"patch": {
|
||||
"operationId": "SimpleProducts_update",
|
||||
@@ -877,9 +856,7 @@
|
||||
}
|
||||
],
|
||||
"summary": "Update a product using optimistic locking",
|
||||
"tags": [
|
||||
"Products"
|
||||
]
|
||||
"tags": ["Products"]
|
||||
},
|
||||
"delete": {
|
||||
"operationId": "SimpleProducts_remove",
|
||||
@@ -992,9 +969,7 @@
|
||||
}
|
||||
],
|
||||
"summary": "Delete a product as an administrator",
|
||||
"tags": [
|
||||
"Products"
|
||||
]
|
||||
"tags": ["Products"]
|
||||
}
|
||||
},
|
||||
"/api/v1/categories": {
|
||||
@@ -1036,9 +1011,7 @@
|
||||
}
|
||||
},
|
||||
"summary": "List product categories",
|
||||
"tags": [
|
||||
"Categories"
|
||||
]
|
||||
"tags": ["Categories"]
|
||||
}
|
||||
},
|
||||
"/api/v1/categories/{id}": {
|
||||
@@ -1128,9 +1101,7 @@
|
||||
}
|
||||
},
|
||||
"summary": "Get one category",
|
||||
"tags": [
|
||||
"Categories"
|
||||
]
|
||||
"tags": ["Categories"]
|
||||
}
|
||||
},
|
||||
"/api/v1/orders": {
|
||||
@@ -1256,9 +1227,7 @@
|
||||
}
|
||||
],
|
||||
"summary": "List orders visible to the current user",
|
||||
"tags": [
|
||||
"Orders"
|
||||
]
|
||||
"tags": ["Orders"]
|
||||
},
|
||||
"post": {
|
||||
"operationId": "SimpleOrders_create",
|
||||
@@ -1346,6 +1315,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "The validated request violates an order invariant.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Demo rate limit scenario.",
|
||||
"content": {
|
||||
@@ -1373,9 +1352,7 @@
|
||||
}
|
||||
],
|
||||
"summary": "Create an order and validate product stock",
|
||||
"tags": [
|
||||
"Orders"
|
||||
]
|
||||
"tags": ["Orders"]
|
||||
}
|
||||
},
|
||||
"/api/v1/orders/{id}": {
|
||||
@@ -1490,9 +1467,7 @@
|
||||
}
|
||||
],
|
||||
"summary": "Get one visible order",
|
||||
"tags": [
|
||||
"Orders"
|
||||
]
|
||||
"tags": ["Orders"]
|
||||
}
|
||||
},
|
||||
"/api/v1/orders/{id}/cancel": {
|
||||
@@ -1617,9 +1592,7 @@
|
||||
}
|
||||
],
|
||||
"summary": "Cancel an order if its state permits the transition",
|
||||
"tags": [
|
||||
"Orders"
|
||||
]
|
||||
"tags": ["Orders"]
|
||||
}
|
||||
},
|
||||
"/api/v1/testing/scenarios": {
|
||||
@@ -1661,9 +1634,7 @@
|
||||
}
|
||||
},
|
||||
"summary": "List deterministic X-Demo-Scenario values",
|
||||
"tags": [
|
||||
"Testing"
|
||||
]
|
||||
"tags": ["Testing"]
|
||||
}
|
||||
},
|
||||
"/api/v1/testing/reset": {
|
||||
@@ -1705,9 +1676,7 @@
|
||||
}
|
||||
},
|
||||
"summary": "Reset all Simple API state and token revocations",
|
||||
"tags": [
|
||||
"Testing"
|
||||
]
|
||||
"tags": ["Testing"]
|
||||
}
|
||||
},
|
||||
"/api/v1/testing/seed/{preset}": {
|
||||
@@ -1740,10 +1709,7 @@
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"enum": [
|
||||
"small",
|
||||
"large"
|
||||
],
|
||||
"enum": ["small", "large"],
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
@@ -1761,9 +1727,7 @@
|
||||
}
|
||||
},
|
||||
"summary": "Select a small or large deterministic dataset",
|
||||
"tags": [
|
||||
"Testing"
|
||||
]
|
||||
"tags": ["Testing"]
|
||||
}
|
||||
},
|
||||
"/api/v1/testing/users/{userId}/role": {
|
||||
@@ -1863,9 +1827,7 @@
|
||||
}
|
||||
},
|
||||
"summary": "Change a user role to exercise dynamic access control",
|
||||
"tags": [
|
||||
"Testing"
|
||||
]
|
||||
"tags": ["Testing"]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1896,17 +1858,12 @@
|
||||
"properties": {
|
||||
"application": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"simple",
|
||||
"complex"
|
||||
],
|
||||
"enum": ["simple", "complex"],
|
||||
"example": "simple"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ok"
|
||||
],
|
||||
"enum": ["ok"],
|
||||
"example": "ok"
|
||||
},
|
||||
"timestamp": {
|
||||
@@ -1918,12 +1875,7 @@
|
||||
"example": "1.0.0"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"application",
|
||||
"status",
|
||||
"timestamp",
|
||||
"version"
|
||||
]
|
||||
"required": ["application", "status", "timestamp", "version"]
|
||||
},
|
||||
"HealthResponseDto": {
|
||||
"type": "object",
|
||||
@@ -1932,9 +1884,7 @@
|
||||
"$ref": "#/components/schemas/HealthDataDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
]
|
||||
"required": ["data"]
|
||||
},
|
||||
"LoginDto": {
|
||||
"type": "object",
|
||||
@@ -1951,10 +1901,7 @@
|
||||
"minLength": 8
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"email",
|
||||
"password"
|
||||
]
|
||||
"required": ["email", "password"]
|
||||
},
|
||||
"JwtTokensDto": {
|
||||
"type": "object",
|
||||
@@ -1972,18 +1919,11 @@
|
||||
},
|
||||
"tokenType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"Bearer"
|
||||
],
|
||||
"enum": ["Bearer"],
|
||||
"example": "Bearer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"accessToken",
|
||||
"refreshToken",
|
||||
"expiresIn",
|
||||
"tokenType"
|
||||
]
|
||||
"required": ["accessToken", "refreshToken", "expiresIn", "tokenType"]
|
||||
},
|
||||
"SimpleUserDto": {
|
||||
"type": "object",
|
||||
@@ -2003,10 +1943,7 @@
|
||||
},
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"admin",
|
||||
"customer"
|
||||
]
|
||||
"enum": ["admin", "customer"]
|
||||
},
|
||||
"avatarUrl": {
|
||||
"type": "object",
|
||||
@@ -2014,13 +1951,7 @@
|
||||
"example": "https://i.pravatar.cc/160?img=12"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"email",
|
||||
"name",
|
||||
"role",
|
||||
"avatarUrl"
|
||||
]
|
||||
"required": ["id", "email", "name", "role", "avatarUrl"]
|
||||
},
|
||||
"JwtAuthDataDto": {
|
||||
"type": "object",
|
||||
@@ -2032,10 +1963,7 @@
|
||||
"$ref": "#/components/schemas/SimpleUserDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"tokens",
|
||||
"user"
|
||||
]
|
||||
"required": ["tokens", "user"]
|
||||
},
|
||||
"JwtAuthResponseDto": {
|
||||
"type": "object",
|
||||
@@ -2044,9 +1972,7 @@
|
||||
"$ref": "#/components/schemas/JwtAuthDataDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
]
|
||||
"required": ["data"]
|
||||
},
|
||||
"ErrorDetailDto": {
|
||||
"type": "object",
|
||||
@@ -2064,9 +1990,7 @@
|
||||
"example": "isEmail"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"message"
|
||||
]
|
||||
"required": ["message"]
|
||||
},
|
||||
"ErrorResponseDto": {
|
||||
"type": "object",
|
||||
@@ -2121,9 +2045,7 @@
|
||||
"description": "Refresh token returned by login or the previous refresh call."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"refreshToken"
|
||||
]
|
||||
"required": ["refreshToken"]
|
||||
},
|
||||
"SimpleUserResponseDto": {
|
||||
"type": "object",
|
||||
@@ -2132,9 +2054,7 @@
|
||||
"$ref": "#/components/schemas/SimpleUserDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
]
|
||||
"required": ["data"]
|
||||
},
|
||||
"Object": {
|
||||
"type": "object",
|
||||
@@ -2166,10 +2086,7 @@
|
||||
},
|
||||
"currency": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"USD",
|
||||
"EUR"
|
||||
],
|
||||
"enum": ["USD", "EUR"],
|
||||
"example": "USD"
|
||||
},
|
||||
"categoryId": {
|
||||
@@ -2241,12 +2158,7 @@
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"page",
|
||||
"limit",
|
||||
"total",
|
||||
"totalPages"
|
||||
]
|
||||
"required": ["page", "limit", "total", "totalPages"]
|
||||
},
|
||||
"ProductsResponseDto": {
|
||||
"type": "object",
|
||||
@@ -2261,10 +2173,7 @@
|
||||
"$ref": "#/components/schemas/PageMetaDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"meta"
|
||||
]
|
||||
"required": ["data", "meta"]
|
||||
},
|
||||
"ProductResponseDto": {
|
||||
"type": "object",
|
||||
@@ -2273,9 +2182,7 @@
|
||||
"$ref": "#/components/schemas/SimpleProductDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
]
|
||||
"required": ["data"]
|
||||
},
|
||||
"CreateProductDto": {
|
||||
"type": "object",
|
||||
@@ -2295,10 +2202,7 @@
|
||||
},
|
||||
"currency": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"USD",
|
||||
"EUR"
|
||||
],
|
||||
"enum": ["USD", "EUR"],
|
||||
"example": "USD"
|
||||
},
|
||||
"categoryId": {
|
||||
@@ -2344,10 +2248,7 @@
|
||||
},
|
||||
"currency": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"USD",
|
||||
"EUR"
|
||||
],
|
||||
"enum": ["USD", "EUR"],
|
||||
"example": "USD"
|
||||
},
|
||||
"categoryId": {
|
||||
@@ -2371,9 +2272,7 @@
|
||||
"description": "Version last read by the frontend."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"version"
|
||||
]
|
||||
"required": ["version"]
|
||||
},
|
||||
"MutationResultDto": {
|
||||
"type": "object",
|
||||
@@ -2387,10 +2286,7 @@
|
||||
"example": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"success"
|
||||
]
|
||||
"required": ["id", "success"]
|
||||
},
|
||||
"MutationResponseDto": {
|
||||
"type": "object",
|
||||
@@ -2399,9 +2295,7 @@
|
||||
"$ref": "#/components/schemas/MutationResultDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
]
|
||||
"required": ["data"]
|
||||
},
|
||||
"SimpleCategoryDto": {
|
||||
"type": "object",
|
||||
@@ -2423,12 +2317,7 @@
|
||||
"example": 4
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"slug",
|
||||
"productCount"
|
||||
]
|
||||
"required": ["id", "name", "slug", "productCount"]
|
||||
},
|
||||
"CategoriesResponseDto": {
|
||||
"type": "object",
|
||||
@@ -2440,9 +2329,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
]
|
||||
"required": ["data"]
|
||||
},
|
||||
"CategoryResponseDto": {
|
||||
"type": "object",
|
||||
@@ -2451,9 +2338,7 @@
|
||||
"$ref": "#/components/schemas/SimpleCategoryDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
]
|
||||
"required": ["data"]
|
||||
},
|
||||
"SimpleOrderItemDto": {
|
||||
"type": "object",
|
||||
@@ -2475,12 +2360,7 @@
|
||||
"example": 12990
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"productId",
|
||||
"productName",
|
||||
"quantity",
|
||||
"unitPriceCents"
|
||||
]
|
||||
"required": ["productId", "productName", "quantity", "unitPriceCents"]
|
||||
},
|
||||
"SimpleOrderDto": {
|
||||
"type": "object",
|
||||
@@ -2495,12 +2375,7 @@
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"pending",
|
||||
"paid",
|
||||
"shipped",
|
||||
"cancelled"
|
||||
]
|
||||
"enum": ["pending", "paid", "shipped", "cancelled"]
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
@@ -2514,10 +2389,7 @@
|
||||
},
|
||||
"currency": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"USD",
|
||||
"EUR"
|
||||
],
|
||||
"enum": ["USD", "EUR"],
|
||||
"example": "USD"
|
||||
},
|
||||
"createdAt": {
|
||||
@@ -2548,10 +2420,7 @@
|
||||
"$ref": "#/components/schemas/PageMetaDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data",
|
||||
"meta"
|
||||
]
|
||||
"required": ["data", "meta"]
|
||||
},
|
||||
"OrderResponseDto": {
|
||||
"type": "object",
|
||||
@@ -2560,9 +2429,7 @@
|
||||
"$ref": "#/components/schemas/SimpleOrderDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
]
|
||||
"required": ["data"]
|
||||
},
|
||||
"CreateOrderItemDto": {
|
||||
"type": "object",
|
||||
@@ -2576,11 +2443,23 @@
|
||||
"example": 1,
|
||||
"minimum": 1,
|
||||
"maximum": 20
|
||||
},
|
||||
"expectedVersion": {
|
||||
"type": "number",
|
||||
"example": 1,
|
||||
"minimum": 1
|
||||
},
|
||||
"expectedUnitPriceCents": {
|
||||
"type": "number",
|
||||
"example": 12990,
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"productId",
|
||||
"quantity"
|
||||
"quantity",
|
||||
"expectedVersion",
|
||||
"expectedUnitPriceCents"
|
||||
]
|
||||
},
|
||||
"CreateOrderDto": {
|
||||
@@ -2593,9 +2472,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"items"
|
||||
]
|
||||
"required": ["items"]
|
||||
},
|
||||
"ScenarioDto": {
|
||||
"type": "object",
|
||||
@@ -2609,10 +2486,7 @@
|
||||
"example": "Delays the response to exercise loading and cancellation states."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"description"
|
||||
]
|
||||
"required": ["name", "description"]
|
||||
},
|
||||
"ScenariosResponseDto": {
|
||||
"type": "object",
|
||||
@@ -2624,9 +2498,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
]
|
||||
"required": ["data"]
|
||||
},
|
||||
"TestingActionDataDto": {
|
||||
"type": "object",
|
||||
@@ -2640,10 +2512,7 @@
|
||||
"example": "State reset to the default deterministic seed."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"success",
|
||||
"message"
|
||||
]
|
||||
"required": ["success", "message"]
|
||||
},
|
||||
"TestingActionResponseDto": {
|
||||
"type": "object",
|
||||
@@ -2652,25 +2521,18 @@
|
||||
"$ref": "#/components/schemas/TestingActionDataDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"data"
|
||||
]
|
||||
"required": ["data"]
|
||||
},
|
||||
"ChangeSimpleRoleDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"admin",
|
||||
"customer"
|
||||
],
|
||||
"enum": ["admin", "customer"],
|
||||
"example": "customer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"role"
|
||||
]
|
||||
"required": ["role"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
12
examples/demo-backend/package-lock.json
generated
12
examples/demo-backend/package-lock.json
generated
@@ -3020,9 +3020,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.17",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.17.tgz",
|
||||
"integrity": "sha512-w+aeW/mkgM4PyRMOJCgi3fOrTm5Q8QY1OSfn2TO2iuDj3ezIHqejmuxbjfPrqUkgqRew1iqkyAn0tr0ZwHD9+w==",
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4685,9 +4685,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/brace-expansion": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
|
||||
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"start:dev": "concurrently -k -n simple,complex -c blue,magenta \"npm:dev:simple\" \"npm:dev:complex\"",
|
||||
"dev:simple": "nest start --watch --entryFile apps/simple/main",
|
||||
"dev:complex": "nest start --watch --entryFile apps/complex/main",
|
||||
"dev:simple": "nest start --watch --path tsconfig.build.json --entryFile apps/simple/main",
|
||||
"dev:complex": "nest start --watch --path tsconfig.build.json --entryFile apps/complex/main",
|
||||
"start:simple": "node dist/apps/simple/main.js",
|
||||
"start:complex": "node dist/apps/complex/main.js",
|
||||
"openapi:generate": "npm run build && node dist/scripts/generate-openapi.js",
|
||||
|
||||
@@ -270,7 +270,7 @@ export class SimpleOrdersController {
|
||||
@Post()
|
||||
@ApiOperation({ summary: "Create an order and validate product stock" })
|
||||
@ApiCreatedResponse({ type: OrderResponseDto })
|
||||
@ApiStandardErrors({ auth: true, conflict: true })
|
||||
@ApiStandardErrors({ auth: true, conflict: true, unprocessable: true })
|
||||
create(
|
||||
@Req() request: DemoRequest,
|
||||
@Body() dto: CreateOrderDto,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Matches,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
@@ -243,6 +244,9 @@ export class CreateProductDto {
|
||||
example: "https://picsum.photos/seed/dock/640/480",
|
||||
})
|
||||
@IsString()
|
||||
@Matches(/^https:\/\/picsum\.photos\//, {
|
||||
message: "imageUrl must use the https://picsum.photos host",
|
||||
})
|
||||
imageUrl!: string;
|
||||
}
|
||||
|
||||
@@ -298,6 +302,16 @@ export class CreateOrderItemDto {
|
||||
@Min(1)
|
||||
@Max(20)
|
||||
quantity!: number;
|
||||
|
||||
@ApiProperty({ example: 1, minimum: 1 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
expectedVersion!: number;
|
||||
|
||||
@ApiProperty({ example: 12990, minimum: 0 })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
expectedUnitPriceCents!: number;
|
||||
}
|
||||
|
||||
export class CreateOrderDto {
|
||||
|
||||
@@ -351,21 +351,54 @@ export class SimpleStore {
|
||||
details: [{ field: "items", message: "Add at least one item." }],
|
||||
});
|
||||
}
|
||||
const items = dto.items.map(({ productId, quantity }) => {
|
||||
const product = this.getProduct(productId);
|
||||
if (product.stock < quantity) {
|
||||
throw new ConflictException({
|
||||
code: "INSUFFICIENT_STOCK",
|
||||
message: `Not enough stock for ${product.name}.`,
|
||||
const productIds = new Set<string>();
|
||||
|
||||
dto.items.forEach((item) => {
|
||||
if (productIds.has(item.productId)) {
|
||||
throw new UnprocessableEntityException({
|
||||
code: "DUPLICATE_ORDER_PRODUCT",
|
||||
message: "Each product may appear only once in an order.",
|
||||
});
|
||||
}
|
||||
return {
|
||||
productId,
|
||||
productName: product.name,
|
||||
quantity,
|
||||
unitPriceCents: product.priceCents,
|
||||
};
|
||||
|
||||
productIds.add(item.productId);
|
||||
});
|
||||
|
||||
const items = dto.items.map(
|
||||
({ productId, quantity, expectedVersion, expectedUnitPriceCents }) => {
|
||||
const product = this.getProduct(productId);
|
||||
|
||||
if (
|
||||
product.version !== expectedVersion ||
|
||||
product.priceCents !== expectedUnitPriceCents
|
||||
) {
|
||||
throw new ConflictException({
|
||||
code: "PRODUCT_CHANGED",
|
||||
message: `${product.name} changed before checkout.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (product.currency !== "USD") {
|
||||
throw new UnprocessableEntityException({
|
||||
code: "UNSUPPORTED_ORDER_CURRENCY",
|
||||
message: "Simple API checkout accepts USD products only.",
|
||||
});
|
||||
}
|
||||
|
||||
if (product.stock < quantity) {
|
||||
throw new ConflictException({
|
||||
code: "INSUFFICIENT_STOCK",
|
||||
message: `Not enough stock for ${product.name}.`,
|
||||
});
|
||||
}
|
||||
return {
|
||||
productId,
|
||||
productName: product.name,
|
||||
quantity,
|
||||
unitPriceCents: product.priceCents,
|
||||
};
|
||||
},
|
||||
);
|
||||
const order: SimpleOrderDto = {
|
||||
id: `order-${String(this.orderSequence++).padStart(3, "0")}`,
|
||||
userId,
|
||||
@@ -417,7 +450,7 @@ export class SimpleStore {
|
||||
stock,
|
||||
rating,
|
||||
imageUrl: `https://picsum.photos/seed/${slug}/640/480`,
|
||||
createdAt: `2026-07-${String(10 + this.products.length).padStart(2, "0")}T09:00:00.000Z`,
|
||||
createdAt: "2026-07-10T09:00:00.000Z",
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,11 +9,17 @@ import {
|
||||
ApiResponse,
|
||||
ApiTooManyRequestsResponse,
|
||||
ApiUnauthorizedResponse,
|
||||
ApiUnprocessableEntityResponse,
|
||||
} from "@nestjs/swagger";
|
||||
import { ErrorResponseDto } from "./api.dto";
|
||||
|
||||
export function ApiStandardErrors(
|
||||
options: { auth?: boolean; notFound?: boolean; conflict?: boolean } = {},
|
||||
options: {
|
||||
auth?: boolean;
|
||||
notFound?: boolean;
|
||||
conflict?: boolean;
|
||||
unprocessable?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const decorators: Array<
|
||||
ClassDecorator | MethodDecorator | PropertyDecorator
|
||||
@@ -63,6 +69,15 @@ export function ApiStandardErrors(
|
||||
);
|
||||
}
|
||||
|
||||
if (options.unprocessable) {
|
||||
decorators.push(
|
||||
ApiUnprocessableEntityResponse({
|
||||
description: "The validated request violates an order invariant.",
|
||||
type: ErrorResponseDto,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return applyDecorators(...decorators);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import type { Request, Response } from "express";
|
||||
import { Observable } from "rxjs";
|
||||
import { delay, map } from "rxjs/operators";
|
||||
import { fromEvent, Observable, timer } from "rxjs";
|
||||
import { delay, map, mergeMap, takeUntil } from "rxjs/operators";
|
||||
import { DEMO_SCENARIOS, type DemoScenario } from "./api.dto";
|
||||
|
||||
function scenarioFromRequest(request: Request): DemoScenario {
|
||||
@@ -80,9 +80,20 @@ export class ScenarioInterceptor implements NestInterceptor {
|
||||
: scenario === "timeout"
|
||||
? Number(process.env.MOCK_TIMEOUT_DELAY_MS ?? 30000)
|
||||
: 0;
|
||||
const isMutation = !["GET", "HEAD", "OPTIONS"].includes(request.method);
|
||||
const source =
|
||||
configuredDelay > 0 && isMutation
|
||||
? timer(configuredDelay).pipe(
|
||||
takeUntil(fromEvent(response, "close")),
|
||||
mergeMap(() => next.handle()),
|
||||
)
|
||||
: next
|
||||
.handle()
|
||||
.pipe(
|
||||
configuredDelay > 0 ? delay(configuredDelay) : (value) => value,
|
||||
);
|
||||
|
||||
return next.handle().pipe(
|
||||
configuredDelay > 0 ? delay(configuredDelay) : (source) => source,
|
||||
return source.pipe(
|
||||
map((payload) => this.transformPayload(payload, scenario)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -151,9 +151,137 @@ describe("Simple API", () => {
|
||||
const order = await request(app.getHttpServer())
|
||||
.post("/api/v1/orders")
|
||||
.set("Authorization", authorization)
|
||||
.send({ items: [{ productId: "product-keyboard", quantity: 1 }] })
|
||||
.send({
|
||||
items: [
|
||||
{
|
||||
productId: "product-keyboard",
|
||||
quantity: 1,
|
||||
expectedVersion: 1,
|
||||
expectedUnitPriceCents: 12990,
|
||||
},
|
||||
],
|
||||
})
|
||||
.expect(201);
|
||||
expect(order.body.data.userId).toBe("user-customer");
|
||||
expect(order.body.data.totalCents).toBe(12990);
|
||||
});
|
||||
|
||||
it("does not execute a timeout mutation after the client disconnects", async () => {
|
||||
const customer = await login("customer@demo.local");
|
||||
const authorization = `Bearer ${customer.body.data.tokens.accessToken as string}`;
|
||||
const ordersBefore = await request(app.getHttpServer())
|
||||
.get("/api/v1/orders")
|
||||
.set("Authorization", authorization)
|
||||
.expect(200);
|
||||
const previousDelay = process.env.MOCK_TIMEOUT_DELAY_MS;
|
||||
|
||||
process.env.MOCK_TIMEOUT_DELAY_MS = "100";
|
||||
|
||||
try {
|
||||
await request(app.getHttpServer())
|
||||
.post("/api/v1/orders")
|
||||
.set("Authorization", authorization)
|
||||
.set("X-Demo-Scenario", "timeout")
|
||||
.send({
|
||||
items: [
|
||||
{
|
||||
productId: "product-keyboard",
|
||||
quantity: 1,
|
||||
expectedVersion: 1,
|
||||
expectedUnitPriceCents: 12990,
|
||||
},
|
||||
],
|
||||
})
|
||||
.timeout({ deadline: 10 });
|
||||
} catch {
|
||||
// The client intentionally closes the response before the mutation delay elapses.
|
||||
} finally {
|
||||
if (previousDelay === undefined) {
|
||||
delete process.env.MOCK_TIMEOUT_DELAY_MS;
|
||||
} else {
|
||||
process.env.MOCK_TIMEOUT_DELAY_MS = previousDelay;
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
|
||||
const ordersAfter = await request(app.getHttpServer())
|
||||
.get("/api/v1/orders")
|
||||
.set("Authorization", authorization)
|
||||
.expect(200);
|
||||
|
||||
expect(ordersAfter.body.meta.total).toBe(ordersBefore.body.meta.total);
|
||||
});
|
||||
|
||||
it("rejects an order when the confirmed product snapshot changed", async () => {
|
||||
const customer = await login("customer@demo.local");
|
||||
const authorization = `Bearer ${customer.body.data.tokens.accessToken as string}`;
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/api/v1/orders")
|
||||
.set("Authorization", authorization)
|
||||
.send({
|
||||
items: [
|
||||
{
|
||||
productId: "product-keyboard",
|
||||
quantity: 1,
|
||||
expectedVersion: 1,
|
||||
expectedUnitPriceCents: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
.expect(409);
|
||||
|
||||
expect(response.body.code).toBe("PRODUCT_CHANGED");
|
||||
});
|
||||
|
||||
it("rejects duplicate product lines and unsupported order currency", async () => {
|
||||
const admin = await login();
|
||||
const adminAuthorization = `Bearer ${admin.body.data.tokens.accessToken as string}`;
|
||||
const customer = await login("customer@demo.local");
|
||||
const customerAuthorization = `Bearer ${customer.body.data.tokens.accessToken as string}`;
|
||||
const line = {
|
||||
productId: "product-keyboard",
|
||||
quantity: 20,
|
||||
expectedVersion: 1,
|
||||
expectedUnitPriceCents: 12990,
|
||||
};
|
||||
const duplicateResponse = await request(app.getHttpServer())
|
||||
.post("/api/v1/orders")
|
||||
.set("Authorization", customerAuthorization)
|
||||
.send({ items: [line, line] })
|
||||
.expect(422);
|
||||
|
||||
expect(duplicateResponse.body.code).toBe("DUPLICATE_ORDER_PRODUCT");
|
||||
|
||||
const eurProduct = await request(app.getHttpServer())
|
||||
.post("/api/v1/products")
|
||||
.set("Authorization", adminAuthorization)
|
||||
.send({
|
||||
name: "Euro Product",
|
||||
description: "A deterministic product priced in euros.",
|
||||
priceCents: 1000,
|
||||
currency: "EUR",
|
||||
categoryId: "category-books",
|
||||
stock: 5,
|
||||
imageUrl: "https://picsum.photos/seed/euro-product/640/480",
|
||||
})
|
||||
.expect(201);
|
||||
const currencyResponse = await request(app.getHttpServer())
|
||||
.post("/api/v1/orders")
|
||||
.set("Authorization", customerAuthorization)
|
||||
.send({
|
||||
items: [
|
||||
{
|
||||
productId: eurProduct.body.data.id,
|
||||
quantity: 1,
|
||||
expectedVersion: eurProduct.body.data.version,
|
||||
expectedUnitPriceCents: eurProduct.body.data.priceCents,
|
||||
},
|
||||
],
|
||||
})
|
||||
.expect(422);
|
||||
|
||||
expect(currencyResponse.body.code).toBe("UNSUPPORTED_ORDER_CURRENCY");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"incremental": false,
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
|
||||
1
examples/demo-frontend/.env.example
Normal file
1
examples/demo-frontend/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_SIMPLE_API_URL=http://localhost:3001
|
||||
42
examples/demo-frontend/.gitignore
vendored
Normal file
42
examples/demo-frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
5
examples/demo-frontend/AGENTS.md
Normal file
5
examples/demo-frontend/AGENTS.md
Normal file
@@ -0,0 +1,5 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
1
examples/demo-frontend/CLAUDE.md
Normal file
1
examples/demo-frontend/CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
102
examples/demo-frontend/README.md
Normal file
102
examples/demo-frontend/README.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Demo frontend
|
||||
|
||||
Полноценное Next.js 16 приложение поверх меньшего из двух fixture API: `../demo-backend/openapi/simple.json`.
|
||||
|
||||
Пример показывает SLM Level 1 на каталоге: public data, JWT login/refresh/logout, persisted cart, checkout, protected orders, RBAC, admin CRUD, optimistic locking и детерминированные network outcomes.
|
||||
|
||||
## Запуск
|
||||
|
||||
Требуются Node.js 20+, npm 10+ и современный browser с Web Locks API. Auth и cart transitions работают fail-closed без cross-tab lock; `localhost` считается secure context для локальной разработки.
|
||||
|
||||
Сначала запустите Simple API:
|
||||
|
||||
```bash
|
||||
cd ../demo-backend
|
||||
npm install
|
||||
npm run dev:simple
|
||||
```
|
||||
|
||||
Затем запустите frontend:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Откройте `http://localhost:3000`. Backend по умолчанию доступен на `http://localhost:3001`.
|
||||
|
||||
Чтобы изменить URL backend, создайте `.env.local` рядом с `package.json`:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_SIMPLE_API_URL=http://localhost:3001
|
||||
```
|
||||
|
||||
## Demo accounts
|
||||
|
||||
| Email | Password | Role | Scenarios |
|
||||
|---|---|---|---|
|
||||
| `admin@demo.local` | `demo1234` | admin | Product CRUD, all orders |
|
||||
| `customer@demo.local` | `demo1234` | customer | Cart, checkout, own orders |
|
||||
|
||||
## Routes
|
||||
|
||||
| Route | Composition responsibility |
|
||||
|---|---|
|
||||
| `/` | Product search, category filter, sorting and pagination |
|
||||
| `/products/[productId]` | Product detail and add-to-cart |
|
||||
| `/cart` | Multi-domain checkout coordination |
|
||||
| `/orders` | Protected order history and cancellation |
|
||||
| `/sign-in` | JWT session lifecycle and account selection |
|
||||
| `/admin/products` | RBAC-gated CRUD and optimistic locking |
|
||||
|
||||
Плавающий `Demo controls` widget переключает `X-Demo-Scenario`: slow, timeout, 500, 429, empty, expired auth, forbidden и conflict. Отдельное действие `Seed 250` загружает настоящий большой seed с интерактивными product IDs; synthetic `large-dataset` header намеренно не используется для mutation UI.
|
||||
|
||||
## SLM boundary
|
||||
|
||||
`src` является SLM root:
|
||||
|
||||
```text
|
||||
src/
|
||||
├── app/ Next.js bootstrap and route entries
|
||||
├── compositions/ Layout, screens and demo widget
|
||||
├── domains/ Auth, catalog, cart, orders and demo-control
|
||||
├── infra/ Generated REST client, JWT storage and browser storage
|
||||
├── ui/ Product-agnostic button, form field and feedback panel
|
||||
└── shared/ Deterministic predicates, formatting and Result type
|
||||
```
|
||||
|
||||
Пример использует Level 1 осознанно: у каждого домена одна browser runtime integration. Дополнительные factories, adapters и assemblies Level 2 не окупили бы стоимость. Checkout остаётся в composition, поэтому `orders` не импортирует `cart`, а module graph остаётся ацикличным.
|
||||
|
||||
State и lifecycle распределены по владельцам:
|
||||
|
||||
- `auth` владеет пользовательской сессией; `infra/simple-auth-session` хранит technical JWT pair, стабильный `sessionId` и CAS revision.
|
||||
- `cart` владеет строками, totals и persisted snapshot validation; monotonic revision и conditional clear сериализованы между вкладками.
|
||||
- SWR cache принадлежит REST infra module, создаётся отдельным provider и remount-ится при смене logical auth session.
|
||||
- React providers создаются один раз в application scope и очищают свои subscriptions при unmount.
|
||||
- Route entries только адаптируют Next.js params и подключают public APIs compositions.
|
||||
- Reset, seed, role и request-scenario transitions синхронизируют cache, auth, cart и route-local pagination между вкладками.
|
||||
|
||||
## REST client
|
||||
|
||||
Split SDK генерируется из committed OpenAPI:
|
||||
|
||||
```bash
|
||||
npm run codegen:simple-rest-api
|
||||
```
|
||||
|
||||
Generated-код живёт только в `src/infra/simple-rest-api/generated` и не редактируется вручную. GET hooks вызывают точечные operations через `simpleHttpClient`; submit-сценарии используют полный `simpleRestApi`. Внешний код импортирует REST capability только через `@/infra/simple-rest-api`.
|
||||
|
||||
OpenAPI fixture описывает числовые `page` и `limit` как `object`. Исправление generated type изолировано в `types/to-generated-query.ts`; runtime query остаётся числовым.
|
||||
|
||||
JWT refresh выполняется в transport `onError`, ограничен одним retry и дедуплицирует конкурентные refresh requests одной revision. CAS не позволяет позднему refresh воскресить logout или перезаписать новый login. Хранение refresh token в `localStorage` допустимо только для этой архитектурной fixture; production-приложение должно выбрать threat model и более безопасную session strategy.
|
||||
|
||||
Checkout привязан к captured auth session и persisted cart revision. Backend атомарно проверяет product version, unit price, USD currency, aggregate stock и уникальность product lines. Timeout-сценарий откладывает mutation handler и отменяет его при disconnect, поэтому frontend timeout не скрывает завершившийся POST.
|
||||
|
||||
## Проверка
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
npm run build
|
||||
```
|
||||
|
||||
`npm run check` запускает architecture constraints, ESLint, TypeScript и Vitest. Architecture script строит import graph через TypeScript AST и явный module manifest, затем проверяет направление слоёв, public module imports, side-effect/dynamic imports, запрет утечки generated SDK и циклы.
|
||||
37
examples/demo-frontend/eslint.config.mjs
Normal file
37
examples/demo-frontend/eslint.config.mjs
Normal file
@@ -0,0 +1,37 @@
|
||||
import js from '@eslint/js'
|
||||
import nextPlugin from '@next/eslint-plugin-next'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import globals from 'globals'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores([
|
||||
'.next/**',
|
||||
'out/**',
|
||||
'build/**',
|
||||
'coverage/**',
|
||||
'next-env.d.ts',
|
||||
'src/infra/simple-rest-api/generated/**'
|
||||
]),
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ['**/*.{js,mjs,mts,ts,tsx}'],
|
||||
plugins: {
|
||||
'@next/next': nextPlugin,
|
||||
'react-hooks': reactHooks
|
||||
},
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
...nextPlugin.configs.recommended.rules,
|
||||
...nextPlugin.configs['core-web-vitals'].rules,
|
||||
...reactHooks.configs.flat.recommended.rules
|
||||
}
|
||||
}
|
||||
])
|
||||
24
examples/demo-frontend/next.config.ts
Normal file
24
examples/demo-frontend/next.config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
turbopack: {
|
||||
root: fileURLToPath(new URL('.', import.meta.url))
|
||||
},
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'picsum.photos',
|
||||
pathname: '/**'
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'i.pravatar.cc',
|
||||
pathname: '/**'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
4280
examples/demo-frontend/package-lock.json
generated
Normal file
4280
examples/demo-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
examples/demo-frontend/package.json
Normal file
40
examples/demo-frontend/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "demo-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"check:architecture": "node scripts/check-architecture.mjs",
|
||||
"check": "npm run check:architecture && npm run lint && npm run typecheck && npm run test",
|
||||
"codegen:simple-rest-api": "npx @gromlab/api-codegen@5.1.1 -i ../demo-backend/openapi/simple.json -o src/infra/simple-rest-api/generated"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "2.1.1",
|
||||
"next": "16.2.12",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"swr": "2.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "10.0.1",
|
||||
"@next/eslint-plugin-next": "16.2.12",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "10.8.0",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"globals": "17.8.0",
|
||||
"typescript": "^5",
|
||||
"typescript-eslint": "8.65.0",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"overrides": {
|
||||
"postcss": "8.5.25",
|
||||
"sharp": "0.35.3"
|
||||
}
|
||||
}
|
||||
8
examples/demo-frontend/public/product-placeholder.svg
Normal file
8
examples/demo-frontend/public/product-placeholder.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480" role="img" aria-labelledby="title description">
|
||||
<title id="title">Product image unavailable</title>
|
||||
<desc id="description">Neutral geometric placeholder for a missing product image.</desc>
|
||||
<rect width="640" height="480" fill="#ebe9e2"/>
|
||||
<path d="M0 390 180 210l105 105 78-78 277 243H0Z" fill="#b7d72c"/>
|
||||
<circle cx="470" cy="130" r="58" fill="#3057ff"/>
|
||||
<path d="M72 74h214v24H72zm0 48h142v16H72z" fill="#171813"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 496 B |
360
examples/demo-frontend/scripts/check-architecture.mjs
Normal file
360
examples/demo-frontend/scripts/check-architecture.mjs
Normal file
@@ -0,0 +1,360 @@
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
||||
import { dirname, extname, join, relative, resolve, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import ts from 'typescript'
|
||||
|
||||
const SCRIPT_DIRECTORY = dirname(fileURLToPath(import.meta.url))
|
||||
const PROJECT_ROOT = resolve(SCRIPT_DIRECTORY, '..')
|
||||
const SOURCE_ROOT = join(PROJECT_ROOT, 'src')
|
||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx'])
|
||||
const MODULE_ROOTS = [
|
||||
'compositions/layouts/store-shell',
|
||||
'compositions/screens/cart',
|
||||
'compositions/screens/catalog',
|
||||
'compositions/screens/orders',
|
||||
'compositions/screens/product-admin',
|
||||
'compositions/screens/product-detail',
|
||||
'compositions/screens/sign-in',
|
||||
'compositions/widgets/demo-toolbar',
|
||||
'domains/auth',
|
||||
'domains/cart',
|
||||
'domains/catalog',
|
||||
'domains/demo-control',
|
||||
'domains/orders',
|
||||
'infra/browser-storage',
|
||||
'infra/simple-auth-session',
|
||||
'infra/simple-rest-api',
|
||||
'shared/lib/value-predicates',
|
||||
'ui/button',
|
||||
'ui/feedback-panel',
|
||||
'ui/form-field'
|
||||
].sort((left, right) => right.length - left.length)
|
||||
const MODULAR_LAYERS = new Set(['compositions', 'domains', 'infra', 'ui'])
|
||||
const ALLOWED_TARGETS = {
|
||||
app: new Set(['app', 'compositions', 'domains', 'infra', 'ui', 'shared']),
|
||||
compositions: new Set(['compositions', 'domains', 'infra', 'ui', 'shared']),
|
||||
domains: new Set(['domains', 'infra', 'ui', 'shared']),
|
||||
infra: new Set(['infra', 'shared']),
|
||||
ui: new Set(['ui', 'shared']),
|
||||
shared: new Set(['shared'])
|
||||
}
|
||||
|
||||
/**
|
||||
* Рекурсивно собирает TypeScript-файлы SLM root.
|
||||
*/
|
||||
const collectSourceFiles = (directory) => {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const entryPath = join(directory, entry.name)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
return collectSourceFiles(entryPath)
|
||||
}
|
||||
|
||||
return SOURCE_EXTENSIONS.has(extname(entry.name)) ? [entryPath] : []
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Нормализует filesystem path к формату architecture manifest.
|
||||
*/
|
||||
const normalizePath = (filePath) => {
|
||||
return filePath.split(sep).join('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает путь файла относительно SLM root.
|
||||
*/
|
||||
const getSourcePath = (filePath) => {
|
||||
return normalizePath(relative(SOURCE_ROOT, filePath))
|
||||
}
|
||||
|
||||
/**
|
||||
* Определяет слой относительного source path.
|
||||
*/
|
||||
const getLayer = (sourcePath) => {
|
||||
return sourcePath.split('/')[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Находит явно объявленного module-owner по longest-prefix rule.
|
||||
*/
|
||||
const getModuleId = (sourcePath) => {
|
||||
return MODULE_ROOTS.find(
|
||||
(moduleRoot) => sourcePath === moduleRoot || sourcePath.startsWith(`${moduleRoot}/`)
|
||||
) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет deep import относительно явно объявленного public entry.
|
||||
*/
|
||||
const isDeepModuleImport = (targetPath, sourceModuleId) => {
|
||||
const targetModuleId = getModuleId(targetPath)
|
||||
|
||||
return targetModuleId !== null &&
|
||||
targetModuleId !== sourceModuleId &&
|
||||
targetPath !== targetModuleId
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет направление слоя независимо от синтаксиса import path.
|
||||
*/
|
||||
const isLayerImportAllowed = (sourcePath, targetPath) => {
|
||||
const sourceLayer = getLayer(sourcePath)
|
||||
const targetLayer = getLayer(targetPath)
|
||||
|
||||
return ALLOWED_TARGETS[sourceLayer]?.has(targetLayer) === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает static, side-effect, reexport и dynamic module specifiers через TypeScript AST.
|
||||
*/
|
||||
const getModuleSpecifiers = (filePath, sourceCode) => {
|
||||
const scriptKind = extname(filePath) === '.tsx' ? ts.ScriptKind.TSX : ts.ScriptKind.TS
|
||||
const sourceFile = ts.createSourceFile(
|
||||
filePath,
|
||||
sourceCode,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
scriptKind
|
||||
)
|
||||
const specifiers = []
|
||||
|
||||
/**
|
||||
* Обходит один AST node и собирает строковые module references.
|
||||
*/
|
||||
const visit = (node) => {
|
||||
if (
|
||||
(ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
|
||||
node.moduleSpecifier &&
|
||||
ts.isStringLiteral(node.moduleSpecifier)
|
||||
) {
|
||||
specifiers.push(node.moduleSpecifier.text)
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isImportEqualsDeclaration(node) &&
|
||||
ts.isExternalModuleReference(node.moduleReference) &&
|
||||
node.moduleReference.expression &&
|
||||
ts.isStringLiteral(node.moduleReference.expression)
|
||||
) {
|
||||
specifiers.push(node.moduleReference.expression.text)
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isCallExpression(node) &&
|
||||
node.arguments.length === 1 &&
|
||||
ts.isStringLiteral(node.arguments[0]) &&
|
||||
(node.expression.kind === ts.SyntaxKind.ImportKeyword ||
|
||||
(ts.isIdentifier(node.expression) && node.expression.text === 'require'))
|
||||
) {
|
||||
specifiers.push(node.arguments[0].text)
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isImportTypeNode(node) &&
|
||||
ts.isLiteralTypeNode(node.argument) &&
|
||||
ts.isStringLiteral(node.argument.literal)
|
||||
) {
|
||||
specifiers.push(node.argument.literal.text)
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
visit(sourceFile)
|
||||
|
||||
return specifiers
|
||||
}
|
||||
|
||||
/**
|
||||
* Разрешает относительный TypeScript import.
|
||||
*/
|
||||
const resolveRelativeImport = (sourceFile, specifier) => {
|
||||
const basePath = resolve(dirname(sourceFile), specifier)
|
||||
const candidates = [
|
||||
basePath,
|
||||
`${basePath}.ts`,
|
||||
`${basePath}.tsx`,
|
||||
join(basePath, 'index.ts'),
|
||||
join(basePath, 'index.tsx')
|
||||
]
|
||||
|
||||
return candidates.find((candidate) => existsSync(candidate)) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Находит первый cycle в module graph.
|
||||
*/
|
||||
const findCycle = (graph) => {
|
||||
const visited = new Set()
|
||||
const active = new Set()
|
||||
const path = []
|
||||
|
||||
/**
|
||||
* Обходит один module node depth-first.
|
||||
*/
|
||||
const visit = (moduleId) => {
|
||||
if (active.has(moduleId)) {
|
||||
const cycleStart = path.indexOf(moduleId)
|
||||
|
||||
return [...path.slice(cycleStart), moduleId]
|
||||
}
|
||||
|
||||
if (visited.has(moduleId)) {
|
||||
return null
|
||||
}
|
||||
|
||||
visited.add(moduleId)
|
||||
active.add(moduleId)
|
||||
path.push(moduleId)
|
||||
|
||||
for (const dependency of graph.get(moduleId) ?? []) {
|
||||
const cycle = visit(dependency)
|
||||
|
||||
if (cycle !== null) {
|
||||
return cycle
|
||||
}
|
||||
}
|
||||
|
||||
path.pop()
|
||||
active.delete(moduleId)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
for (const moduleId of graph.keys()) {
|
||||
const cycle = visit(moduleId)
|
||||
|
||||
if (cycle !== null) {
|
||||
return cycle
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const violations = []
|
||||
const graph = new Map()
|
||||
const sourceFiles = collectSourceFiles(SOURCE_ROOT)
|
||||
const parserProbe = getModuleSpecifiers(
|
||||
'architecture-probe.ts',
|
||||
`
|
||||
import '@/infra/simple-rest-api'
|
||||
export { useAuth } from '@/domains/auth'
|
||||
type Auth = import('@/domains/auth').AuthContextValue
|
||||
const lazy = import('@/compositions/screens/catalog')
|
||||
`
|
||||
)
|
||||
const expectedProbeSpecifiers = [
|
||||
'@/infra/simple-rest-api',
|
||||
'@/domains/auth',
|
||||
'@/domains/auth',
|
||||
'@/compositions/screens/catalog'
|
||||
]
|
||||
|
||||
expectedProbeSpecifiers.forEach((specifier) => {
|
||||
if (!parserProbe.includes(specifier)) {
|
||||
violations.push(`architecture parser fixture missed ${specifier}`)
|
||||
}
|
||||
})
|
||||
|
||||
if (!isDeepModuleImport('shared/lib/value-predicates/value-predicates', 'domains/cart')) {
|
||||
violations.push('architecture boundary fixture did not detect shared module deep import')
|
||||
}
|
||||
|
||||
if (isLayerImportAllowed('shared/bridge.ts', 'domains/auth/index.ts')) {
|
||||
violations.push('architecture boundary fixture allowed shared to import domains relatively')
|
||||
}
|
||||
|
||||
MODULE_ROOTS.forEach((moduleRoot) => {
|
||||
const moduleDirectory = join(SOURCE_ROOT, moduleRoot)
|
||||
const publicEntry = join(moduleDirectory, 'index.ts')
|
||||
|
||||
if (!existsSync(moduleDirectory) || !existsSync(publicEntry)) {
|
||||
violations.push(`${moduleRoot}: declared module must be a folder with index.ts public API`)
|
||||
}
|
||||
})
|
||||
|
||||
sourceFiles.forEach((sourceFile) => {
|
||||
const sourcePath = getSourcePath(sourceFile)
|
||||
const sourceLayer = getLayer(sourcePath)
|
||||
const sourceModuleId = getModuleId(sourcePath)
|
||||
const relativeSource = relative(PROJECT_ROOT, sourceFile)
|
||||
const sourceCode = readFileSync(sourceFile, 'utf8')
|
||||
|
||||
if (MODULAR_LAYERS.has(sourceLayer) && sourceModuleId === null) {
|
||||
violations.push(`${relativeSource}: implementation file has no declared module-owner`)
|
||||
}
|
||||
|
||||
getModuleSpecifiers(sourceFile, sourceCode).forEach((specifier) => {
|
||||
if (specifier.startsWith('@/')) {
|
||||
const targetPath = specifier.slice(2)
|
||||
const targetLayer = getLayer(targetPath)
|
||||
const targetModuleId = getModuleId(targetPath)
|
||||
|
||||
if (!isLayerImportAllowed(sourcePath, targetPath)) {
|
||||
violations.push(`${relativeSource}: ${sourceLayer} cannot import ${targetLayer} via ${specifier}`)
|
||||
}
|
||||
|
||||
if (isDeepModuleImport(targetPath, sourceModuleId)) {
|
||||
violations.push(`${relativeSource}: deep import into ${targetModuleId} via ${specifier}`)
|
||||
}
|
||||
|
||||
if (sourceModuleId !== null && targetModuleId !== null && sourceModuleId !== targetModuleId) {
|
||||
const dependencies = graph.get(sourceModuleId) ?? new Set()
|
||||
dependencies.add(targetModuleId)
|
||||
graph.set(sourceModuleId, dependencies)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (specifier.startsWith('.')) {
|
||||
const targetFile = resolveRelativeImport(sourceFile, specifier)
|
||||
|
||||
if (targetFile === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetModuleId = getModuleId(getSourcePath(targetFile))
|
||||
const targetPath = getSourcePath(targetFile)
|
||||
const targetLayer = getLayer(targetPath)
|
||||
|
||||
if (!isLayerImportAllowed(sourcePath, targetPath)) {
|
||||
violations.push(
|
||||
`${relativeSource}: ${sourceLayer} cannot import ${targetLayer} via ${specifier}`
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
sourceModuleId !== null &&
|
||||
targetModuleId !== null &&
|
||||
sourceModuleId !== targetModuleId
|
||||
) {
|
||||
violations.push(`${relativeSource}: relative import crosses into ${targetModuleId}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const isRestModuleFile = sourceModuleId === 'infra/simple-rest-api'
|
||||
|
||||
if (sourceCode.includes('/generated') && !isRestModuleFile) {
|
||||
violations.push(`${relativeSource}: generated SDK import escaped infra/simple-rest-api`)
|
||||
}
|
||||
})
|
||||
|
||||
const cycle = findCycle(graph)
|
||||
|
||||
if (cycle !== null) {
|
||||
violations.push(`module dependency cycle: ${cycle.join(' -> ')}`)
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error('SLM architecture check failed:')
|
||||
violations.forEach((violation) => console.error(`- ${violation}`))
|
||||
process.exitCode = 1
|
||||
} else {
|
||||
console.log(
|
||||
`SLM architecture check passed for ${sourceFiles.length} files and ${MODULE_ROOTS.length} declared modules.`
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import { ProductAdminScreen } from '@/compositions/screens/product-admin'
|
||||
|
||||
/**
|
||||
* Metadata catalog administration route.
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
title: 'Product admin'
|
||||
}
|
||||
|
||||
/**
|
||||
* Подключает admin composition к framework route.
|
||||
*/
|
||||
export default function ProductAdminPage() {
|
||||
return <ProductAdminScreen />
|
||||
}
|
||||
17
examples/demo-frontend/src/app/(store)/cart/page.tsx
Normal file
17
examples/demo-frontend/src/app/(store)/cart/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import { CartScreen } from '@/compositions/screens/cart'
|
||||
|
||||
/**
|
||||
* Metadata cart route.
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
title: 'Cart'
|
||||
}
|
||||
|
||||
/**
|
||||
* Подключает multi-domain checkout composition к cart route.
|
||||
*/
|
||||
export default function CartPage() {
|
||||
return <CartScreen />
|
||||
}
|
||||
12
examples/demo-frontend/src/app/(store)/layout.tsx
Normal file
12
examples/demo-frontend/src/app/(store)/layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { StoreShellLayout } from '@/compositions/layouts/store-shell'
|
||||
|
||||
import type { AppLayoutProps } from '../types/app-layout-props.type'
|
||||
|
||||
/**
|
||||
* Подключает общий storefront layout ко всем leaf routes группы.
|
||||
*/
|
||||
export default function StoreLayout(props: AppLayoutProps) {
|
||||
const { children } = props
|
||||
|
||||
return <StoreShellLayout>{children}</StoreShellLayout>
|
||||
}
|
||||
17
examples/demo-frontend/src/app/(store)/orders/page.tsx
Normal file
17
examples/demo-frontend/src/app/(store)/orders/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import { OrdersScreen } from '@/compositions/screens/orders'
|
||||
|
||||
/**
|
||||
* Metadata protected orders route.
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
title: 'Orders'
|
||||
}
|
||||
|
||||
/**
|
||||
* Подключает protected orders composition к framework route.
|
||||
*/
|
||||
export default function OrdersPage() {
|
||||
return <OrdersScreen />
|
||||
}
|
||||
8
examples/demo-frontend/src/app/(store)/page.tsx
Normal file
8
examples/demo-frontend/src/app/(store)/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { CatalogScreen } from '@/compositions/screens/catalog'
|
||||
|
||||
/**
|
||||
* Подключает catalog composition к корневому route.
|
||||
*/
|
||||
export default function CatalogPage() {
|
||||
return <CatalogScreen />
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import { ProductDetailScreen } from '@/compositions/screens/product-detail'
|
||||
|
||||
import type { ProductPageProps } from '../../../types/product-page-props.type'
|
||||
|
||||
/**
|
||||
* Metadata dynamic product route без запроса к runtime fixture во время build.
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
title: 'Product detail'
|
||||
}
|
||||
|
||||
/**
|
||||
* Адаптирует async dynamic segment к product detail composition.
|
||||
*/
|
||||
export default async function ProductPage(props: ProductPageProps) {
|
||||
const { productId } = await props.params
|
||||
|
||||
return <ProductDetailScreen productId={productId} />
|
||||
}
|
||||
17
examples/demo-frontend/src/app/(store)/sign-in/page.tsx
Normal file
17
examples/demo-frontend/src/app/(store)/sign-in/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import { SignInScreen } from '@/compositions/screens/sign-in'
|
||||
|
||||
/**
|
||||
* Metadata sign-in route.
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
title: 'Sign in'
|
||||
}
|
||||
|
||||
/**
|
||||
* Подключает auth composition к framework route.
|
||||
*/
|
||||
export default function SignInPage() {
|
||||
return <SignInScreen />
|
||||
}
|
||||
BIN
examples/demo-frontend/src/app/favicon.ico
Normal file
BIN
examples/demo-frontend/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
85
examples/demo-frontend/src/app/globals.css
Normal file
85
examples/demo-frontend/src/app/globals.css
Normal file
@@ -0,0 +1,85 @@
|
||||
:root {
|
||||
--color-paper: #f3efe4;
|
||||
--color-surface: #faf7ef;
|
||||
--color-surface-strong: #e6e0d4;
|
||||
--color-ink: #191918;
|
||||
--color-ink-muted: #65615a;
|
||||
--color-ink-faint: #67635c;
|
||||
--color-line: #d7d0c3;
|
||||
--color-line-strong: #aaa297;
|
||||
--color-blue: #2847f4;
|
||||
--color-acid: #c9ff50;
|
||||
--color-acid-dark: #6f9c00;
|
||||
--color-orange: #ff8a3d;
|
||||
--color-error: #b83228;
|
||||
--color-focus: #8dacff;
|
||||
--font-body: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--font-display: 'Arial Black', 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
|
||||
--shadow-card: 0 10px 40px rgb(37 31 22 / 7%);
|
||||
--shadow-card-hover: 0 20px 55px rgb(37 31 22 / 14%);
|
||||
}
|
||||
|
||||
html {
|
||||
color: var(--color-ink);
|
||||
background: var(--color-paper);
|
||||
scroll-behavior: smooth;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--color-ink);
|
||||
background:
|
||||
radial-gradient(circle at 15% 0%, rgb(255 255 255 / 58%), transparent 25%),
|
||||
var(--color-paper);
|
||||
font-family: var(--font-body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
::selection {
|
||||
color: var(--color-ink);
|
||||
background: var(--color-acid);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
31
examples/demo-frontend/src/app/layout.tsx
Normal file
31
examples/demo-frontend/src/app/layout.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import { AppProviders } from './providers'
|
||||
import type { AppLayoutProps } from './types/app-layout-props.type'
|
||||
import './globals.css'
|
||||
|
||||
/**
|
||||
* Статические metadata архитектурного demo-приложения.
|
||||
*/
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'Layer Supply',
|
||||
template: '%s / Layer Supply'
|
||||
},
|
||||
description: 'A complete Next.js storefront demonstrating Scoped Layered Module Design.'
|
||||
}
|
||||
|
||||
/**
|
||||
* Корневая Next.js entry point с application-scoped providers.
|
||||
*/
|
||||
export default function RootLayout(props: AppLayoutProps) {
|
||||
const { children } = props
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<AppProviders>{children}</AppProviders>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
49
examples/demo-frontend/src/app/providers.tsx
Normal file
49
examples/demo-frontend/src/app/providers.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import { AuthProvider, useAuth } from '@/domains/auth'
|
||||
import { CartProvider } from '@/domains/cart'
|
||||
import { SimpleRestApiProvider } from '@/infra/simple-rest-api'
|
||||
|
||||
import type { AppLayoutProps } from './types/app-layout-props.type'
|
||||
|
||||
/**
|
||||
* Создаёт новый REST cache при каждой смене logical auth session.
|
||||
*
|
||||
* Используется для:
|
||||
* - исключения DTO предыдущего пользователя из нового session scope
|
||||
*/
|
||||
const AuthScopedRestApiProvider = (props: AppLayoutProps) => {
|
||||
const { children } = props
|
||||
const auth = useAuth()
|
||||
const cacheScopeKey = auth.status === 'checking'
|
||||
? `checking:${auth.sessionKey ?? 'anonymous'}`
|
||||
: auth.sessionKey ?? auth.status
|
||||
|
||||
return (
|
||||
<SimpleRestApiProvider
|
||||
key={cacheScopeKey}
|
||||
isPaused={auth.status === 'checking'}
|
||||
>
|
||||
{children}
|
||||
</SimpleRestApiProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Подключает application-scoped domain state и auth-scoped REST cache.
|
||||
*
|
||||
* Используется для:
|
||||
* - сохранения auth/cart state между route transitions
|
||||
* - пересоздания technical cache на границе пользовательской сессии
|
||||
*/
|
||||
export const AppProviders = (props: AppLayoutProps) => {
|
||||
const { children } = props
|
||||
|
||||
return (
|
||||
<AuthProvider>
|
||||
<CartProvider>
|
||||
<AuthScopedRestApiProvider>{children}</AuthScopedRestApiProvider>
|
||||
</CartProvider>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Props framework layouts, передающих готовое route subtree.
|
||||
*/
|
||||
export type AppLayoutProps = {
|
||||
/** Вложенное route subtree. */
|
||||
children: ReactNode
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Props dynamic product route в Next.js 16.
|
||||
*/
|
||||
export type ProductPageProps = {
|
||||
/** Асинхронные параметры dynamic segment. */
|
||||
params: Promise<{
|
||||
/** Идентификатор продукта из URL. */
|
||||
productId: string
|
||||
}>
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { StoreShellLayout } from './store-shell.layout'
|
||||
export type { StoreShellLayoutProps } from './types/store-shell-layout-props.type'
|
||||
@@ -0,0 +1,134 @@
|
||||
'use client'
|
||||
|
||||
import cl from 'clsx'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
import { useAuth } from '@/domains/auth'
|
||||
import { useCart } from '@/domains/cart'
|
||||
import { DemoToolbarWidget } from '@/compositions/widgets/demo-toolbar'
|
||||
import { Button } from '@/ui/button'
|
||||
|
||||
import type { StoreShellLayoutProps } from './types/store-shell-layout-props.type'
|
||||
import styles from './styles/store-shell.module.css'
|
||||
|
||||
/**
|
||||
* Общий каркас навигации и application-scoped controls магазина.
|
||||
*
|
||||
* Используется для:
|
||||
* - сохранения auth/cart контекста между App Router страницами
|
||||
* - доступа к deterministic demo controls
|
||||
*/
|
||||
export const StoreShellLayout = (props: StoreShellLayoutProps) => {
|
||||
const { children, className, ...rootAttrs } = props
|
||||
const pathname = usePathname()
|
||||
const auth = useAuth()
|
||||
const cart = useCart()
|
||||
const isAdmin = auth.status === 'authenticated' && auth.user?.role === 'admin'
|
||||
const hasSession = auth.sessionKey !== null
|
||||
const accountLabel = auth.user?.name ?? 'Sign in'
|
||||
|
||||
/**
|
||||
* Определяет активный верхнеуровневый navigation item.
|
||||
*/
|
||||
const isActivePath = (path: string): boolean => {
|
||||
return path === '/' ? pathname === '/' : pathname.startsWith(path)
|
||||
}
|
||||
|
||||
const isCatalogActive = isActivePath('/')
|
||||
const isCartActive = isActivePath('/cart')
|
||||
const isOrdersActive = isActivePath('/orders')
|
||||
const isAdminActive = isActivePath('/admin')
|
||||
|
||||
/**
|
||||
* Завершает текущую сессию из общего header.
|
||||
*/
|
||||
const handleSignOut = (): void => {
|
||||
void auth.signOut()
|
||||
}
|
||||
|
||||
/**
|
||||
* Пытается восстановить unreadable cart явным empty snapshot.
|
||||
*/
|
||||
const handleCartReset = (): void => {
|
||||
void cart.clearCart()
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...rootAttrs} className={cl(styles.root, className)}>
|
||||
<header className={styles.header}>
|
||||
<Link className={styles.brand} href="/" aria-label="Layer Supply home">
|
||||
<span className={styles.brandMark}>LS</span>
|
||||
<span className={styles.brandText}>
|
||||
Layer
|
||||
<br />
|
||||
Supply
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className={styles.nav} aria-label="Primary navigation">
|
||||
<Link
|
||||
className={cl(styles.navLink, isCatalogActive && styles.active)}
|
||||
href="/"
|
||||
aria-current={isCatalogActive ? 'page' : undefined}
|
||||
>
|
||||
Catalog
|
||||
</Link>
|
||||
<Link
|
||||
className={cl(styles.navLink, isCartActive && styles.active)}
|
||||
href="/cart"
|
||||
aria-current={isCartActive ? 'page' : undefined}
|
||||
>
|
||||
Cart <span className={styles.count}>{cart.itemCount}</span>
|
||||
</Link>
|
||||
<Link
|
||||
className={cl(styles.navLink, isOrdersActive && styles.active)}
|
||||
href="/orders"
|
||||
aria-current={isOrdersActive ? 'page' : undefined}
|
||||
>
|
||||
Orders
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<Link
|
||||
className={cl(styles.navLink, isAdminActive && styles.active)}
|
||||
href="/admin/products"
|
||||
aria-current={isAdminActive ? 'page' : undefined}
|
||||
>
|
||||
Admin
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className={styles.account}>
|
||||
<span className={styles.accountMeta}>{auth.status}</span>
|
||||
<Link className={styles.accountLink} href="/sign-in">
|
||||
{accountLabel}
|
||||
</Link>
|
||||
{hasSession && (
|
||||
<Button variant="ghost" size="small" onClick={handleSignOut}>
|
||||
Exit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{cart.error && (
|
||||
<div className={styles.cartError} role="alert">
|
||||
<span>{cart.error.message}</span>
|
||||
<Button variant="ghost" size="small" onClick={handleCartReset}>
|
||||
Reset cart
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className={styles.page}>{children}</div>
|
||||
|
||||
<footer className={styles.footer}>
|
||||
<span>Next.js 16 + SLM Level 1</span>
|
||||
<span>Simple API / localhost:3001</span>
|
||||
</footer>
|
||||
|
||||
<DemoToolbarWidget />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
.root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: clamp(18px, 4vw, 56px);
|
||||
min-height: 82px;
|
||||
padding: 12px clamp(18px, 5vw, 72px);
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
background: color-mix(in srgb, var(--color-paper) 88%, transparent);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.brandMark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
color: var(--color-paper);
|
||||
border-radius: 50%;
|
||||
background: var(--color-blue);
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.brandText {
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 820;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 0.82;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: clamp(14px, 3vw, 34px);
|
||||
}
|
||||
|
||||
.navLink {
|
||||
position: relative;
|
||||
padding: 10px 0;
|
||||
color: var(--color-ink-muted);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.navLink::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 3px;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
border-radius: 3px;
|
||||
background: var(--color-orange);
|
||||
transform: scaleX(0);
|
||||
transition: transform 160ms ease;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.navLink:hover,
|
||||
.active {
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.active::after {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.count {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
margin-left: 3px;
|
||||
padding: 0 5px;
|
||||
color: var(--color-ink);
|
||||
border-radius: 999px;
|
||||
background: var(--color-acid);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.account {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
align-items: center;
|
||||
justify-items: end;
|
||||
gap: 2px 10px;
|
||||
}
|
||||
|
||||
.accountMeta {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--color-ink-faint);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.accountLink {
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 720;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cartError {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
color: var(--color-error);
|
||||
border-left: 3px solid var(--color-error);
|
||||
background: color-mix(in srgb, var(--color-error) 8%, transparent);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.page {
|
||||
min-height: calc(100vh - 160px);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 28px clamp(18px, 5vw, 72px) 90px;
|
||||
color: var(--color-ink-faint);
|
||||
border-top: 1px solid var(--color-line);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@media (max-width: 850px) {
|
||||
.header {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.nav {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 540px) {
|
||||
.header {
|
||||
padding-inline: 14px;
|
||||
}
|
||||
|
||||
.brandText,
|
||||
.accountMeta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
flex-direction: column;
|
||||
padding-inline: 18px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
/**
|
||||
* Параметры общего storefront layout.
|
||||
*/
|
||||
export type StoreShellLayoutParams = object
|
||||
|
||||
/**
|
||||
* Атрибуты корневого контейнера.
|
||||
*/
|
||||
type RootAttrs = ComponentPropsWithoutRef<'div'>
|
||||
|
||||
/**
|
||||
* Props общего storefront layout.
|
||||
*/
|
||||
export type StoreShellLayoutProps = RootAttrs & StoreShellLayoutParams
|
||||
@@ -0,0 +1,374 @@
|
||||
'use client'
|
||||
|
||||
import type { ChangeEvent, FocusEvent } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import cl from 'clsx'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import { useAuth } from '@/domains/auth'
|
||||
import { getCartProductQuantityLimit, useCart } from '@/domains/cart'
|
||||
import { PRODUCT_IMAGE_PLACEHOLDER, useCatalogCommands } from '@/domains/catalog'
|
||||
import { useOrderCommands, validateOrderDraft } from '@/domains/orders'
|
||||
import type { OrderDraftLine } from '@/domains/orders'
|
||||
import { formatMoney } from '@/shared/lib/format-money'
|
||||
import { isEmptyArray, isNonEmptyArray } from '@/shared/lib/value-predicates'
|
||||
import { Button } from '@/ui/button'
|
||||
import { FeedbackPanel } from '@/ui/feedback-panel'
|
||||
|
||||
import type { CartScreenProps } from './types/cart-screen-props.type'
|
||||
import styles from './styles/cart.module.css'
|
||||
|
||||
/**
|
||||
* Multi-domain cart and checkout composition.
|
||||
*
|
||||
* Используется для:
|
||||
* - координации cart, auth и orders без междоменного цикла
|
||||
* - отображения stock и checkout errors
|
||||
*/
|
||||
export const CartScreen = (props: CartScreenProps) => {
|
||||
const { className, ...rootAttrs } = props
|
||||
const cart = useCart()
|
||||
const auth = useAuth()
|
||||
const catalogCommands = useCatalogCommands()
|
||||
const orderCommands = useOrderCommands()
|
||||
const router = useRouter()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [checkoutError, setCheckoutError] = useState<string | null>(null)
|
||||
const [quantityDraftsById, setQuantityDraftsById] = useState<Record<string, string>>({})
|
||||
const isMountedRef = useRef(false)
|
||||
const checkoutAttemptRef = useRef(0)
|
||||
const hasLines = isNonEmptyArray(cart.lines)
|
||||
const orderDraft: OrderDraftLine[] = cart.lines.map((line) => ({
|
||||
productId: line.product.id,
|
||||
quantity: line.quantity,
|
||||
availableStock: line.product.stock,
|
||||
currency: line.product.currency,
|
||||
expectedVersion: line.product.version,
|
||||
expectedUnitPriceCents: line.product.priceCents
|
||||
}))
|
||||
const draftError = validateOrderDraft(orderDraft)
|
||||
const canCheckout = draftError === null
|
||||
const totalLabel = cart.currency === null
|
||||
? 'Mixed currencies'
|
||||
: formatMoney(cart.subtotalCents, cart.currency)
|
||||
const checkoutLabel = auth.status === 'authenticated'
|
||||
? 'Place order'
|
||||
: auth.status === 'unavailable'
|
||||
? 'Retry session first'
|
||||
: 'Sign in to checkout'
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
checkoutAttemptRef.current += 1
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Сохраняет локальный quantity draft до commit на blur.
|
||||
*/
|
||||
const handleQuantityChange = (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
productId: string
|
||||
): void => {
|
||||
const value = event.currentTarget.value
|
||||
|
||||
setQuantityDraftsById((currentDrafts) => ({
|
||||
...currentDrafts,
|
||||
[productId]: value
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Применяет завершённое редактирование quantity без удаления на пустом draft.
|
||||
*/
|
||||
const handleQuantityCommit = (
|
||||
event: FocusEvent<HTMLInputElement>,
|
||||
productId: string
|
||||
): void => {
|
||||
const value = event.currentTarget.value.trim()
|
||||
|
||||
if (value === '') {
|
||||
setQuantityDraftsById((currentDrafts) => {
|
||||
const nextDrafts = { ...currentDrafts }
|
||||
|
||||
delete nextDrafts[productId]
|
||||
|
||||
return nextDrafts
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const quantity = Number(value)
|
||||
|
||||
if (Number.isFinite(quantity)) {
|
||||
void cart.setQuantity(productId, quantity)
|
||||
}
|
||||
|
||||
setQuantityDraftsById((currentDrafts) => {
|
||||
const nextDrafts = { ...currentDrafts }
|
||||
|
||||
delete nextDrafts[productId]
|
||||
|
||||
return nextDrafts
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет lifecycle текущей checkout-попытки после async boundary.
|
||||
*/
|
||||
const isCheckoutAttemptActive = (attemptId: number): boolean => {
|
||||
return isMountedRef.current && checkoutAttemptRef.current === attemptId
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт order из cart snapshot или направляет гостя к auth route.
|
||||
*/
|
||||
const handleCheckout = async (): Promise<void> => {
|
||||
if (auth.status === 'unavailable') {
|
||||
const result = await auth.refreshCurrentUser()
|
||||
|
||||
if (!result.isSuccess) {
|
||||
setCheckoutError(result.error.message)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (auth.status !== 'authenticated') {
|
||||
router.push('/sign-in')
|
||||
return
|
||||
}
|
||||
|
||||
const sessionKey = auth.sessionKey
|
||||
|
||||
if (sessionKey === null || isSubmitting) {
|
||||
return
|
||||
}
|
||||
|
||||
const cartRevision = cart.revision
|
||||
const attemptId = checkoutAttemptRef.current + 1
|
||||
|
||||
checkoutAttemptRef.current = attemptId
|
||||
|
||||
setIsSubmitting(true)
|
||||
setCheckoutError(null)
|
||||
|
||||
const productsResult = await catalogCommands.loadProducts(
|
||||
orderDraft.map((line) => line.productId)
|
||||
)
|
||||
|
||||
if (!isCheckoutAttemptActive(attemptId)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!productsResult.isSuccess) {
|
||||
setIsSubmitting(false)
|
||||
setCheckoutError(productsResult.error.message)
|
||||
return
|
||||
}
|
||||
|
||||
if (!auth.isCurrentSession(sessionKey)) {
|
||||
setIsSubmitting(false)
|
||||
setCheckoutError('The active session changed. Start checkout again.')
|
||||
return
|
||||
}
|
||||
|
||||
const reconciliationStatus = await cart.reconcileProducts(
|
||||
productsResult.data,
|
||||
cartRevision
|
||||
)
|
||||
|
||||
if (!isCheckoutAttemptActive(attemptId)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (reconciliationStatus === 'stale') {
|
||||
setIsSubmitting(false)
|
||||
setCheckoutError('The cart changed during checkout. Review it and try again.')
|
||||
return
|
||||
}
|
||||
|
||||
if (reconciliationStatus === 'unavailable') {
|
||||
setIsSubmitting(false)
|
||||
setCheckoutError('Cart storage is unavailable. Checkout was not started.')
|
||||
return
|
||||
}
|
||||
|
||||
if (reconciliationStatus === 'updated') {
|
||||
setIsSubmitting(false)
|
||||
setCheckoutError('Inventory changed. Review the refreshed totals before checkout.')
|
||||
return
|
||||
}
|
||||
|
||||
const cartRevisionStatus = await cart.isCurrentRevision(cartRevision)
|
||||
|
||||
if (!isCheckoutAttemptActive(attemptId)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (cartRevisionStatus === 'unavailable') {
|
||||
setIsSubmitting(false)
|
||||
setCheckoutError('Cart storage is unavailable. Checkout was not started.')
|
||||
return
|
||||
}
|
||||
|
||||
if (cartRevisionStatus === 'stale' || !auth.isCurrentSession(sessionKey)) {
|
||||
setIsSubmitting(false)
|
||||
setCheckoutError('The checkout scope changed. Review the cart and try again.')
|
||||
return
|
||||
}
|
||||
|
||||
const result = await orderCommands.createOrder(orderDraft, sessionKey)
|
||||
|
||||
if (result.isSuccess) {
|
||||
const clearStatus = await cart.clearCart(cartRevision)
|
||||
|
||||
if (!isCheckoutAttemptActive(attemptId)) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(false)
|
||||
|
||||
if (clearStatus === 'unavailable') {
|
||||
setCheckoutError('The order was created, but cart storage could not be updated.')
|
||||
return
|
||||
}
|
||||
|
||||
if (clearStatus === 'stale') {
|
||||
setCheckoutError('The order was created, but the cart changed in another tab.')
|
||||
return
|
||||
}
|
||||
|
||||
if (auth.isCurrentSession(sessionKey)) {
|
||||
router.push('/orders')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!isCheckoutAttemptActive(attemptId)) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(false)
|
||||
setCheckoutError(result.error.message)
|
||||
}
|
||||
|
||||
return (
|
||||
<main {...rootAttrs} className={cl(styles.root, className)}>
|
||||
<header className={styles.header}>
|
||||
<div>
|
||||
<p className={styles.kicker}>Cart / composition-owned checkout</p>
|
||||
<h1>Selected objects</h1>
|
||||
</div>
|
||||
<span className={styles.count}>{cart.itemCount} units</span>
|
||||
</header>
|
||||
|
||||
{isEmptyArray(cart.lines) && cart.isHydrated && (
|
||||
<FeedbackPanel
|
||||
variant="empty"
|
||||
title="Nothing crosses this boundary yet"
|
||||
description="Add an object from the catalog. The cart snapshot stays owned by its domain and persists locally."
|
||||
>
|
||||
<Link className={styles.catalogLink} href="/">
|
||||
Open catalog
|
||||
</Link>
|
||||
</FeedbackPanel>
|
||||
)}
|
||||
|
||||
{hasLines && (
|
||||
<div className={styles.layout}>
|
||||
<section className={styles.lines} aria-label="Cart products">
|
||||
{cart.lines.map((line) => (
|
||||
<article key={line.product.id} className={styles.line}>
|
||||
<Link
|
||||
className={styles.imageWrap}
|
||||
href={`/products/${line.product.id}`}
|
||||
aria-label={`Open ${line.product.name}`}
|
||||
>
|
||||
<Image
|
||||
className={styles.image}
|
||||
src={line.product.imageUrl}
|
||||
alt=""
|
||||
fill
|
||||
sizes="150px"
|
||||
onError={(event) => {
|
||||
event.currentTarget.srcset = ''
|
||||
event.currentTarget.src = PRODUCT_IMAGE_PLACEHOLDER
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
<div className={styles.lineInfo}>
|
||||
<span>{line.product.categoryId.replace('category-', '')}</span>
|
||||
<Link href={`/products/${line.product.id}`}>
|
||||
<h2>{line.product.name}</h2>
|
||||
</Link>
|
||||
<strong>{formatMoney(line.product.priceCents, line.product.currency)}</strong>
|
||||
</div>
|
||||
<label className={styles.quantity}>
|
||||
<span>Quantity</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={getCartProductQuantityLimit(line.product)}
|
||||
step={1}
|
||||
value={quantityDraftsById[line.product.id] ?? String(line.quantity)}
|
||||
disabled={isSubmitting}
|
||||
onChange={(event) => handleQuantityChange(event, line.product.id)}
|
||||
onBlur={(event) => handleQuantityCommit(event, line.product.id)}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="small"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => void cart.removeProduct(line.product.id)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<aside className={styles.summary}>
|
||||
<p className={styles.sequence}>03 / CHECKOUT</p>
|
||||
<h2>Order summary</h2>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Units</dt>
|
||||
<dd>{cart.itemCount}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Subtotal</dt>
|
||||
<dd>{totalLabel}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Session</dt>
|
||||
<dd>{auth.status}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{draftError && (
|
||||
<p className={styles.warning} role="status">
|
||||
{draftError.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{checkoutError && <p className={styles.error} role="alert">{checkoutError}</p>}
|
||||
|
||||
<Button
|
||||
isLoading={isSubmitting}
|
||||
disabled={!canCheckout || isSubmitting}
|
||||
onClick={handleCheckout}
|
||||
>
|
||||
{checkoutLabel}
|
||||
</Button>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CartScreen } from './cart.screen'
|
||||
export type { CartScreenProps } from './types/cart-screen-props.type'
|
||||
@@ -0,0 +1,200 @@
|
||||
.root {
|
||||
display: grid;
|
||||
gap: 34px;
|
||||
padding: clamp(42px, 7vw, 100px) clamp(18px, 6vw, 90px) 100px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding-bottom: 28px;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.kicker,
|
||||
.sequence,
|
||||
.lineInfo > span,
|
||||
.quantity span {
|
||||
color: var(--color-blue);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin-top: 13px;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(3rem, 8vw, 7.5rem);
|
||||
letter-spacing: -0.055em;
|
||||
line-height: 0.82;
|
||||
}
|
||||
|
||||
.count {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--color-line-strong);
|
||||
border-radius: 999px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(300px, 0.38fr);
|
||||
gap: clamp(30px, 6vw, 90px);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.lines {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.line {
|
||||
display: grid;
|
||||
grid-template-columns: 132px minmax(0, 1fr) 100px auto;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
padding: 18px 0;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.imageWrap {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 15px;
|
||||
background: var(--color-surface-strong);
|
||||
}
|
||||
|
||||
.image {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.lineInfo {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.lineInfo h2 {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.4rem, 3vw, 2.25rem);
|
||||
line-height: 0.95;
|
||||
}
|
||||
|
||||
.lineInfo strong {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.quantity {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.quantity input {
|
||||
width: 80px;
|
||||
min-height: 42px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--color-line-strong);
|
||||
border-radius: 10px;
|
||||
background: var(--color-surface);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.summary {
|
||||
position: sticky;
|
||||
top: 110px;
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
padding: clamp(24px, 4vw, 42px);
|
||||
border-radius: 24px;
|
||||
background: var(--color-acid);
|
||||
}
|
||||
|
||||
.sequence {
|
||||
color: color-mix(in srgb, var(--color-ink) 58%, transparent);
|
||||
}
|
||||
|
||||
.summary h2 {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(2rem, 4vw, 3.6rem);
|
||||
line-height: 0.9;
|
||||
}
|
||||
|
||||
.summary dl {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.summary dl div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 13px 0;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-ink) 18%, transparent);
|
||||
}
|
||||
|
||||
.summary dt {
|
||||
color: color-mix(in srgb, var(--color-ink) 60%, transparent);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.summary dd {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.warning,
|
||||
.error {
|
||||
padding: 11px 13px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: rgb(255 255 255 / 38%);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--color-error);
|
||||
background: rgb(255 255 255 / 55%);
|
||||
}
|
||||
|
||||
.catalogLink {
|
||||
padding: 14px 20px;
|
||||
color: var(--color-paper);
|
||||
border-radius: 999px;
|
||||
background: var(--color-ink);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.summary {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.line {
|
||||
grid-template-columns: 92px 1fr;
|
||||
}
|
||||
|
||||
.quantity,
|
||||
.line > button {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.quantity input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
/**
|
||||
* Параметры cart and checkout screen.
|
||||
*/
|
||||
export type CartScreenParams = object
|
||||
|
||||
/**
|
||||
* Атрибуты корневого main без внешнего содержимого.
|
||||
*/
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'main'>, 'children'>
|
||||
|
||||
/**
|
||||
* Props cart and checkout screen.
|
||||
*/
|
||||
export type CartScreenProps = RootAttrs & CartScreenParams
|
||||
@@ -0,0 +1,217 @@
|
||||
'use client'
|
||||
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { useDeferredValue, useState } from 'react'
|
||||
import cl from 'clsx'
|
||||
|
||||
import { useCategories, useProductCatalog } from '@/domains/catalog'
|
||||
import type { CatalogFilters } from '@/domains/catalog'
|
||||
import { useOnDemoFixtureChange } from '@/domains/demo-control'
|
||||
import { isEmptyArray, isNonEmptyArray, isOneOf } from '@/shared/lib/value-predicates'
|
||||
import { Button } from '@/ui/button'
|
||||
import { FeedbackPanel } from '@/ui/feedback-panel'
|
||||
|
||||
import { ProductCard } from './ui/product-card'
|
||||
import type { CatalogScreenProps } from './types/catalog-screen-props.type'
|
||||
import styles from './styles/catalog.module.css'
|
||||
|
||||
const CATALOG_SORTS = ['newest', 'price-asc', 'price-desc', 'name'] as const
|
||||
const SKELETON_IDS = ['one', 'two', 'three', 'four', 'five', 'six'] as const
|
||||
|
||||
/**
|
||||
* Публичная витрина с фильтрами и offset pagination.
|
||||
*
|
||||
* Используется для:
|
||||
* - поиска и сравнения fixture-продуктов
|
||||
* - демонстрации loading, empty и transport error outcomes
|
||||
*/
|
||||
export const CatalogScreen = (props: CatalogScreenProps) => {
|
||||
const { className, ...rootAttrs } = props
|
||||
const [filters, setFilters] = useState<CatalogFilters>({
|
||||
page: 1,
|
||||
limit: 12,
|
||||
search: '',
|
||||
categoryId: '',
|
||||
sort: 'newest'
|
||||
})
|
||||
const deferredSearch = useDeferredValue(filters.search)
|
||||
const catalog = useProductCatalog({ ...filters, search: deferredSearch })
|
||||
const categoriesState = useCategories()
|
||||
const hasProducts = isNonEmptyArray(catalog.products)
|
||||
const shouldShowEmpty =
|
||||
!catalog.isLoading && catalog.error === null && isEmptyArray(catalog.products)
|
||||
const resultSummary = catalog.pagination
|
||||
? `${catalog.pagination.total} objects / page ${catalog.pagination.page}`
|
||||
: 'Connecting to inventory'
|
||||
const canGoBack = (catalog.pagination?.page ?? 1) > 1
|
||||
const canGoForward =
|
||||
catalog.pagination !== null && catalog.pagination.page < catalog.pagination.totalPages
|
||||
|
||||
useOnDemoFixtureChange((kind) => {
|
||||
if (kind === 'data') {
|
||||
setFilters((current) => ({ ...current, page: 1 }))
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Обновляет search-фильтр, сохраняя ввод отзывчивым через deferred value.
|
||||
*/
|
||||
const handleSearchChange = (event: ChangeEvent<HTMLInputElement>): void => {
|
||||
setFilters((current) => ({ ...current, search: event.target.value, page: 1 }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Применяет category-фильтр и возвращает выдачу на первую страницу.
|
||||
*/
|
||||
const handleCategoryChange = (event: ChangeEvent<HTMLSelectElement>): void => {
|
||||
setFilters((current) => ({ ...current, categoryId: event.target.value, page: 1 }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Применяет только поддерживаемую доменом сортировку.
|
||||
*/
|
||||
const handleSortChange = (event: ChangeEvent<HTMLSelectElement>): void => {
|
||||
const value = event.target.value
|
||||
|
||||
if (isOneOf(value, CATALOG_SORTS)) {
|
||||
setFilters((current) => ({ ...current, sort: value, page: 1 }))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает предыдущую страницу выдачи.
|
||||
*/
|
||||
const handlePreviousPage = (): void => {
|
||||
setFilters((current) => ({ ...current, page: Math.max(1, current.page - 1) }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает следующую страницу выдачи.
|
||||
*/
|
||||
const handleNextPage = (): void => {
|
||||
setFilters((current) => ({ ...current, page: current.page + 1 }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Повторяет неуспешный catalog query.
|
||||
*/
|
||||
const handleReload = (): void => {
|
||||
void catalog.reload()
|
||||
}
|
||||
|
||||
return (
|
||||
<main {...rootAttrs} className={cl(styles.root, className)}>
|
||||
<section className={styles.hero} aria-labelledby="catalog-title">
|
||||
<p className={styles.kicker}>Deterministic objects for frontend systems</p>
|
||||
<h1 className={styles.title} id="catalog-title">
|
||||
Useful things,
|
||||
<br />
|
||||
visible boundaries.
|
||||
</h1>
|
||||
<p className={styles.lede}>
|
||||
A storefront where every loading state, cache edge, auth transition and conflict can be
|
||||
reproduced on demand.
|
||||
</p>
|
||||
<span className={styles.sequence}>01 / CATALOG</span>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className={styles.catalog}
|
||||
aria-label="Product catalog"
|
||||
aria-busy={catalog.isLoading || catalog.isRefreshing}
|
||||
>
|
||||
<div className={styles.filters}>
|
||||
<label className={styles.searchField}>
|
||||
<span>Search inventory</span>
|
||||
<input
|
||||
type="search"
|
||||
value={filters.search}
|
||||
placeholder="Keyboard, desk, systems..."
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className={styles.selectField}>
|
||||
<span>Category</span>
|
||||
<select value={filters.categoryId} onChange={handleCategoryChange}>
|
||||
<option value="">All categories</option>
|
||||
{categoriesState.categories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name} ({category.productCount})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className={styles.selectField}>
|
||||
<span>Order</span>
|
||||
<select value={filters.sort} onChange={handleSortChange}>
|
||||
<option value="newest">Newest first</option>
|
||||
<option value="price-asc">Price, low to high</option>
|
||||
<option value="price-desc">Price, high to low</option>
|
||||
<option value="name">Name</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className={styles.resultBar}>
|
||||
<span>{resultSummary}</span>
|
||||
{catalog.isRefreshing && <span className={styles.refreshing}>Refreshing</span>}
|
||||
</div>
|
||||
|
||||
{catalog.isLoading && (
|
||||
<div
|
||||
className={styles.productGrid}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label="Loading products"
|
||||
>
|
||||
{SKELETON_IDS.map((id) => (
|
||||
<div key={id} className={styles.skeleton} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{catalog.error && (
|
||||
<FeedbackPanel
|
||||
variant="error"
|
||||
title="The catalog boundary held"
|
||||
description={catalog.error.message}
|
||||
>
|
||||
<Button onClick={handleReload}>Retry request</Button>
|
||||
</FeedbackPanel>
|
||||
)}
|
||||
|
||||
{shouldShowEmpty && (
|
||||
<FeedbackPanel
|
||||
variant="empty"
|
||||
title="A valid empty state"
|
||||
description="No objects match this request. Try another filter or select the normal demo scenario."
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasProducts && (
|
||||
<div className={styles.productGrid}>
|
||||
{catalog.products.map((product, index) => (
|
||||
<ProductCard key={product.id} product={product} index={index} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{catalog.pagination && (catalog.pagination.totalPages > 1 || filters.page > 1) && (
|
||||
<div className={styles.pagination}>
|
||||
<Button variant="ghost" disabled={!canGoBack} onClick={handlePreviousPage}>
|
||||
Previous
|
||||
</Button>
|
||||
<span>
|
||||
{catalog.pagination.page} / {catalog.pagination.totalPages}
|
||||
</span>
|
||||
<Button variant="ghost" disabled={!canGoForward} onClick={handleNextPage}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CatalogScreen } from './catalog.screen'
|
||||
export type { CatalogScreenProps } from './types/catalog-screen-props.type'
|
||||
@@ -0,0 +1,212 @@
|
||||
.root {
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(250px, 0.34fr);
|
||||
gap: 20px 60px;
|
||||
min-height: 520px;
|
||||
padding: clamp(72px, 11vw, 150px) clamp(20px, 7vw, 110px) 80px;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.hero::before {
|
||||
position: absolute;
|
||||
top: 18%;
|
||||
right: -8vw;
|
||||
width: clamp(230px, 34vw, 520px);
|
||||
aspect-ratio: 1;
|
||||
border: 1px solid var(--color-line-strong);
|
||||
border-radius: 50%;
|
||||
box-shadow:
|
||||
inset 0 0 0 38px var(--color-paper),
|
||||
inset 0 0 0 39px var(--color-line);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.hero::after {
|
||||
position: absolute;
|
||||
right: 15%;
|
||||
bottom: -140px;
|
||||
width: 240px;
|
||||
height: 300px;
|
||||
background: var(--color-acid);
|
||||
transform: rotate(24deg);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.kicker,
|
||||
.sequence {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--color-blue);
|
||||
}
|
||||
|
||||
.title {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 10ch;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(3.3rem, 9vw, 8.8rem);
|
||||
letter-spacing: -0.065em;
|
||||
line-height: 0.78;
|
||||
}
|
||||
|
||||
.lede {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
align-self: end;
|
||||
max-width: 33ch;
|
||||
color: var(--color-ink-muted);
|
||||
font-size: clamp(1rem, 1.6vw, 1.3rem);
|
||||
line-height: 1.58;
|
||||
}
|
||||
|
||||
.sequence {
|
||||
position: absolute;
|
||||
bottom: 30px;
|
||||
left: clamp(20px, 7vw, 110px);
|
||||
color: var(--color-ink-faint);
|
||||
}
|
||||
|
||||
.catalog {
|
||||
display: grid;
|
||||
gap: 26px;
|
||||
padding: 54px clamp(18px, 5vw, 72px) 0;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 1fr) repeat(2, minmax(180px, 0.35fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.searchField,
|
||||
.selectField {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filters label > span {
|
||||
color: var(--color-ink-faint);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.filters :is(input, select) {
|
||||
width: 100%;
|
||||
min-height: 50px;
|
||||
padding: 0 15px;
|
||||
color: var(--color-ink);
|
||||
border: 1px solid var(--color-line-strong);
|
||||
border-radius: 13px;
|
||||
outline: none;
|
||||
background: var(--color-surface);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.filters :is(input, select):focus {
|
||||
border-color: var(--color-blue);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-blue) 14%, transparent);
|
||||
}
|
||||
|
||||
.resultBar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
min-height: 24px;
|
||||
color: var(--color-ink-faint);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.refreshing {
|
||||
color: var(--color-blue);
|
||||
}
|
||||
|
||||
.productGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: clamp(14px, 2vw, 26px);
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
min-height: 410px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 22px;
|
||||
background:
|
||||
linear-gradient(105deg, transparent 30%, rgb(255 255 255 / 48%) 45%, transparent 60%)
|
||||
0 0 / 220% 100%,
|
||||
var(--color-surface-strong);
|
||||
animation: sweep 1.4s linear infinite;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
padding-top: 18px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
@keyframes sweep {
|
||||
to {
|
||||
background-position: -220% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.lede {
|
||||
max-width: 50ch;
|
||||
}
|
||||
|
||||
.filters,
|
||||
.productGrid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.searchField {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hero {
|
||||
min-height: 440px;
|
||||
padding-top: 70px;
|
||||
}
|
||||
|
||||
.hero::after {
|
||||
right: -40px;
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
.filters,
|
||||
.productGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.searchField {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
/**
|
||||
* Параметры storefront catalog screen.
|
||||
*/
|
||||
export type CatalogScreenParams = object
|
||||
|
||||
/**
|
||||
* Атрибуты корневого main без внешнего содержимого.
|
||||
*/
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'main'>, 'children'>
|
||||
|
||||
/**
|
||||
* Props storefront catalog screen.
|
||||
*/
|
||||
export type CatalogScreenProps = RootAttrs & CatalogScreenParams
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ProductCard } from './product-card'
|
||||
export type { ProductCardProps } from './types/product-card-props.type'
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
|
||||
import cl from 'clsx'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { useCart } from '@/domains/cart'
|
||||
import { PRODUCT_IMAGE_PLACEHOLDER } from '@/domains/catalog'
|
||||
import { formatMoney } from '@/shared/lib/format-money'
|
||||
import { Button } from '@/ui/button'
|
||||
|
||||
import type { ProductCardProps } from './types/product-card-props.type'
|
||||
import styles from './styles/product-card.module.css'
|
||||
|
||||
/**
|
||||
* Карточка продукта для grid-выдачи catalog screen.
|
||||
*
|
||||
* Используется для:
|
||||
* - перехода к detail route
|
||||
* - добавления доступного продукта в application cart
|
||||
*/
|
||||
export const ProductCard = (props: ProductCardProps) => {
|
||||
const { product, index, className, ...rootAttrs } = props
|
||||
const cart = useCart()
|
||||
const isSoldOut = product.stock === 0
|
||||
const stockLabel = isSoldOut ? 'Out of stock' : `${product.stock} ready`
|
||||
|
||||
/**
|
||||
* Добавляет продукт в cart domain без transport-зависимости компонента.
|
||||
*/
|
||||
const handleAdd = (): void => {
|
||||
void cart.addProduct(product)
|
||||
}
|
||||
|
||||
return (
|
||||
<article {...rootAttrs} className={cl(styles.root, className)}>
|
||||
<Link
|
||||
className={styles.imageLink}
|
||||
href={`/products/${product.id}`}
|
||||
aria-label={`Open ${product.name}`}
|
||||
>
|
||||
<Image
|
||||
className={styles.image}
|
||||
src={product.imageUrl}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 720px) 100vw, (max-width: 1100px) 50vw, 33vw"
|
||||
onError={(event) => {
|
||||
event.currentTarget.srcset = ''
|
||||
event.currentTarget.src = PRODUCT_IMAGE_PLACEHOLDER
|
||||
}}
|
||||
/>
|
||||
<span className={styles.index}>{String(index + 1).padStart(2, '0')}</span>
|
||||
</Link>
|
||||
|
||||
<div className={styles.body}>
|
||||
<div className={styles.meta}>
|
||||
<span>{stockLabel}</span>
|
||||
<span>{product.rating.toFixed(1)} / 5</span>
|
||||
</div>
|
||||
<Link href={`/products/${product.id}`}>
|
||||
<h2 className={styles.name}>{product.name}</h2>
|
||||
</Link>
|
||||
<p className={styles.description}>{product.description}</p>
|
||||
<div className={styles.footer}>
|
||||
<strong className={styles.price}>{formatMoney(product.priceCents, product.currency)}</strong>
|
||||
<Button size="small" disabled={isSoldOut} onClick={handleAdd}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
.root {
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 22px;
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-card);
|
||||
transition:
|
||||
transform 200ms ease,
|
||||
box-shadow 200ms ease;
|
||||
}
|
||||
|
||||
.root:hover {
|
||||
box-shadow: var(--shadow-card-hover);
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.imageLink {
|
||||
position: relative;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 4 / 3;
|
||||
background: var(--color-surface-strong);
|
||||
}
|
||||
|
||||
.image {
|
||||
object-fit: cover;
|
||||
filter: saturate(0.78) contrast(1.04);
|
||||
transition: transform 420ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
.root:hover .image {
|
||||
transform: scale(1.035);
|
||||
}
|
||||
|
||||
.index {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
color: var(--color-paper);
|
||||
border-radius: 50%;
|
||||
background: var(--color-blue);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr auto;
|
||||
gap: 13px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.meta,
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--color-ink-faint);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.64rem;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.35rem, 2.5vw, 2rem);
|
||||
line-height: 0.98;
|
||||
}
|
||||
|
||||
.description {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
color: var(--color-ink-muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.price {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.94rem;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
import type { Product } from '@/domains/catalog'
|
||||
|
||||
/**
|
||||
* Собственные параметры product card внутри catalog screen.
|
||||
*/
|
||||
export type ProductCardParams = {
|
||||
/** Catalog-модель продукта. */
|
||||
product: Product
|
||||
/** Позиция карточки для editorial numbering. */
|
||||
index: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Атрибуты корневой article-карточки без внешнего содержимого.
|
||||
*/
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'article'>, 'children'>
|
||||
|
||||
/**
|
||||
* Props product card внутри catalog screen.
|
||||
*/
|
||||
export type ProductCardProps = RootAttrs & ProductCardParams
|
||||
@@ -0,0 +1,2 @@
|
||||
export { OrdersScreen } from './orders.screen'
|
||||
export type { OrdersScreenProps } from './types/orders-screen-props.type'
|
||||
@@ -0,0 +1,212 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import cl from 'clsx'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { useAuth } from '@/domains/auth'
|
||||
import { canCancelOrder, useOrderCommands, useOrders } from '@/domains/orders'
|
||||
import type { OrderError } from '@/domains/orders'
|
||||
import { formatDate } from '@/shared/lib/format-date'
|
||||
import { formatMoney } from '@/shared/lib/format-money'
|
||||
import { isEmptyArray, isNonEmptyArray } from '@/shared/lib/value-predicates'
|
||||
import { Button } from '@/ui/button'
|
||||
import { FeedbackPanel } from '@/ui/feedback-panel'
|
||||
|
||||
import type { OrdersScreenProps } from './types/orders-screen-props.type'
|
||||
import styles from './styles/orders.module.css'
|
||||
|
||||
/**
|
||||
* Protected история заказов с допустимыми state transitions.
|
||||
*
|
||||
* Используется для:
|
||||
* - проверки auth-aware deferred query
|
||||
* - демонстрации успешной и конфликтной отмены заказа
|
||||
*/
|
||||
export const OrdersScreen = (props: OrdersScreenProps) => {
|
||||
const { className, ...rootAttrs } = props
|
||||
const auth = useAuth()
|
||||
const isAuthenticated = auth.status === 'authenticated'
|
||||
const ordersState = useOrders(isAuthenticated)
|
||||
const orderCommands = useOrderCommands()
|
||||
const [cancellingOrderId, setCancellingOrderId] = useState<string | null>(null)
|
||||
const [actionError, setActionError] = useState<OrderError | null>(null)
|
||||
const hasOrders = isNonEmptyArray(ordersState.orders)
|
||||
|
||||
/**
|
||||
* Повторяет неуспешный protected orders query.
|
||||
*/
|
||||
const handleReload = (): void => {
|
||||
void ordersState.reload()
|
||||
}
|
||||
|
||||
/**
|
||||
* Повторяет recoverable auth check перед protected query.
|
||||
*/
|
||||
const handleSessionRetry = (): void => {
|
||||
void auth.refreshCurrentUser()
|
||||
}
|
||||
|
||||
/**
|
||||
* Запрашивает допустимый переход заказа в cancelled.
|
||||
*/
|
||||
const handleCancel = async (orderId: string): Promise<void> => {
|
||||
const sessionKey = auth.sessionKey
|
||||
|
||||
if (sessionKey === null) {
|
||||
return
|
||||
}
|
||||
|
||||
setCancellingOrderId(orderId)
|
||||
setActionError(null)
|
||||
|
||||
const result = await orderCommands.cancelOrder(orderId, sessionKey)
|
||||
|
||||
setCancellingOrderId(null)
|
||||
|
||||
if (!result.isSuccess) {
|
||||
setActionError(result.error)
|
||||
}
|
||||
}
|
||||
|
||||
if (auth.status === 'checking') {
|
||||
return (
|
||||
<main {...rootAttrs} className={cl(styles.root, styles.outcome, className)}>
|
||||
<FeedbackPanel
|
||||
title="Restoring protected scope"
|
||||
description="The orders query stays disabled until auth resolves its application-scoped session."
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
if (auth.status === 'unavailable') {
|
||||
return (
|
||||
<main {...rootAttrs} className={cl(styles.root, styles.outcome, className)}>
|
||||
<FeedbackPanel
|
||||
variant="error"
|
||||
title="Session authority is unavailable"
|
||||
description={auth.sessionError?.message ?? 'The session was preserved for retry.'}
|
||||
>
|
||||
<Button onClick={handleSessionRetry}>Retry session</Button>
|
||||
</FeedbackPanel>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main {...rootAttrs} className={cl(styles.root, styles.outcome, className)}>
|
||||
<FeedbackPanel
|
||||
variant="empty"
|
||||
title="This route needs a session"
|
||||
description="Sign in as customer to see own orders, or as admin to see every fixture order."
|
||||
>
|
||||
<Link className={styles.primaryLink} href="/sign-in">
|
||||
Choose account
|
||||
</Link>
|
||||
</FeedbackPanel>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main
|
||||
{...rootAttrs}
|
||||
className={cl(styles.root, className)}
|
||||
aria-busy={ordersState.isLoading}
|
||||
>
|
||||
<header className={styles.header}>
|
||||
<div>
|
||||
<p className={styles.kicker}>Orders / {auth.user?.role}</p>
|
||||
<h1>State in motion</h1>
|
||||
</div>
|
||||
<p className={styles.lede}>
|
||||
Backend transitions remain authoritative. The UI only offers cancellation where the
|
||||
current domain model allows it.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{ordersState.isLoading && (
|
||||
<div
|
||||
className={styles.loading}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label="Loading orders"
|
||||
/>
|
||||
)}
|
||||
|
||||
{ordersState.error && (
|
||||
<FeedbackPanel
|
||||
variant="error"
|
||||
title="Orders stayed inside their boundary"
|
||||
description={ordersState.error.message}
|
||||
>
|
||||
<Button onClick={handleReload}>Retry orders</Button>
|
||||
</FeedbackPanel>
|
||||
)}
|
||||
|
||||
{actionError && <p className={styles.actionError} role="alert">{actionError.message}</p>}
|
||||
|
||||
{isEmptyArray(ordersState.orders) && !ordersState.isLoading && ordersState.error === null && (
|
||||
<FeedbackPanel
|
||||
variant="empty"
|
||||
title="No visible orders"
|
||||
description="Create one from the cart or switch the demo scenario back to normal."
|
||||
>
|
||||
<Link className={styles.primaryLink} href="/">
|
||||
Browse catalog
|
||||
</Link>
|
||||
</FeedbackPanel>
|
||||
)}
|
||||
|
||||
{hasOrders && (
|
||||
<section className={styles.orders} aria-label="Order history">
|
||||
{ordersState.orders.map((order, index) => {
|
||||
const canCancel = canCancelOrder(order)
|
||||
const isCancelling = cancellingOrderId === order.id
|
||||
|
||||
return (
|
||||
<article key={order.id} className={styles.order}>
|
||||
<div className={styles.orderIndex}>{String(index + 1).padStart(2, '0')}</div>
|
||||
<div className={styles.orderHeader}>
|
||||
<div>
|
||||
<span>{formatDate(order.createdAt)}</span>
|
||||
<h2>{order.id}</h2>
|
||||
</div>
|
||||
<span className={styles.status} data-status={order.status}>
|
||||
{order.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.lines}>
|
||||
{order.lines.map((line) => (
|
||||
<div key={line.productId} className={styles.line}>
|
||||
<span>{line.quantity} x</span>
|
||||
<strong>{line.productName}</strong>
|
||||
<span>{formatMoney(line.unitPriceCents, order.currency)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.orderFooter}>
|
||||
<strong>{formatMoney(order.totalCents, order.currency)}</strong>
|
||||
{canCancel && (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="small"
|
||||
isLoading={isCancelling}
|
||||
onClick={() => handleCancel(order.id)}
|
||||
>
|
||||
Cancel order
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
.root {
|
||||
display: grid;
|
||||
gap: 38px;
|
||||
padding: clamp(48px, 8vw, 112px) clamp(18px, 7vw, 110px) 110px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(260px, 0.42fr);
|
||||
gap: 40px;
|
||||
align-items: end;
|
||||
padding-bottom: 34px;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.kicker {
|
||||
color: var(--color-blue);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
max-width: 9ch;
|
||||
margin-top: 14px;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(3rem, 8vw, 7.4rem);
|
||||
letter-spacing: -0.055em;
|
||||
line-height: 0.8;
|
||||
}
|
||||
|
||||
.lede {
|
||||
color: var(--color-ink-muted);
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.orders {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.order {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 26px;
|
||||
overflow: hidden;
|
||||
padding: clamp(24px, 4vw, 38px);
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 24px;
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.orderIndex {
|
||||
position: absolute;
|
||||
right: -8px;
|
||||
bottom: -22px;
|
||||
color: color-mix(in srgb, var(--color-ink) 5%, transparent);
|
||||
font-family: var(--font-display);
|
||||
font-size: 9rem;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.orderHeader,
|
||||
.orderFooter,
|
||||
.line {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.orderHeader span,
|
||||
.line span {
|
||||
color: var(--color-ink-faint);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.orderHeader h2 {
|
||||
margin-top: 6px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.7rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 8px 11px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface-strong);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status[data-status='pending'] {
|
||||
color: #845300;
|
||||
background: #ffe5ad;
|
||||
}
|
||||
|
||||
.status[data-status='paid'] {
|
||||
color: #215a00;
|
||||
background: #dfffa0;
|
||||
}
|
||||
|
||||
.status[data-status='shipped'] {
|
||||
color: #123ec0;
|
||||
background: #dae4ff;
|
||||
}
|
||||
|
||||
.status[data-status='cancelled'] {
|
||||
color: #8b2920;
|
||||
background: #ffd9d5;
|
||||
}
|
||||
|
||||
.lines {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.line {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
padding: 11px 0;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.line strong {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.orderFooter {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.orderFooter > strong {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.actionError {
|
||||
padding: 14px 18px;
|
||||
color: var(--color-error);
|
||||
border-left: 5px solid var(--color-error);
|
||||
background: color-mix(in srgb, var(--color-error) 8%, transparent);
|
||||
}
|
||||
|
||||
.loading {
|
||||
min-height: 380px;
|
||||
border-radius: 24px;
|
||||
background: var(--color-surface-strong);
|
||||
animation: pulse 1s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.outcome {
|
||||
min-height: calc(100vh - 82px);
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.primaryLink {
|
||||
padding: 14px 20px;
|
||||
color: var(--color-paper);
|
||||
border-radius: 999px;
|
||||
background: var(--color-ink);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
to {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 850px) {
|
||||
.header,
|
||||
.orders {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
/**
|
||||
* Параметры protected orders screen.
|
||||
*/
|
||||
export type OrdersScreenParams = object
|
||||
|
||||
/**
|
||||
* Атрибуты корневого main без внешнего содержимого.
|
||||
*/
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'main'>, 'children'>
|
||||
|
||||
/**
|
||||
* Props protected orders screen.
|
||||
*/
|
||||
export type OrdersScreenProps = RootAttrs & OrdersScreenParams
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ProductAdminScreen } from './product-admin.screen'
|
||||
export type { ProductAdminScreenProps } from './types/product-admin-screen-props.type'
|
||||
@@ -0,0 +1,351 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import cl from 'clsx'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { useAuth } from '@/domains/auth'
|
||||
import {
|
||||
useCategories,
|
||||
useCatalogCommands,
|
||||
useProductCatalog
|
||||
} from '@/domains/catalog'
|
||||
import type {
|
||||
CatalogError,
|
||||
CreateProductInput,
|
||||
Product,
|
||||
UpdateProductInput
|
||||
} from '@/domains/catalog'
|
||||
import { useOnDemoFixtureChange } from '@/domains/demo-control'
|
||||
import { formatMoney } from '@/shared/lib/format-money'
|
||||
import { isNonEmptyArray } from '@/shared/lib/value-predicates'
|
||||
import { Button } from '@/ui/button'
|
||||
import { FeedbackPanel } from '@/ui/feedback-panel'
|
||||
|
||||
import { ProductForm } from './ui/product-form'
|
||||
import type { ProductAdminScreenProps } from './types/product-admin-screen-props.type'
|
||||
import styles from './styles/product-admin.module.css'
|
||||
|
||||
/**
|
||||
* Admin-only catalog mutation workspace.
|
||||
*
|
||||
* Используется для:
|
||||
* - create/update/delete RBAC-сценариев
|
||||
* - проверки optimistic-lock conflict outcome
|
||||
*/
|
||||
export const ProductAdminScreen = (props: ProductAdminScreenProps) => {
|
||||
const { className, ...rootAttrs } = props
|
||||
const auth = useAuth()
|
||||
const catalogCommands = useCatalogCommands()
|
||||
const [adminPage, setAdminPage] = useState(1)
|
||||
const catalog = useProductCatalog({
|
||||
page: adminPage,
|
||||
limit: 100,
|
||||
search: '',
|
||||
categoryId: '',
|
||||
sort: 'newest'
|
||||
})
|
||||
const categoriesState = useCategories()
|
||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null)
|
||||
const [formGeneration, setFormGeneration] = useState(0)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [deletingProductId, setDeletingProductId] = useState<string | null>(null)
|
||||
const [mutationError, setMutationError] = useState<CatalogError | null>(null)
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null)
|
||||
const isAdmin = auth.status === 'authenticated' && auth.user?.role === 'admin'
|
||||
const hasProducts = isNonEmptyArray(catalog.products)
|
||||
const canGoBack = (catalog.pagination?.page ?? 1) > 1
|
||||
const canGoForward =
|
||||
catalog.pagination !== null && catalog.pagination.page < catalog.pagination.totalPages
|
||||
const isSelectedProductAvailable =
|
||||
selectedProduct !== null &&
|
||||
(
|
||||
catalog.isLoading ||
|
||||
catalog.error !== null ||
|
||||
catalog.products.some((product) => product.id === selectedProduct.id)
|
||||
)
|
||||
const formProduct = isSelectedProductAvailable ? selectedProduct : null
|
||||
const formKey = `${formProduct?.id ?? 'new'}-${formProduct?.version ?? 0}-${categoriesState.categories.length}-${formGeneration}`
|
||||
|
||||
useOnDemoFixtureChange((kind) => {
|
||||
if (kind === 'data') {
|
||||
setAdminPage(1)
|
||||
setSelectedProduct(null)
|
||||
setMutationError(null)
|
||||
setFormGeneration((value) => value + 1)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Переключает product form в edit mode с последним catalog snapshot.
|
||||
*/
|
||||
const handleEdit = (product: Product): void => {
|
||||
setSelectedProduct(product)
|
||||
setMutationError(null)
|
||||
setSuccessMessage(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает product form в create mode.
|
||||
*/
|
||||
const handleCancelEdit = (): void => {
|
||||
setSelectedProduct(null)
|
||||
setMutationError(null)
|
||||
setFormGeneration((value) => value + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Выбирает create или update domain scenario по форме input.
|
||||
*/
|
||||
const handleSubmit = async (
|
||||
input: CreateProductInput | UpdateProductInput
|
||||
): Promise<void> => {
|
||||
const sessionKey = auth.sessionKey
|
||||
|
||||
if (sessionKey === null) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setMutationError(null)
|
||||
setSuccessMessage(null)
|
||||
|
||||
const result = 'id' in input
|
||||
? await catalogCommands.updateProduct(input, sessionKey)
|
||||
: await catalogCommands.createProduct(input, sessionKey)
|
||||
|
||||
setIsSubmitting(false)
|
||||
|
||||
if (!result.isSuccess) {
|
||||
setMutationError(result.error)
|
||||
return
|
||||
}
|
||||
|
||||
setSuccessMessage(`${result.data.name} saved at version ${result.data.version}.`)
|
||||
setSelectedProduct(null)
|
||||
setFormGeneration((value) => value + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет продукт после явного browser confirmation.
|
||||
*/
|
||||
const handleRemove = async (product: Product): Promise<void> => {
|
||||
const sessionKey = auth.sessionKey
|
||||
|
||||
if (sessionKey === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const shouldRemove = window.confirm(`Remove ${product.name}?`)
|
||||
|
||||
if (!shouldRemove) {
|
||||
return
|
||||
}
|
||||
|
||||
setDeletingProductId(product.id)
|
||||
setMutationError(null)
|
||||
setSuccessMessage(null)
|
||||
|
||||
const result = await catalogCommands.removeProduct(product.id, sessionKey)
|
||||
|
||||
setDeletingProductId(null)
|
||||
|
||||
if (!result.isSuccess) {
|
||||
setMutationError(result.error)
|
||||
return
|
||||
}
|
||||
|
||||
setSuccessMessage(`${product.name} removed.`)
|
||||
|
||||
if (selectedProduct?.id === product.id) {
|
||||
setSelectedProduct(null)
|
||||
setFormGeneration((value) => value + 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Повторяет catalog query после transport failure.
|
||||
*/
|
||||
const handleReload = (): void => {
|
||||
void catalog.reload()
|
||||
}
|
||||
|
||||
/**
|
||||
* Открывает предыдущую admin inventory page.
|
||||
*/
|
||||
const handlePreviousPage = (): void => {
|
||||
setAdminPage((page) => Math.max(1, page - 1))
|
||||
}
|
||||
|
||||
/**
|
||||
* Открывает следующую admin inventory page.
|
||||
*/
|
||||
const handleNextPage = (): void => {
|
||||
setAdminPage((page) => page + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Повторяет recoverable auth check перед admin workspace.
|
||||
*/
|
||||
const handleSessionRetry = (): void => {
|
||||
void auth.refreshCurrentUser()
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает authority snapshot после optimistic-lock conflict.
|
||||
*/
|
||||
const handleLoadLatest = async (): Promise<void> => {
|
||||
if (formProduct === null) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
const result = await catalogCommands.loadProducts([formProduct.id])
|
||||
setIsSubmitting(false)
|
||||
|
||||
if (!result.isSuccess) {
|
||||
setMutationError(result.error)
|
||||
return
|
||||
}
|
||||
|
||||
const latestProduct = result.data[0]
|
||||
|
||||
if (latestProduct !== undefined) {
|
||||
setSelectedProduct(latestProduct)
|
||||
setMutationError(null)
|
||||
setSuccessMessage('Loaded the latest product version. Review changes before saving.')
|
||||
}
|
||||
}
|
||||
|
||||
if (auth.status === 'checking') {
|
||||
return (
|
||||
<main {...rootAttrs} className={cl(styles.root, styles.outcome, className)}>
|
||||
<FeedbackPanel
|
||||
title="Resolving admin scope"
|
||||
description="Catalog mutation UI waits for the auth owner before rendering protected controls."
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
if (auth.status === 'unavailable') {
|
||||
return (
|
||||
<main {...rootAttrs} className={cl(styles.root, styles.outcome, className)}>
|
||||
<FeedbackPanel
|
||||
variant="error"
|
||||
title="Admin session is temporarily unavailable"
|
||||
description={auth.sessionError?.message ?? 'The session was preserved for retry.'}
|
||||
>
|
||||
<Button onClick={handleSessionRetry}>Retry session</Button>
|
||||
</FeedbackPanel>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main {...rootAttrs} className={cl(styles.root, styles.outcome, className)}>
|
||||
<FeedbackPanel
|
||||
variant="error"
|
||||
title="Admin boundary"
|
||||
description="This composition does not infer permissions from the route. It reads the role owned by auth."
|
||||
>
|
||||
<Link className={styles.primaryLink} href="/sign-in">
|
||||
Sign in as admin
|
||||
</Link>
|
||||
</FeedbackPanel>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main {...rootAttrs} className={cl(styles.root, className)}>
|
||||
<header className={styles.header}>
|
||||
<div>
|
||||
<p className={styles.kicker}>Catalog / protected mutations</p>
|
||||
<h1>Versioned inventory</h1>
|
||||
</div>
|
||||
<p>
|
||||
Every update carries the last-read version. Use the conflict demo scenario to inspect
|
||||
the domain error without exposing transport payloads.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{catalog.error && (
|
||||
<FeedbackPanel variant="error" title="Catalog query failed" description={catalog.error.message}>
|
||||
<Button onClick={handleReload}>Retry inventory</Button>
|
||||
</FeedbackPanel>
|
||||
)}
|
||||
|
||||
<div className={styles.workspace}>
|
||||
<div className={styles.formColumn}>
|
||||
<ProductForm
|
||||
key={formKey}
|
||||
categories={categoriesState.categories}
|
||||
product={formProduct}
|
||||
isSubmitting={isSubmitting}
|
||||
error={mutationError?.message ?? null}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={handleCancelEdit}
|
||||
/>
|
||||
{mutationError?.code === 'conflict' && formProduct !== null && (
|
||||
<Button variant="secondary" onClick={handleLoadLatest}>
|
||||
Load latest version
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className={styles.inventory} aria-labelledby="inventory-title">
|
||||
<div className={styles.inventoryHeader}>
|
||||
<div>
|
||||
<span>Live snapshot</span>
|
||||
<h2 id="inventory-title">Inventory</h2>
|
||||
</div>
|
||||
<strong>{catalog.pagination?.total ?? 0} products</strong>
|
||||
</div>
|
||||
|
||||
{successMessage && <p className={styles.success} role="status">{successMessage}</p>}
|
||||
|
||||
{hasProducts && (
|
||||
<div className={styles.productList}>
|
||||
{catalog.products.map((product) => (
|
||||
<article key={product.id} className={styles.productRow}>
|
||||
<div>
|
||||
<span>v{product.version} / {product.stock} stock</span>
|
||||
<h3>{product.name}</h3>
|
||||
<small>{formatMoney(product.priceCents, product.currency)}</small>
|
||||
</div>
|
||||
<div className={styles.rowActions}>
|
||||
<Button variant="ghost" size="small" onClick={() => handleEdit(product)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="small"
|
||||
isLoading={deletingProductId === product.id}
|
||||
onClick={() => handleRemove(product)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{catalog.pagination && (catalog.pagination.totalPages > 1 || adminPage > 1) && (
|
||||
<div className={styles.pagination}>
|
||||
<Button variant="ghost" size="small" disabled={!canGoBack} onClick={handlePreviousPage}>
|
||||
Previous 100
|
||||
</Button>
|
||||
<span>{catalog.pagination.page} / {catalog.pagination.totalPages}</span>
|
||||
<Button variant="ghost" size="small" disabled={!canGoForward} onClick={handleNextPage}>
|
||||
Next 100
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
.root {
|
||||
display: grid;
|
||||
gap: 42px;
|
||||
padding: clamp(46px, 7vw, 100px) clamp(18px, 6vw, 90px) 110px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 0.42fr);
|
||||
gap: 42px;
|
||||
align-items: end;
|
||||
padding-bottom: 34px;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.kicker,
|
||||
.inventoryHeader span,
|
||||
.productRow span {
|
||||
color: var(--color-blue);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
max-width: 10ch;
|
||||
margin-top: 14px;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(3rem, 7.5vw, 7.2rem);
|
||||
letter-spacing: -0.055em;
|
||||
line-height: 0.8;
|
||||
}
|
||||
|
||||
.header > p {
|
||||
color: var(--color-ink-muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 0.78fr) minmax(0, 1.22fr);
|
||||
gap: clamp(24px, 5vw, 70px);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.formColumn {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.inventory {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.inventoryHeader {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.inventoryHeader h2 {
|
||||
margin-top: 7px;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(2rem, 4vw, 3.6rem);
|
||||
line-height: 0.9;
|
||||
}
|
||||
|
||||
.inventoryHeader strong {
|
||||
color: var(--color-ink-faint);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.productList {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.productRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 17px 0;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.productRow h3 {
|
||||
margin: 6px 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.45rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.productRow small {
|
||||
color: var(--color-ink-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.success {
|
||||
padding: 12px 14px;
|
||||
color: #315c00;
|
||||
border-left: 4px solid var(--color-acid-dark);
|
||||
background: color-mix(in srgb, var(--color-acid) 28%, transparent);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
padding-top: 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.outcome {
|
||||
min-height: calc(100vh - 82px);
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.primaryLink {
|
||||
padding: 14px 20px;
|
||||
color: var(--color-paper);
|
||||
border-radius: 999px;
|
||||
background: var(--color-ink);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.header,
|
||||
.workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.productRow {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
justify-content: start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
/**
|
||||
* Параметры product administration screen.
|
||||
*/
|
||||
export type ProductAdminScreenParams = object
|
||||
|
||||
/**
|
||||
* Атрибуты корневого main без внешнего содержимого.
|
||||
*/
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'main'>, 'children'>
|
||||
|
||||
/**
|
||||
* Props product administration screen.
|
||||
*/
|
||||
export type ProductAdminScreenProps = RootAttrs & ProductAdminScreenParams
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ProductForm } from './product-form'
|
||||
export type { ProductFormProps } from './types/product-form-props.type'
|
||||
@@ -0,0 +1,272 @@
|
||||
'use client'
|
||||
|
||||
import type { ChangeEvent, FormEvent } from 'react'
|
||||
import { useState } from 'react'
|
||||
import cl from 'clsx'
|
||||
|
||||
import type { CatalogCurrency, CreateProductInput, UpdateProductInput } from '@/domains/catalog'
|
||||
import { isOneOf } from '@/shared/lib/value-predicates'
|
||||
import { Button } from '@/ui/button'
|
||||
import { FormField } from '@/ui/form-field'
|
||||
|
||||
import type { ProductFormProps } from './types/product-form-props.type'
|
||||
import styles from './styles/product-form.module.css'
|
||||
|
||||
const CURRENCIES = ['USD', 'EUR'] as const
|
||||
|
||||
/**
|
||||
* Локальное редактируемое состояние admin-формы.
|
||||
*/
|
||||
type ProductFormValues = {
|
||||
/** Название продукта. */
|
||||
name: string
|
||||
/** Полное описание. */
|
||||
description: string
|
||||
/** Цена в cents как form string. */
|
||||
priceCents: string
|
||||
/** Валюта. */
|
||||
currency: CatalogCurrency
|
||||
/** Выбранная категория. */
|
||||
categoryId: string
|
||||
/** Stock как form string. */
|
||||
stock: string
|
||||
/** URL удалённого изображения. */
|
||||
imageUrl: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт initial form state из create mode или catalog snapshot.
|
||||
*/
|
||||
const createInitialValues = (props: ProductFormProps): ProductFormValues => {
|
||||
const { product, categories } = props
|
||||
|
||||
if (product !== null) {
|
||||
return {
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
priceCents: String(product.priceCents),
|
||||
currency: product.currency,
|
||||
categoryId: product.categoryId,
|
||||
stock: String(product.stock),
|
||||
imageUrl: product.imageUrl
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: '',
|
||||
description: '',
|
||||
priceCents: '9900',
|
||||
currency: 'USD',
|
||||
categoryId: categories[0]?.id ?? '',
|
||||
stock: '10',
|
||||
imageUrl: 'https://picsum.photos/seed/new-object/640/480'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Форма create/update с сохранением optimistic-lock версии продукта.
|
||||
*
|
||||
* Используется для:
|
||||
* - создания fixture-продукта
|
||||
* - редактирования последнего catalog snapshot
|
||||
*/
|
||||
export const ProductForm = (props: ProductFormProps) => {
|
||||
const {
|
||||
categories,
|
||||
product,
|
||||
isSubmitting,
|
||||
error,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
className,
|
||||
...rootAttrs
|
||||
} = props
|
||||
const [values, setValues] = useState<ProductFormValues>(() => createInitialValues(props))
|
||||
const isEditMode = product !== null
|
||||
const title = isEditMode ? `Edit ${product.name}` : 'Create object'
|
||||
const submitLabel = isEditMode ? 'Save version' : 'Create product'
|
||||
|
||||
/**
|
||||
* Обновляет текстовое поле формы по имени control.
|
||||
*/
|
||||
const handleTextChange = (
|
||||
event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
): void => {
|
||||
const field = event.target.name
|
||||
const value = event.target.value
|
||||
|
||||
if (field === 'name' || field === 'description' || field === 'imageUrl') {
|
||||
setValues((current) => ({ ...current, [field]: value }))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет числовое поле, сохраняя browser form representation.
|
||||
*/
|
||||
const handleNumberChange = (event: ChangeEvent<HTMLInputElement>): void => {
|
||||
const field = event.target.name
|
||||
const value = event.target.value
|
||||
|
||||
if (field === 'priceCents' || field === 'stock') {
|
||||
setValues((current) => ({ ...current, [field]: value }))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет выбранную category.
|
||||
*/
|
||||
const handleCategoryChange = (event: ChangeEvent<HTMLSelectElement>): void => {
|
||||
setValues((current) => ({ ...current, categoryId: event.target.value }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет только поддерживаемую backend currency.
|
||||
*/
|
||||
const handleCurrencyChange = (event: ChangeEvent<HTMLSelectElement>): void => {
|
||||
const value = event.target.value
|
||||
|
||||
if (isOneOf(value, CURRENCIES)) {
|
||||
setValues((current) => ({ ...current, currency: value }))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Передаёт нормализованный domain input screen-владельцу.
|
||||
*/
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault()
|
||||
|
||||
const baseInput: CreateProductInput = {
|
||||
name: values.name.trim(),
|
||||
description: values.description.trim(),
|
||||
priceCents: Number(values.priceCents),
|
||||
currency: values.currency,
|
||||
categoryId: values.categoryId,
|
||||
stock: Number(values.stock),
|
||||
imageUrl: values.imageUrl.trim()
|
||||
}
|
||||
|
||||
if (product !== null) {
|
||||
const updateInput: UpdateProductInput = {
|
||||
...baseInput,
|
||||
id: product.id,
|
||||
version: product.version
|
||||
}
|
||||
|
||||
await onSubmit(updateInput)
|
||||
return
|
||||
}
|
||||
|
||||
await onSubmit(baseInput)
|
||||
}
|
||||
|
||||
return (
|
||||
<form {...rootAttrs} className={cl(styles.root, className)} onSubmit={handleSubmit}>
|
||||
<div className={styles.heading}>
|
||||
<div>
|
||||
<span>{isEditMode ? `Version ${product.version}` : 'New catalog entry'}</span>
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
{isEditMode && (
|
||||
<Button variant="ghost" size="small" onClick={onCancel}>
|
||||
Cancel edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormField label="Name" htmlFor="product-name">
|
||||
<input
|
||||
id="product-name"
|
||||
name="name"
|
||||
value={values.name}
|
||||
minLength={2}
|
||||
maxLength={120}
|
||||
required
|
||||
onChange={handleTextChange}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Description" htmlFor="product-description">
|
||||
<textarea
|
||||
id="product-description"
|
||||
name="description"
|
||||
value={values.description}
|
||||
minLength={10}
|
||||
maxLength={1000}
|
||||
required
|
||||
onChange={handleTextChange}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className={styles.columns}>
|
||||
<FormField label="Price, cents" htmlFor="product-price">
|
||||
<input
|
||||
id="product-price"
|
||||
name="priceCents"
|
||||
type="number"
|
||||
min={0}
|
||||
value={values.priceCents}
|
||||
required
|
||||
onChange={handleNumberChange}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Currency" htmlFor="product-currency">
|
||||
<select id="product-currency" value={values.currency} onChange={handleCurrencyChange}>
|
||||
<option value="USD">USD</option>
|
||||
<option value="EUR">EUR</option>
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className={styles.columns}>
|
||||
<FormField label="Category" htmlFor="product-category">
|
||||
<select
|
||||
id="product-category"
|
||||
value={values.categoryId}
|
||||
required
|
||||
onChange={handleCategoryChange}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select category
|
||||
</option>
|
||||
{categories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Stock" htmlFor="product-stock">
|
||||
<input
|
||||
id="product-stock"
|
||||
name="stock"
|
||||
type="number"
|
||||
min={0}
|
||||
value={values.stock}
|
||||
required
|
||||
onChange={handleNumberChange}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField label="Image URL" htmlFor="product-image" hint="Allowed demo host: picsum.photos">
|
||||
<input
|
||||
id="product-image"
|
||||
name="imageUrl"
|
||||
type="url"
|
||||
value={values.imageUrl}
|
||||
required
|
||||
onChange={handleTextChange}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{error && <p className={styles.error} role="alert">{error}</p>}
|
||||
|
||||
<Button type="submit" isLoading={isSubmitting}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
.root {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: clamp(22px, 4vw, 38px);
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 24px;
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.heading span {
|
||||
color: var(--color-blue);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.64rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.heading h2 {
|
||||
margin-top: 8px;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.8rem, 4vw, 3rem);
|
||||
line-height: 0.92;
|
||||
}
|
||||
|
||||
.columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 12px 14px;
|
||||
color: var(--color-error);
|
||||
border-left: 4px solid var(--color-error);
|
||||
background: color-mix(in srgb, var(--color-error) 8%, transparent);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
import type {
|
||||
Category,
|
||||
CreateProductInput,
|
||||
Product,
|
||||
UpdateProductInput
|
||||
} from '@/domains/catalog'
|
||||
|
||||
/**
|
||||
* Собственные параметры admin product form.
|
||||
*/
|
||||
export type ProductFormParams = {
|
||||
/** Справочник допустимых категорий. */
|
||||
categories: Category[]
|
||||
/** Редактируемый продукт или null для create mode. */
|
||||
product: Product | null
|
||||
/** Выполняется ли catalog mutation. */
|
||||
isSubmitting: boolean
|
||||
/** Ошибка последней catalog mutation. */
|
||||
error: string | null
|
||||
/** Передаёт валидный create/update input screen-владельцу. */
|
||||
onSubmit: (input: CreateProductInput | UpdateProductInput) => Promise<void>
|
||||
/** Возвращает форму в create mode. */
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Атрибуты корневой form без собственного submit callback.
|
||||
*/
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'form'>, 'children' | 'onSubmit'>
|
||||
|
||||
/**
|
||||
* Props admin product form.
|
||||
*/
|
||||
export type ProductFormProps = RootAttrs & ProductFormParams
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ProductDetailScreen } from './product-detail.screen'
|
||||
export type { ProductDetailScreenProps } from './types/product-detail-screen-props.type'
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client'
|
||||
|
||||
import cl from 'clsx'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { PRODUCT_IMAGE_PLACEHOLDER, useProduct } from '@/domains/catalog'
|
||||
import { useCart } from '@/domains/cart'
|
||||
import { formatMoney } from '@/shared/lib/format-money'
|
||||
import { Button } from '@/ui/button'
|
||||
import { FeedbackPanel } from '@/ui/feedback-panel'
|
||||
|
||||
import type { ProductDetailScreenProps } from './types/product-detail-screen-props.type'
|
||||
import styles from './styles/product-detail.module.css'
|
||||
|
||||
/**
|
||||
* Детальная карточка выбранного catalog-продукта.
|
||||
*
|
||||
* Используется для:
|
||||
* - проверки dynamic route и detail query
|
||||
* - добавления продукта в application cart
|
||||
*/
|
||||
export const ProductDetailScreen = (props: ProductDetailScreenProps) => {
|
||||
const { productId, className, ...rootAttrs } = props
|
||||
const productState = useProduct(productId)
|
||||
const cart = useCart()
|
||||
|
||||
/**
|
||||
* Повторяет неуспешный product detail query.
|
||||
*/
|
||||
const handleReload = (): void => {
|
||||
void productState.reload()
|
||||
}
|
||||
|
||||
if (productState.isLoading) {
|
||||
return (
|
||||
<main {...rootAttrs} className={cl(styles.root, className)} aria-busy>
|
||||
<div
|
||||
className={styles.loading}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label="Loading product"
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (productState.error !== null || productState.product === null) {
|
||||
const message = productState.error?.message ?? 'The product returned no usable data.'
|
||||
|
||||
return (
|
||||
<main {...rootAttrs} className={cl(styles.root, styles.outcome, className)}>
|
||||
<FeedbackPanel variant="error" title="Product boundary stopped here" description={message}>
|
||||
<Button onClick={handleReload}>Retry detail</Button>
|
||||
<Link className={styles.textLink} href="/">
|
||||
Back to catalog
|
||||
</Link>
|
||||
</FeedbackPanel>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const product = productState.product
|
||||
const isSoldOut = product.stock === 0
|
||||
const stockLabel = isSoldOut ? 'Unavailable' : `${product.stock} units in deterministic stock`
|
||||
|
||||
/**
|
||||
* Добавляет текущий detail-продукт в cart domain.
|
||||
*/
|
||||
const handleAdd = (): void => {
|
||||
void cart.addProduct(product)
|
||||
}
|
||||
|
||||
return (
|
||||
<main {...rootAttrs} className={cl(styles.root, className)}>
|
||||
<Link className={styles.back} href="/">
|
||||
Back / catalog
|
||||
</Link>
|
||||
|
||||
<article className={styles.product}>
|
||||
<div className={styles.visual}>
|
||||
<Image
|
||||
className={styles.image}
|
||||
src={product.imageUrl}
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
sizes="(max-width: 820px) 100vw, 55vw"
|
||||
onError={(event) => {
|
||||
event.currentTarget.srcset = ''
|
||||
event.currentTarget.src = PRODUCT_IMAGE_PLACEHOLDER
|
||||
}}
|
||||
/>
|
||||
<span className={styles.version}>v{product.version}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
<p className={styles.eyebrow}>Object / {product.categoryId.replace('category-', '')}</p>
|
||||
<h1 className={styles.title}>{product.name}</h1>
|
||||
<p className={styles.description}>{product.description}</p>
|
||||
|
||||
<dl className={styles.facts}>
|
||||
<div>
|
||||
<dt>Rating</dt>
|
||||
<dd>{product.rating.toFixed(1)} / 5</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Stock</dt>
|
||||
<dd>{stockLabel}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Lock</dt>
|
||||
<dd>Version {product.version}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className={styles.purchase}>
|
||||
<strong>{formatMoney(product.priceCents, product.currency)}</strong>
|
||||
<Button disabled={isSoldOut} onClick={handleAdd}>
|
||||
Add to cart
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
.root {
|
||||
min-height: calc(100vh - 82px);
|
||||
padding: clamp(28px, 5vw, 72px);
|
||||
}
|
||||
|
||||
.back,
|
||||
.textLink {
|
||||
display: inline-flex;
|
||||
color: var(--color-blue);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.product {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(340px, 0.85fr);
|
||||
gap: clamp(32px, 7vw, 100px);
|
||||
margin-top: 34px;
|
||||
}
|
||||
|
||||
.visual {
|
||||
position: relative;
|
||||
min-height: min(66vw, 720px);
|
||||
overflow: hidden;
|
||||
border-radius: 28px;
|
||||
background: var(--color-surface-strong);
|
||||
}
|
||||
|
||||
.image {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.version {
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 54px;
|
||||
height: 54px;
|
||||
color: var(--color-paper);
|
||||
border-radius: 50%;
|
||||
background: var(--color-blue);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
align-self: center;
|
||||
display: grid;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--color-blue);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.title {
|
||||
max-width: 9ch;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(3.2rem, 7vw, 7rem);
|
||||
letter-spacing: -0.055em;
|
||||
line-height: 0.82;
|
||||
}
|
||||
|
||||
.description {
|
||||
max-width: 48ch;
|
||||
color: var(--color-ink-muted);
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.facts {
|
||||
display: grid;
|
||||
border-block: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.facts div {
|
||||
display: grid;
|
||||
grid-template-columns: 90px 1fr;
|
||||
gap: 16px;
|
||||
padding: 13px 0;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
}
|
||||
|
||||
.facts div:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.facts dt {
|
||||
color: var(--color-ink-faint);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.facts dd {
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.purchase {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.purchase strong {
|
||||
font-family: var(--font-mono);
|
||||
font-size: clamp(1.35rem, 3vw, 2rem);
|
||||
}
|
||||
|
||||
.outcome {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.loading {
|
||||
min-height: 70vh;
|
||||
border-radius: 28px;
|
||||
background:
|
||||
linear-gradient(105deg, transparent 30%, rgb(255 255 255 / 55%) 45%, transparent 60%)
|
||||
0 0 / 220% 100%,
|
||||
var(--color-surface-strong);
|
||||
animation: sweep 1.4s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes sweep {
|
||||
to {
|
||||
background-position: -220% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.product {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.visual {
|
||||
min-height: 70vw;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
/**
|
||||
* Параметры product detail screen.
|
||||
*/
|
||||
export type ProductDetailScreenParams = {
|
||||
/** Product id, адаптированный route entry. */
|
||||
productId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Атрибуты корневого main без внешнего содержимого.
|
||||
*/
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'main'>, 'children'>
|
||||
|
||||
/**
|
||||
* Props product detail screen.
|
||||
*/
|
||||
export type ProductDetailScreenProps = RootAttrs & ProductDetailScreenParams
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SignInScreen } from './sign-in.screen'
|
||||
export type { SignInScreenProps } from './types/sign-in-screen-props.type'
|
||||
@@ -0,0 +1,205 @@
|
||||
'use client'
|
||||
|
||||
import type { ChangeEvent, FormEvent } from 'react'
|
||||
import { useState } from 'react'
|
||||
import cl from 'clsx'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import { AUTH_DEMO_ACCOUNTS, useAuth } from '@/domains/auth'
|
||||
import type { AuthError, DemoAccount, SignInCredentials } from '@/domains/auth'
|
||||
import { Button } from '@/ui/button'
|
||||
import { FeedbackPanel } from '@/ui/feedback-panel'
|
||||
import { FormField } from '@/ui/form-field'
|
||||
|
||||
import type { SignInScreenProps } from './types/sign-in-screen-props.type'
|
||||
import styles from './styles/sign-in.module.css'
|
||||
|
||||
/**
|
||||
* Auth route с двумя воспроизводимыми fixture-ролями.
|
||||
*
|
||||
* Используется для:
|
||||
* - запуска JWT login/refresh lifecycle
|
||||
* - переключения customer и admin RBAC outcomes
|
||||
*/
|
||||
export const SignInScreen = (props: SignInScreenProps) => {
|
||||
const { className, ...rootAttrs } = props
|
||||
const auth = useAuth()
|
||||
const router = useRouter()
|
||||
const [credentials, setCredentials] = useState<SignInCredentials>({
|
||||
email: AUTH_DEMO_ACCOUNTS[0].email,
|
||||
password: AUTH_DEMO_ACCOUNTS[0].password
|
||||
})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<AuthError | null>(null)
|
||||
|
||||
/**
|
||||
* Подставляет выбранную deterministic account в форму.
|
||||
*/
|
||||
const handleAccountSelect = (account: DemoAccount): void => {
|
||||
setCredentials({ email: account.email, password: account.password })
|
||||
setError(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет email login-формы.
|
||||
*/
|
||||
const handleEmailChange = (event: ChangeEvent<HTMLInputElement>): void => {
|
||||
setCredentials((current) => ({ ...current, email: event.target.value }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет password login-формы.
|
||||
*/
|
||||
const handlePasswordChange = (event: ChangeEvent<HTMLInputElement>): void => {
|
||||
setCredentials((current) => ({ ...current, password: event.target.value }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняет auth-domain login и переводит пользователя в каталог.
|
||||
*/
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
const result = await auth.signIn(credentials)
|
||||
|
||||
setIsSubmitting(false)
|
||||
|
||||
if (result.isSuccess) {
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
|
||||
setError(result.error)
|
||||
}
|
||||
|
||||
/**
|
||||
* Повторяет recoverable проверку сохранённой session.
|
||||
*/
|
||||
const handleSessionRetry = (): void => {
|
||||
void auth.refreshCurrentUser()
|
||||
}
|
||||
|
||||
/**
|
||||
* Явно завершает сохранённую session после recoverable failure.
|
||||
*/
|
||||
const handleSignOut = (): void => {
|
||||
void auth.signOut()
|
||||
}
|
||||
|
||||
if (auth.status === 'checking') {
|
||||
return (
|
||||
<main {...rootAttrs} className={cl(styles.root, styles.outcome, className)}>
|
||||
<FeedbackPanel
|
||||
title="Restoring session"
|
||||
description="The auth owner is validating persisted JWT data before exposing a route outcome."
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (auth.status === 'authenticated' && auth.user !== null) {
|
||||
return (
|
||||
<main {...rootAttrs} className={cl(styles.root, styles.outcome, className)}>
|
||||
<FeedbackPanel
|
||||
variant="success"
|
||||
title={`Signed in as ${auth.user.name}`}
|
||||
description={`The active ${auth.user.role} session is available to every composition through the auth public API.`}
|
||||
>
|
||||
<div className={styles.outcomeActions}>
|
||||
<Link className={styles.primaryLink} href="/">
|
||||
Browse catalog
|
||||
</Link>
|
||||
<Link className={styles.textLink} href="/orders">
|
||||
Open orders
|
||||
</Link>
|
||||
</div>
|
||||
</FeedbackPanel>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (auth.status === 'unavailable') {
|
||||
return (
|
||||
<main {...rootAttrs} className={cl(styles.root, styles.outcome, className)}>
|
||||
<FeedbackPanel
|
||||
variant="error"
|
||||
title="Session check is temporarily unavailable"
|
||||
description={auth.sessionError?.message ?? 'The saved session was kept for a safe retry.'}
|
||||
>
|
||||
<div className={styles.outcomeActions}>
|
||||
<Button onClick={handleSessionRetry}>Retry session</Button>
|
||||
<Button variant="ghost" onClick={handleSignOut}>End session</Button>
|
||||
</div>
|
||||
</FeedbackPanel>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main {...rootAttrs} className={cl(styles.root, className)}>
|
||||
<section className={styles.intro}>
|
||||
<p className={styles.kicker}>Auth / JWT transport</p>
|
||||
<h1 className={styles.title}>Choose a boundary to cross.</h1>
|
||||
<p className={styles.description}>
|
||||
Both accounts use the same password, but their domain permissions produce different
|
||||
navigation, mutations and order visibility.
|
||||
</p>
|
||||
|
||||
<div className={styles.accounts}>
|
||||
{AUTH_DEMO_ACCOUNTS.map((account) => (
|
||||
<button
|
||||
key={account.email}
|
||||
type="button"
|
||||
className={styles.accountCard}
|
||||
onClick={() => handleAccountSelect(account)}
|
||||
>
|
||||
<span>{account.role}</span>
|
||||
<strong>{account.email}</strong>
|
||||
<small>{account.description}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.formPanel} aria-labelledby="sign-in-title">
|
||||
<span className={styles.sequence}>02 / SESSION</span>
|
||||
<h2 id="sign-in-title">Enter the fixture</h2>
|
||||
<form className={styles.form} onSubmit={handleSubmit}>
|
||||
<FormField label="Email" htmlFor="email" hint="Try either demo account">
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
value={credentials.email}
|
||||
required
|
||||
onChange={handleEmailChange}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Password" htmlFor="password" hint="Fixture default: demo1234">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
minLength={8}
|
||||
value={credentials.password}
|
||||
required
|
||||
onChange={handlePasswordChange}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{error && <p className={styles.error} role="alert">{error.message}</p>}
|
||||
|
||||
<Button type="submit" isLoading={isSubmitting}>
|
||||
Start session
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
.root {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(360px, 0.72fr);
|
||||
min-height: calc(100vh - 82px);
|
||||
}
|
||||
|
||||
.intro,
|
||||
.formPanel {
|
||||
padding: clamp(44px, 8vw, 110px);
|
||||
}
|
||||
|
||||
.intro {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 30px;
|
||||
color: var(--color-paper);
|
||||
background: var(--color-ink);
|
||||
}
|
||||
|
||||
.kicker,
|
||||
.sequence {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
color: var(--color-acid);
|
||||
}
|
||||
|
||||
.title {
|
||||
max-width: 9ch;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(3.2rem, 8vw, 7.5rem);
|
||||
letter-spacing: -0.055em;
|
||||
line-height: 0.82;
|
||||
}
|
||||
|
||||
.description {
|
||||
max-width: 48ch;
|
||||
color: rgb(247 242 232 / 68%);
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.accounts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.accountCard {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 18px;
|
||||
color: inherit;
|
||||
border: 1px solid rgb(255 255 255 / 18%);
|
||||
border-radius: 16px;
|
||||
background: rgb(255 255 255 / 5%);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
background 160ms ease;
|
||||
}
|
||||
|
||||
.accountCard:hover,
|
||||
.accountCard:focus-visible {
|
||||
border-color: var(--color-acid);
|
||||
outline: none;
|
||||
background: rgb(200 255 77 / 8%);
|
||||
}
|
||||
|
||||
.accountCard span,
|
||||
.accountCard small {
|
||||
color: rgb(247 242 232 / 54%);
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.accountCard strong {
|
||||
overflow: hidden;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.formPanel {
|
||||
position: relative;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 26px;
|
||||
background:
|
||||
radial-gradient(circle at 90% 10%, color-mix(in srgb, var(--color-orange) 22%, transparent), transparent 32%),
|
||||
var(--color-paper);
|
||||
}
|
||||
|
||||
.sequence {
|
||||
color: var(--color-blue);
|
||||
}
|
||||
|
||||
.formPanel h2 {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(2rem, 5vw, 4rem);
|
||||
line-height: 0.9;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
max-width: 440px;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 12px 14px;
|
||||
color: var(--color-error);
|
||||
border-left: 4px solid var(--color-error);
|
||||
background: color-mix(in srgb, var(--color-error) 8%, transparent);
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.outcome {
|
||||
place-items: center;
|
||||
padding: clamp(30px, 8vw, 120px);
|
||||
}
|
||||
|
||||
.outcomeActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.primaryLink {
|
||||
padding: 14px 20px;
|
||||
color: var(--color-paper);
|
||||
border-radius: 999px;
|
||||
background: var(--color-ink);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.textLink {
|
||||
color: var(--color-blue);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.root {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.accounts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
/**
|
||||
* Параметры sign-in screen.
|
||||
*/
|
||||
export type SignInScreenParams = object
|
||||
|
||||
/**
|
||||
* Атрибуты корневого main без внешнего содержимого.
|
||||
*/
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'main'>, 'children'>
|
||||
|
||||
/**
|
||||
* Props sign-in screen.
|
||||
*/
|
||||
export type SignInScreenProps = RootAttrs & SignInScreenParams
|
||||
@@ -0,0 +1,140 @@
|
||||
'use client'
|
||||
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { useEffect, useEffectEvent, useRef } from 'react'
|
||||
import cl from 'clsx'
|
||||
|
||||
import { useAuth } from '@/domains/auth'
|
||||
import { useCart } from '@/domains/cart'
|
||||
import { useDemoControl } from '@/domains/demo-control'
|
||||
import { Button } from '@/ui/button'
|
||||
|
||||
import type { DemoToolbarWidgetProps } from './types/demo-toolbar-widget-props.type'
|
||||
import styles from './styles/demo-toolbar.module.css'
|
||||
|
||||
/**
|
||||
* Управляет детерминированными сбоями и seed fixture-бэкенда.
|
||||
*
|
||||
* Используется для:
|
||||
* - проверки loading, empty, error и auth outcomes
|
||||
* - сброса данных между архитектурными демонстрациями
|
||||
*/
|
||||
export const DemoToolbarWidget = (props: DemoToolbarWidgetProps) => {
|
||||
const { className, ...rootAttrs } = props
|
||||
const demo = useDemoControl()
|
||||
const auth = useAuth()
|
||||
const cart = useCart()
|
||||
const selectedOption = demo.scenarios.find((option) => option.value === demo.scenario)
|
||||
const healthLabel = demo.health === 'online' ? `API ${demo.apiVersion ?? ''}`.trim() : demo.health
|
||||
const canChangeRole = auth.user !== null
|
||||
const nextRole = auth.user?.role === 'admin' ? 'customer' : 'admin'
|
||||
const roleActionLabel = `Become ${nextRole}`
|
||||
const previousFixtureChangeRef = useRef(demo.fixtureChange)
|
||||
const synchronizeFixtureOwners = useEffectEvent((change: string) => {
|
||||
if (change.startsWith('data:')) {
|
||||
void cart.clearCart()
|
||||
}
|
||||
|
||||
if (auth.sessionKey !== null) {
|
||||
void auth.refreshCurrentUser()
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (previousFixtureChangeRef.current !== demo.fixtureChange) {
|
||||
previousFixtureChangeRef.current = demo.fixtureChange
|
||||
synchronizeFixtureOwners(demo.fixtureChange)
|
||||
}
|
||||
}, [demo.fixtureChange])
|
||||
|
||||
/**
|
||||
* Применяет выбранный request-local сценарий.
|
||||
*/
|
||||
const handleScenarioChange = (event: ChangeEvent<HTMLSelectElement>): void => {
|
||||
const selectedScenario = demo.scenarios.find((option) => option.value === event.target.value)
|
||||
|
||||
if (selectedScenario !== undefined) {
|
||||
void demo.selectScenario(selectedScenario.value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Восстанавливает исходный малый seed.
|
||||
*/
|
||||
const handleReset = async (): Promise<void> => {
|
||||
await demo.resetData()
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает большой deterministic dataset.
|
||||
*/
|
||||
const handleLargeSeed = async (): Promise<void> => {
|
||||
await demo.seedData('large')
|
||||
}
|
||||
|
||||
/**
|
||||
* Переключает роль текущего fixture-пользователя и обновляет профиль.
|
||||
*/
|
||||
const handleRoleChange = async (): Promise<void> => {
|
||||
if (auth.user === null) {
|
||||
return
|
||||
}
|
||||
|
||||
await demo.changeRole(auth.user.id, nextRole)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside {...rootAttrs} className={cl(styles.root, className)}>
|
||||
<details className={styles.panel}>
|
||||
<summary className={styles.summary}>
|
||||
<span className={styles.pulse} data-health={demo.health} />
|
||||
<span>Demo controls</span>
|
||||
<span className={styles.current}>{demo.scenario}</span>
|
||||
</summary>
|
||||
|
||||
<div className={styles.body}>
|
||||
<div className={styles.intro}>
|
||||
<p className={styles.eyebrow}>Fixture runtime</p>
|
||||
<p className={styles.description}>
|
||||
{selectedOption?.description ?? 'Choose a deterministic API behavior.'}
|
||||
</p>
|
||||
<span className={styles.health}>{healthLabel}</span>
|
||||
</div>
|
||||
|
||||
<label className={styles.control}>
|
||||
<span>Request scenario</span>
|
||||
<select value={demo.scenario} onChange={handleScenarioChange}>
|
||||
{demo.scenarios.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Button variant="ghost" size="small" isLoading={demo.isMutating} onClick={handleReset}>
|
||||
Reset data
|
||||
</Button>
|
||||
<Button variant="ghost" size="small" isLoading={demo.isMutating} onClick={handleLargeSeed}>
|
||||
Seed 250
|
||||
</Button>
|
||||
{canChangeRole && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
isLoading={demo.isMutating}
|
||||
onClick={handleRoleChange}
|
||||
>
|
||||
{roleActionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{demo.message && <p className={styles.message} role="status">{demo.message}</p>}
|
||||
{demo.error && <p className={styles.error} role="alert">{demo.error.message}</p>}
|
||||
</div>
|
||||
</details>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { DemoToolbarWidget } from './demo-toolbar.widget'
|
||||
export type { DemoToolbarWidgetProps } from './types/demo-toolbar-widget-props.type'
|
||||
@@ -0,0 +1,159 @@
|
||||
.root {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
z-index: 40;
|
||||
width: min(390px, calc(100vw - 36px));
|
||||
}
|
||||
|
||||
.panel {
|
||||
overflow: hidden;
|
||||
color: #f7f2e8;
|
||||
border: 1px solid rgb(255 255 255 / 18%);
|
||||
border-radius: 18px;
|
||||
background: rgb(20 20 19 / 94%);
|
||||
box-shadow: 0 24px 70px rgb(0 0 0 / 28%);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 48px;
|
||||
padding: 0 16px;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pulse {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #ffac33;
|
||||
box-shadow: 0 0 0 5px rgb(255 172 51 / 12%);
|
||||
}
|
||||
|
||||
.pulse[data-health='online'] {
|
||||
background: #c8ff4d;
|
||||
box-shadow: 0 0 0 5px rgb(200 255 77 / 12%);
|
||||
}
|
||||
|
||||
.pulse[data-health='offline'] {
|
||||
background: #ff6b5f;
|
||||
box-shadow: 0 0 0 5px rgb(255 107 95 / 14%);
|
||||
}
|
||||
|
||||
.current {
|
||||
max-width: 130px;
|
||||
overflow: hidden;
|
||||
color: #c8ff4d;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 4px 16px 16px;
|
||||
border-top: 1px solid rgb(255 255 255 / 12%);
|
||||
}
|
||||
|
||||
.intro {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 5px 12px;
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.control span {
|
||||
color: rgb(247 242 232 / 56%);
|
||||
font-size: 0.64rem;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.11em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.description {
|
||||
grid-column: 1 / -1;
|
||||
color: rgb(247 242 232 / 78%);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.health {
|
||||
color: #c8ff4d;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.control {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.control select {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
padding: 0 12px;
|
||||
color: #f7f2e8;
|
||||
border: 1px solid rgb(255 255 255 / 20%);
|
||||
border-radius: 10px;
|
||||
background: #292927;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
color: #f7f2e8;
|
||||
border-color: rgb(255 255 255 / 20%);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.message,
|
||||
.error {
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.message {
|
||||
color: #dfffa0;
|
||||
background: rgb(200 255 77 / 10%);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ffd1cc;
|
||||
background: rgb(255 107 95 / 13%);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.root {
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
width: calc(100vw - 20px);
|
||||
}
|
||||
|
||||
.body {
|
||||
max-height: calc(100dvh - 78px);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
/**
|
||||
* Параметры виджета архитектурных demo-сценариев.
|
||||
*/
|
||||
export type DemoToolbarWidgetParams = object
|
||||
|
||||
/**
|
||||
* Атрибуты корневого aside.
|
||||
*/
|
||||
type RootAttrs = ComponentPropsWithoutRef<'aside'>
|
||||
|
||||
/**
|
||||
* Props demo toolbar widget.
|
||||
*/
|
||||
export type DemoToolbarWidgetProps = RootAttrs & DemoToolbarWidgetParams
|
||||
19
examples/demo-frontend/src/domains/auth/auth.constants.ts
Normal file
19
examples/demo-frontend/src/domains/auth/auth.constants.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { DemoAccount } from './types/demo-account.type'
|
||||
|
||||
/**
|
||||
* Учётные записи Simple API для воспроизводимых auth/RBAC-сценариев.
|
||||
*/
|
||||
export const AUTH_DEMO_ACCOUNTS: readonly DemoAccount[] = [
|
||||
{
|
||||
email: 'admin@demo.local',
|
||||
password: 'demo1234',
|
||||
role: 'admin',
|
||||
description: 'Catalog CRUD and every order'
|
||||
},
|
||||
{
|
||||
email: 'customer@demo.local',
|
||||
password: 'demo1234',
|
||||
role: 'customer',
|
||||
description: 'Checkout and own orders'
|
||||
}
|
||||
]
|
||||
8
examples/demo-frontend/src/domains/auth/auth.context.ts
Normal file
8
examples/demo-frontend/src/domains/auth/auth.context.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
import type { AuthContextValue } from './types/auth-context.type'
|
||||
|
||||
/**
|
||||
* Внутренний React context application-scoped auth API.
|
||||
*/
|
||||
export const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
393
examples/demo-frontend/src/domains/auth/auth.provider.tsx
Normal file
393
examples/demo-frontend/src/domains/auth/auth.provider.tsx
Normal file
@@ -0,0 +1,393 @@
|
||||
'use client'
|
||||
|
||||
import { startTransition, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import {
|
||||
clearSimpleAuthSessionScope,
|
||||
discardSimpleAuthSession,
|
||||
readSimpleAuthSession,
|
||||
replaceSimpleAuthSession,
|
||||
subscribeSimpleAuthSession
|
||||
} from '@/infra/simple-auth-session'
|
||||
import type { SimpleAuthSession } from '@/infra/simple-auth-session'
|
||||
import { simpleRestApi } from '@/infra/simple-rest-api'
|
||||
import type { Result } from '@/shared/types/result.type'
|
||||
|
||||
import { AuthContext } from './auth.context'
|
||||
import { mapAuthError } from './errors/auth-error.mapper'
|
||||
import { mapAuthUser } from './mappers/auth-user.mapper'
|
||||
import type { AuthError } from './types/auth-error.type'
|
||||
import type { AuthContextValue, SignInCredentials } from './types/auth-context.type'
|
||||
import type { AuthProviderProps } from './types/auth-provider-props.type'
|
||||
import type { AuthUser } from './types/auth-user.type'
|
||||
|
||||
const SESSION_STORAGE_ERROR: AuthError = {
|
||||
code: 'service-unavailable',
|
||||
message: 'Browser storage is unavailable. The session was not changed.'
|
||||
}
|
||||
const SESSION_CHANGED_ERROR: AuthError = {
|
||||
code: 'session-expired',
|
||||
message: 'The active session changed before the request completed.'
|
||||
}
|
||||
|
||||
/**
|
||||
* Владеет пользовательской сессией на всём времени жизни приложения.
|
||||
*
|
||||
* Используется для:
|
||||
* - восстановления профиля из CAS-protected JWT session
|
||||
* - предоставления login/logout API всем composition-модулям
|
||||
*/
|
||||
export const AuthProvider = (props: AuthProviderProps) => {
|
||||
const { children } = props
|
||||
const [status, setStatus] = useState<AuthContextValue['status']>('checking')
|
||||
const [user, setUser] = useState<AuthUser | null>(null)
|
||||
const [sessionKey, setSessionKey] = useState<string | null>(null)
|
||||
const [sessionError, setSessionError] = useState<AuthError | null>(null)
|
||||
const sessionKeyRef = useRef<string | null>(null)
|
||||
const profileRequestIdRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true
|
||||
|
||||
/**
|
||||
* Загружает authority-профиль только для всё ещё актуальной session scope.
|
||||
*/
|
||||
const loadSessionUser = async (session: SimpleAuthSession): Promise<void> => {
|
||||
const requestId = ++profileRequestIdRef.current
|
||||
|
||||
try {
|
||||
const response = await simpleRestApi.users.simpleUsersMe()
|
||||
const currentResult = readSimpleAuthSession()
|
||||
|
||||
if (
|
||||
!isActive ||
|
||||
requestId !== profileRequestIdRef.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (currentResult.status === 'unavailable') {
|
||||
setSessionError(SESSION_STORAGE_ERROR)
|
||||
setStatus('unavailable')
|
||||
return
|
||||
}
|
||||
|
||||
if (currentResult.session?.sessionId !== session.sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
sessionKeyRef.current = session.sessionId
|
||||
setSessionKey(session.sessionId)
|
||||
setUser(mapAuthUser(response.data))
|
||||
setSessionError(null)
|
||||
setStatus('authenticated')
|
||||
})
|
||||
} catch (error) {
|
||||
const currentResult = readSimpleAuthSession()
|
||||
|
||||
if (!isActive || requestId !== profileRequestIdRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (currentResult.status === 'unavailable') {
|
||||
setSessionError(SESSION_STORAGE_ERROR)
|
||||
setStatus('unavailable')
|
||||
return
|
||||
}
|
||||
|
||||
const currentSession = currentResult.session
|
||||
|
||||
if (currentSession === null) {
|
||||
sessionKeyRef.current = null
|
||||
setSessionKey(null)
|
||||
setUser(null)
|
||||
setSessionError(null)
|
||||
setStatus('guest')
|
||||
return
|
||||
}
|
||||
|
||||
if (currentSession.sessionId === session.sessionId) {
|
||||
startTransition(() => {
|
||||
sessionKeyRef.current = session.sessionId
|
||||
setSessionKey(session.sessionId)
|
||||
setSessionError(mapAuthError(error))
|
||||
setStatus('unavailable')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribe = subscribeSimpleAuthSession((result) => {
|
||||
if (!isActive) {
|
||||
return
|
||||
}
|
||||
|
||||
if (result.status === 'unavailable') {
|
||||
profileRequestIdRef.current += 1
|
||||
setSessionError(SESSION_STORAGE_ERROR)
|
||||
setStatus('unavailable')
|
||||
return
|
||||
}
|
||||
|
||||
const session = result.session
|
||||
|
||||
if (session === null) {
|
||||
profileRequestIdRef.current += 1
|
||||
sessionKeyRef.current = null
|
||||
setSessionKey(null)
|
||||
setUser(null)
|
||||
setSessionError(null)
|
||||
setStatus('guest')
|
||||
return
|
||||
}
|
||||
|
||||
if (session.sessionId !== sessionKeyRef.current) {
|
||||
profileRequestIdRef.current += 1
|
||||
sessionKeyRef.current = session.sessionId
|
||||
setSessionKey(session.sessionId)
|
||||
setUser(null)
|
||||
setSessionError(null)
|
||||
setStatus('checking')
|
||||
void loadSessionUser(session)
|
||||
}
|
||||
})
|
||||
const initialResult = readSimpleAuthSession()
|
||||
|
||||
if (initialResult.status === 'unavailable') {
|
||||
queueMicrotask(() => {
|
||||
if (isActive) {
|
||||
setSessionError(SESSION_STORAGE_ERROR)
|
||||
setStatus('unavailable')
|
||||
}
|
||||
})
|
||||
} else if (initialResult.session === null) {
|
||||
queueMicrotask(() => {
|
||||
if (isActive) {
|
||||
setStatus('guest')
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const session = initialResult.session
|
||||
|
||||
sessionKeyRef.current = session.sessionId
|
||||
void loadSessionUser(session)
|
||||
}
|
||||
|
||||
return () => {
|
||||
isActive = false
|
||||
unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Выполняет login и создаёт новую logical session scope.
|
||||
*/
|
||||
const signIn = async (
|
||||
credentials: SignInCredentials
|
||||
): Promise<Result<AuthUser, AuthError>> => {
|
||||
try {
|
||||
const response = await simpleRestApi.auth.simpleAuthLogin(credentials)
|
||||
const nextUser = mapAuthUser(response.data.user)
|
||||
const session = await replaceSimpleAuthSession(response.data.tokens)
|
||||
|
||||
if (session === null) {
|
||||
return { isSuccess: false, error: SESSION_STORAGE_ERROR }
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
profileRequestIdRef.current += 1
|
||||
sessionKeyRef.current = session.sessionId
|
||||
setSessionKey(session.sessionId)
|
||||
setUser(nextUser)
|
||||
setSessionError(null)
|
||||
setStatus('authenticated')
|
||||
})
|
||||
|
||||
return { isSuccess: true, data: nextUser }
|
||||
} catch (error) {
|
||||
return { isSuccess: false, error: mapAuthError(error) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Локально завершает session до best-effort backend logout.
|
||||
*/
|
||||
const signOut = async (): Promise<void> => {
|
||||
const currentResult = readSimpleAuthSession()
|
||||
|
||||
if (currentResult.status === 'unavailable') {
|
||||
if (currentResult.observedValue === null) {
|
||||
setSessionError(SESSION_STORAGE_ERROR)
|
||||
setStatus('unavailable')
|
||||
return
|
||||
}
|
||||
|
||||
const isDiscarded = await discardSimpleAuthSession(currentResult.observedValue)
|
||||
|
||||
if (!isDiscarded) {
|
||||
const latestResult = readSimpleAuthSession()
|
||||
|
||||
if (latestResult.status === 'unavailable') {
|
||||
setSessionError(SESSION_STORAGE_ERROR)
|
||||
setStatus('unavailable')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
profileRequestIdRef.current += 1
|
||||
sessionKeyRef.current = null
|
||||
setSessionKey(null)
|
||||
setUser(null)
|
||||
setSessionError(null)
|
||||
setStatus('guest')
|
||||
return
|
||||
}
|
||||
|
||||
const session = currentResult.session
|
||||
|
||||
if (session === null) {
|
||||
profileRequestIdRef.current += 1
|
||||
sessionKeyRef.current = null
|
||||
setSessionKey(null)
|
||||
setUser(null)
|
||||
setSessionError(null)
|
||||
setStatus('guest')
|
||||
return
|
||||
}
|
||||
|
||||
const isCleared = await clearSimpleAuthSessionScope(session.sessionId)
|
||||
|
||||
if (!isCleared) {
|
||||
setSessionError(SESSION_STORAGE_ERROR)
|
||||
setStatus('unavailable')
|
||||
return
|
||||
}
|
||||
|
||||
profileRequestIdRef.current += 1
|
||||
sessionKeyRef.current = null
|
||||
setSessionKey(null)
|
||||
setUser(null)
|
||||
setSessionError(null)
|
||||
setStatus('guest')
|
||||
|
||||
try {
|
||||
await simpleRestApi.auth.simpleAuthLogout({
|
||||
refreshToken: session.tokens.refreshToken
|
||||
})
|
||||
} catch {
|
||||
// Remote revocation is best-effort after the local scope is already closed.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет authority-профиль или публикует recoverable session failure.
|
||||
*/
|
||||
const refreshCurrentUser = async (): Promise<Result<AuthUser, AuthError>> => {
|
||||
const expectedResult = readSimpleAuthSession()
|
||||
|
||||
if (expectedResult.status === 'unavailable') {
|
||||
setSessionError(SESSION_STORAGE_ERROR)
|
||||
setStatus('unavailable')
|
||||
return { isSuccess: false, error: SESSION_STORAGE_ERROR }
|
||||
}
|
||||
|
||||
const expectedSession = expectedResult.session
|
||||
|
||||
if (expectedSession === null) {
|
||||
profileRequestIdRef.current += 1
|
||||
sessionKeyRef.current = null
|
||||
setSessionKey(null)
|
||||
setUser(null)
|
||||
setSessionError(null)
|
||||
setStatus('guest')
|
||||
return { isSuccess: false, error: SESSION_CHANGED_ERROR }
|
||||
}
|
||||
|
||||
const requestId = ++profileRequestIdRef.current
|
||||
|
||||
try {
|
||||
const response = await simpleRestApi.users.simpleUsersMe()
|
||||
const nextUser = mapAuthUser(response.data)
|
||||
const currentResult = readSimpleAuthSession()
|
||||
|
||||
if (currentResult.status === 'unavailable') {
|
||||
setSessionError(SESSION_STORAGE_ERROR)
|
||||
setStatus('unavailable')
|
||||
return { isSuccess: false, error: SESSION_STORAGE_ERROR }
|
||||
}
|
||||
|
||||
const currentSession = currentResult.session
|
||||
|
||||
if (
|
||||
requestId !== profileRequestIdRef.current ||
|
||||
currentSession?.sessionId !== expectedSession.sessionId
|
||||
) {
|
||||
return { isSuccess: false, error: SESSION_CHANGED_ERROR }
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
sessionKeyRef.current = expectedSession.sessionId
|
||||
setSessionKey(expectedSession.sessionId)
|
||||
setUser(nextUser)
|
||||
setSessionError(null)
|
||||
setStatus('authenticated')
|
||||
})
|
||||
|
||||
return { isSuccess: true, data: nextUser }
|
||||
} catch (error) {
|
||||
const authError = mapAuthError(error)
|
||||
const currentResult = readSimpleAuthSession()
|
||||
|
||||
if (currentResult.status === 'unavailable') {
|
||||
setSessionError(SESSION_STORAGE_ERROR)
|
||||
setStatus('unavailable')
|
||||
return { isSuccess: false, error: SESSION_STORAGE_ERROR }
|
||||
}
|
||||
|
||||
const currentSession = currentResult.session
|
||||
|
||||
if (
|
||||
requestId !== profileRequestIdRef.current ||
|
||||
currentSession?.sessionId !== expectedSession.sessionId
|
||||
) {
|
||||
return { isSuccess: false, error: SESSION_CHANGED_ERROR }
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
sessionKeyRef.current = expectedSession.sessionId
|
||||
setSessionKey(expectedSession.sessionId)
|
||||
setSessionError(authError)
|
||||
setStatus('unavailable')
|
||||
})
|
||||
|
||||
return { isSuccess: false, error: authError }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сверяет captured session scope с domain state и persisted authority.
|
||||
*/
|
||||
const isCurrentSession = (expectedSessionKey: string): boolean => {
|
||||
const currentResult = readSimpleAuthSession()
|
||||
const persistedSession = currentResult.status === 'ready' ? currentResult.session : null
|
||||
|
||||
return (
|
||||
sessionKeyRef.current === expectedSessionKey &&
|
||||
persistedSession?.sessionId === expectedSessionKey
|
||||
)
|
||||
}
|
||||
|
||||
const value: AuthContextValue = {
|
||||
status,
|
||||
user,
|
||||
sessionKey,
|
||||
sessionError,
|
||||
isCurrentSession,
|
||||
signIn,
|
||||
signOut,
|
||||
refreshCurrentUser
|
||||
}
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { readSimpleApiFailure } from '@/infra/simple-rest-api'
|
||||
|
||||
import type { AuthError } from '../types/auth-error.type'
|
||||
|
||||
/**
|
||||
* Переводит technical failure в ожидаемую ошибку auth-домена.
|
||||
*/
|
||||
export const mapAuthError = (error: unknown): AuthError => {
|
||||
const failure = readSimpleApiFailure(error)
|
||||
|
||||
if (failure.code === 'INVALID_CREDENTIALS') {
|
||||
return { code: 'invalid-credentials', message: 'Email or password is incorrect.' }
|
||||
}
|
||||
|
||||
if (failure.status === 401) {
|
||||
return { code: 'session-expired', message: 'Your session expired. Sign in again.' }
|
||||
}
|
||||
|
||||
if (failure.status === 403) {
|
||||
return { code: 'access-denied', message: 'This account cannot perform that action.' }
|
||||
}
|
||||
|
||||
if (failure.status === 0) {
|
||||
return { code: 'service-unavailable', message: failure.message }
|
||||
}
|
||||
|
||||
return { code: 'unknown', message: 'Authentication could not be completed.' }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { useContext } from 'react'
|
||||
|
||||
import { AuthContext } from '../auth.context'
|
||||
import type { AuthContextValue } from '../types/auth-context.type'
|
||||
|
||||
/**
|
||||
* Возвращает application-scoped auth API текущего пользователя.
|
||||
*/
|
||||
export const useAuth = (): AuthContextValue => {
|
||||
const context = useContext(AuthContext)
|
||||
|
||||
if (context === null) {
|
||||
throw new Error('useAuth must be used inside AuthProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
14
examples/demo-frontend/src/domains/auth/index.ts
Normal file
14
examples/demo-frontend/src/domains/auth/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
'use client'
|
||||
|
||||
export { AuthProvider } from './auth.provider'
|
||||
export { AUTH_DEMO_ACCOUNTS } from './auth.constants'
|
||||
export { useAuth } from './hooks/use-auth.hook'
|
||||
export type { AuthError, AuthErrorCode } from './types/auth-error.type'
|
||||
export type {
|
||||
AuthContextValue,
|
||||
AuthStatus,
|
||||
SignInCredentials
|
||||
} from './types/auth-context.type'
|
||||
export type { AuthProviderProps } from './types/auth-provider-props.type'
|
||||
export type { AuthUser, AuthUserRole } from './types/auth-user.type'
|
||||
export type { DemoAccount } from './types/demo-account.type'
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { SimpleUserDto } from '@/infra/simple-rest-api'
|
||||
|
||||
import type { AuthUser } from '../types/auth-user.type'
|
||||
|
||||
/**
|
||||
* Переводит внешний user DTO в модель auth-домена.
|
||||
*/
|
||||
export const mapAuthUser = (user: SimpleUserDto): AuthUser => {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
avatarUrl: typeof user.avatarUrl === 'string' ? user.avatarUrl : null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Result } from '@/shared/types/result.type'
|
||||
|
||||
import type { AuthError } from './auth-error.type'
|
||||
import type { AuthUser } from './auth-user.type'
|
||||
|
||||
/**
|
||||
* Состояние восстановления пользовательской сессии.
|
||||
*/
|
||||
export type AuthStatus = 'checking' | 'guest' | 'authenticated' | 'unavailable'
|
||||
|
||||
/**
|
||||
* Данные формы входа.
|
||||
*/
|
||||
export type SignInCredentials = {
|
||||
/** Email demo-пользователя. */
|
||||
email: string
|
||||
/** Пароль demo-пользователя. */
|
||||
password: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Публичный API auth-домена в application scope.
|
||||
*/
|
||||
export type AuthContextValue = {
|
||||
/** Текущее состояние восстановления или авторизации. */
|
||||
status: AuthStatus
|
||||
/** Авторизованный пользователь, если сессия активна. */
|
||||
user: AuthUser | null
|
||||
/** Непрозрачный ключ логической сессии для изоляции protected state. */
|
||||
sessionKey: string | null
|
||||
/** Recoverable ошибка проверки сохранённой сессии. */
|
||||
sessionError: AuthError | null
|
||||
/** Проверяет актуальность captured logical session для async workflow. */
|
||||
isCurrentSession: (sessionKey: string) => boolean
|
||||
/** Выполняет login и сохраняет технические JWT-данные. */
|
||||
signIn: (credentials: SignInCredentials) => Promise<Result<AuthUser, AuthError>>
|
||||
/** Завершает локальную и backend-сессию. */
|
||||
signOut: () => Promise<void>
|
||||
/** Повторно загружает профиль после внешнего изменения роли. */
|
||||
refreshCurrentUser: () => Promise<Result<AuthUser, AuthError>>
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Стабильные коды ожидаемых ошибок auth-сценариев.
|
||||
*/
|
||||
export type AuthErrorCode =
|
||||
| 'invalid-credentials'
|
||||
| 'session-expired'
|
||||
| 'access-denied'
|
||||
| 'service-unavailable'
|
||||
| 'unknown'
|
||||
|
||||
/**
|
||||
* Безопасная ошибка auth-домена.
|
||||
*/
|
||||
export type AuthError = {
|
||||
/** Код для UI-ветвления. */
|
||||
code: AuthErrorCode
|
||||
/** Сообщение без transport payload. */
|
||||
message: string
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Props application-scoped auth provider.
|
||||
*/
|
||||
export type AuthProviderProps = {
|
||||
/** Поддерево, использующее auth API. */
|
||||
children: ReactNode
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Роль пользователя в Simple storefront.
|
||||
*/
|
||||
export type AuthUserRole = 'admin' | 'customer'
|
||||
|
||||
/**
|
||||
* Авторизованный пользователь без transport-полей.
|
||||
*/
|
||||
export type AuthUser = {
|
||||
/** Стабильный идентификатор пользователя. */
|
||||
id: string
|
||||
/** Email для входа и отображения профиля. */
|
||||
email: string
|
||||
/** Отображаемое имя. */
|
||||
name: string
|
||||
/** Текущая роль доступа. */
|
||||
role: AuthUserRole
|
||||
/** Необязательный URL аватара. */
|
||||
avatarUrl: string | null
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { AuthUserRole } from './auth-user.type'
|
||||
|
||||
/**
|
||||
* Детерминированная учётная запись fixture-бэкенда.
|
||||
*/
|
||||
export type DemoAccount = {
|
||||
/** Email для формы входа. */
|
||||
email: string
|
||||
/** Общий fixture-пароль. */
|
||||
password: string
|
||||
/** Роль, которую демонстрирует учётная запись. */
|
||||
role: AuthUserRole
|
||||
/** Краткое описание доступных сценариев. */
|
||||
description: string
|
||||
}
|
||||
8
examples/demo-frontend/src/domains/cart/cart.context.ts
Normal file
8
examples/demo-frontend/src/domains/cart/cart.context.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
import type { CartContextValue } from './types/cart.type'
|
||||
|
||||
/**
|
||||
* Внутренний React context application-scoped корзины.
|
||||
*/
|
||||
export const CartContext = createContext<CartContextValue | null>(null)
|
||||
133
examples/demo-frontend/src/domains/cart/cart.logic.ts
Normal file
133
examples/demo-frontend/src/domains/cart/cart.logic.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { Product } from '@/domains/catalog'
|
||||
|
||||
import type { CartLine, CartReconciliation, CartTotals } from './types/cart.type'
|
||||
|
||||
const MAX_CART_LINE_QUANTITY = 20
|
||||
|
||||
/**
|
||||
* Возвращает единый cart/order limit для конкретного продукта.
|
||||
*/
|
||||
export const getCartProductQuantityLimit = (product: Product): number => {
|
||||
return Math.min(product.stock, MAX_CART_LINE_QUANTITY)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ограничивает cart quantity текущим stock продукта.
|
||||
*/
|
||||
const clampQuantity = (product: Product, quantity: number): number => {
|
||||
if (!Number.isFinite(quantity)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(Math.floor(quantity), getCartProductQuantityLimit(product)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавляет продукт или увеличивает существующую строку корзины.
|
||||
*/
|
||||
export const addCartProduct = (lines: CartLine[], product: Product): CartLine[] => {
|
||||
const existingLine = lines.find((line) => line.product.id === product.id)
|
||||
|
||||
if (existingLine === undefined) {
|
||||
const quantity = clampQuantity(product, 1)
|
||||
|
||||
return quantity === 0 ? lines : [...lines, { product, quantity }]
|
||||
}
|
||||
|
||||
return lines.map((line) => {
|
||||
if (line.product.id !== product.id) {
|
||||
return line
|
||||
}
|
||||
|
||||
return {
|
||||
product,
|
||||
quantity: clampQuantity(product, line.quantity + 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Устанавливает количество строки и удаляет её при нулевом результате.
|
||||
*/
|
||||
export const setCartProductQuantity = (
|
||||
lines: CartLine[],
|
||||
productId: string,
|
||||
quantity: number
|
||||
): CartLine[] => {
|
||||
return lines.flatMap((line) => {
|
||||
if (line.product.id !== productId) {
|
||||
return [line]
|
||||
}
|
||||
|
||||
const nextQuantity = clampQuantity(line.product, quantity)
|
||||
|
||||
return nextQuantity === 0 ? [] : [{ ...line, quantity: nextQuantity }]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет одну продуктовую строку.
|
||||
*/
|
||||
export const removeCartProduct = (lines: CartLine[], productId: string): CartLine[] => {
|
||||
return lines.filter((line) => line.product.id !== productId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Заменяет product snapshots authority-значениями перед checkout.
|
||||
*/
|
||||
export const reconcileCartProducts = (
|
||||
lines: CartLine[],
|
||||
products: Product[]
|
||||
): CartReconciliation => {
|
||||
const productsById = new Map(products.map((product) => [product.id, product]))
|
||||
let hasChanges = false
|
||||
const nextLines = lines
|
||||
.map((line) => {
|
||||
const currentProduct = productsById.get(line.product.id)
|
||||
|
||||
if (currentProduct === undefined) {
|
||||
return line
|
||||
}
|
||||
|
||||
const hasProductChanged =
|
||||
currentProduct.version !== line.product.version ||
|
||||
currentProduct.priceCents !== line.product.priceCents ||
|
||||
currentProduct.currency !== line.product.currency ||
|
||||
currentProduct.stock !== line.product.stock
|
||||
|
||||
if (hasProductChanged) {
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
return {
|
||||
product: currentProduct,
|
||||
quantity: clampQuantity(currentProduct, line.quantity)
|
||||
}
|
||||
})
|
||||
.filter((line) => line.quantity > 0)
|
||||
|
||||
if (nextLines.length !== lines.length) {
|
||||
hasChanges = true
|
||||
}
|
||||
|
||||
return { lines: nextLines, hasChanges }
|
||||
}
|
||||
|
||||
/**
|
||||
* Рассчитывает item count, subtotal и допустимость checkout.
|
||||
*/
|
||||
export const calculateCartTotals = (lines: CartLine[]): CartTotals => {
|
||||
const itemCount = lines.reduce((sum, line) => sum + line.quantity, 0)
|
||||
const subtotalCents = lines.reduce(
|
||||
(sum, line) => sum + line.product.priceCents * line.quantity,
|
||||
0
|
||||
)
|
||||
const firstCurrency = lines[0]?.product.currency ?? null
|
||||
const hasSingleCurrency = lines.every((line) => line.product.currency === firstCurrency)
|
||||
|
||||
return {
|
||||
itemCount,
|
||||
subtotalCents,
|
||||
currency: hasSingleCurrency ? firstCurrency : null
|
||||
}
|
||||
}
|
||||
301
examples/demo-frontend/src/domains/cart/cart.persistence.ts
Normal file
301
examples/demo-frontend/src/domains/cart/cart.persistence.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { hasOwn, isArrayOf, isNumber, isOneOf, isRecord, isString } from '@/shared/lib/value-predicates'
|
||||
import {
|
||||
readJsonStorageResult,
|
||||
withBrowserStorageLock,
|
||||
writeJsonStorage
|
||||
} from '@/infra/browser-storage'
|
||||
|
||||
import { isSupportedProductImageUrl } from '@/domains/catalog'
|
||||
import type { Product } from '@/domains/catalog'
|
||||
|
||||
import { getCartProductQuantityLimit } from './cart.logic'
|
||||
import type { CartLine } from './types/cart.type'
|
||||
|
||||
const CURRENCIES = ['USD', 'EUR'] as const
|
||||
const CART_STORAGE_KEY = 'demo-frontend:cart'
|
||||
const CART_STORAGE_LOCK = 'demo-frontend:cart-lock'
|
||||
|
||||
/**
|
||||
* Создаёт revision, сохраняющую порядок обычных updates и forced recovery.
|
||||
*/
|
||||
const createCartRevision = (currentRevision = 0): number => {
|
||||
return Math.max(currentRevision + 1, Date.now() * 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Persisted cart snapshot с cross-tab monotonic revision.
|
||||
*/
|
||||
export type PersistedCartSnapshot = {
|
||||
/** Монотонная revision, увеличиваемая под storage lock. */
|
||||
revision: number
|
||||
/** Валидированные product snapshots и quantities. */
|
||||
lines: CartLine[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Результат чтения persisted cart repository.
|
||||
*/
|
||||
export type PersistedCartReadResult =
|
||||
| {
|
||||
/** Repository доступен. */
|
||||
status: 'ready'
|
||||
/** Последний snapshot либо null до инициализации. */
|
||||
snapshot: PersistedCartSnapshot | null
|
||||
/** Требуется ли commit мигрированного legacy payload. */
|
||||
shouldCommit: boolean
|
||||
}
|
||||
| {
|
||||
/** Browser storage временно недоступен. */
|
||||
status: 'unavailable'
|
||||
}
|
||||
|
||||
/**
|
||||
* Результат atomic cart transition.
|
||||
*/
|
||||
export type PersistedCartUpdateResult =
|
||||
| {
|
||||
/** Snapshot записан с новой revision. */
|
||||
status: 'updated'
|
||||
/** Authority snapshot после transition. */
|
||||
snapshot: PersistedCartSnapshot
|
||||
}
|
||||
|
||||
| {
|
||||
/** Transform не изменил authority snapshot. */
|
||||
status: 'unchanged'
|
||||
/** Текущий authority snapshot. */
|
||||
snapshot: PersistedCartSnapshot
|
||||
}
|
||||
| {
|
||||
/** Expected revision больше не актуальна. */
|
||||
status: 'stale'
|
||||
}
|
||||
| {
|
||||
/** Repository transition не удалось подтвердить. */
|
||||
status: 'unavailable'
|
||||
}
|
||||
|
||||
/**
|
||||
* Результат проверки persisted cart revision.
|
||||
*/
|
||||
export type PersistedCartRevisionStatus = 'current' | 'stale' | 'unavailable'
|
||||
|
||||
/**
|
||||
* Проверяет persisted-снимок продукта перед восстановлением cart state.
|
||||
*/
|
||||
const isStoredProduct = (value: unknown): value is Product => {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
hasOwn(value, 'id') && isString(value.id) &&
|
||||
hasOwn(value, 'name') && isString(value.name) &&
|
||||
hasOwn(value, 'slug') && isString(value.slug) &&
|
||||
hasOwn(value, 'description') && isString(value.description) &&
|
||||
hasOwn(value, 'priceCents') && isNumber(value.priceCents) &&
|
||||
hasOwn(value, 'currency') && isOneOf(value.currency, CURRENCIES) &&
|
||||
hasOwn(value, 'categoryId') && isString(value.categoryId) &&
|
||||
hasOwn(value, 'stock') && isNumber(value.stock) &&
|
||||
hasOwn(value, 'rating') && isNumber(value.rating) &&
|
||||
hasOwn(value, 'imageUrl') && isString(value.imageUrl) && isSupportedProductImageUrl(value.imageUrl) &&
|
||||
hasOwn(value, 'createdAt') && isString(value.createdAt) &&
|
||||
hasOwn(value, 'version') && isNumber(value.version)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет persisted-строку корзины.
|
||||
*/
|
||||
const isStoredCartLine = (value: unknown): value is CartLine => {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
hasOwn(value, 'product') && isStoredProduct(value.product) &&
|
||||
hasOwn(value, 'quantity') && isNumber(value.quantity) &&
|
||||
Number.isInteger(value.quantity) &&
|
||||
value.quantity > 0 &&
|
||||
value.quantity <= getCartProductQuantityLimit(value.product)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Восстанавливает только полностью валидный persisted cart snapshot.
|
||||
*/
|
||||
export const hydrateCartLines = (value: unknown): CartLine[] => {
|
||||
return isArrayOf(value, isStoredCartLine) ? value : []
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет persisted snapshot envelope после чтения storage.
|
||||
*/
|
||||
const isPersistedCartSnapshot = (value: unknown): value is PersistedCartSnapshot => {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
hasOwn(value, 'revision') &&
|
||||
isNumber(value.revision) &&
|
||||
Number.isInteger(value.revision) &&
|
||||
value.revision >= 1 &&
|
||||
hasOwn(value, 'lines') &&
|
||||
isArrayOf(value.lines, isStoredCartLine)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Читает snapshot и мигрирует legacy lines-array в memory representation.
|
||||
*/
|
||||
export const readPersistedCartSnapshot = (): PersistedCartReadResult => {
|
||||
const result = readJsonStorageResult(CART_STORAGE_KEY)
|
||||
|
||||
if (result.status === 'unavailable' || result.status === 'invalid') {
|
||||
return { status: 'unavailable' }
|
||||
}
|
||||
|
||||
if (result.status === 'missing') {
|
||||
return { status: 'ready', snapshot: null, shouldCommit: true }
|
||||
}
|
||||
|
||||
if (isPersistedCartSnapshot(result.value)) {
|
||||
return { status: 'ready', snapshot: result.value, shouldCommit: false }
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'ready',
|
||||
snapshot: {
|
||||
revision: createCartRevision(),
|
||||
lines: hydrateCartLines(result.value)
|
||||
},
|
||||
shouldCommit: true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт или мигрирует persisted cart snapshot под cross-tab lock.
|
||||
*/
|
||||
export const initializePersistedCartSnapshot = async (): Promise<PersistedCartSnapshot | null> => {
|
||||
try {
|
||||
return await withBrowserStorageLock(CART_STORAGE_LOCK, () => {
|
||||
const result = readPersistedCartSnapshot()
|
||||
|
||||
if (result.status === 'unavailable') {
|
||||
return null
|
||||
}
|
||||
|
||||
const snapshot = result.snapshot ?? { revision: createCartRevision(), lines: [] }
|
||||
|
||||
if (result.shouldCommit && !writeJsonStorage(CART_STORAGE_KEY, snapshot)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return snapshot
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Перезаписывает unreadable cart безопасным пустым snapshot по явному действию пользователя.
|
||||
*/
|
||||
export const resetPersistedCartSnapshot = async (
|
||||
minimumRevision = 0
|
||||
): Promise<PersistedCartSnapshot | null> => {
|
||||
try {
|
||||
return await withBrowserStorageLock(CART_STORAGE_LOCK, () => {
|
||||
const snapshot: PersistedCartSnapshot = {
|
||||
revision: createCartRevision(minimumRevision),
|
||||
lines: []
|
||||
}
|
||||
|
||||
return writeJsonStorage(CART_STORAGE_KEY, snapshot) ? snapshot : null
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Применяет transform к последнему persisted snapshot под cross-tab lock.
|
||||
*/
|
||||
export const updatePersistedCartSnapshot = async (
|
||||
transform: (lines: CartLine[]) => CartLine[] | null,
|
||||
expectedRevision?: number
|
||||
): Promise<PersistedCartUpdateResult> => {
|
||||
try {
|
||||
return await withBrowserStorageLock(CART_STORAGE_LOCK, () => {
|
||||
const result = readPersistedCartSnapshot()
|
||||
|
||||
if (result.status === 'unavailable') {
|
||||
return { status: 'unavailable' }
|
||||
}
|
||||
|
||||
const currentSnapshot = result.snapshot ?? {
|
||||
revision: createCartRevision(),
|
||||
lines: []
|
||||
}
|
||||
|
||||
if (
|
||||
expectedRevision !== undefined &&
|
||||
currentSnapshot.revision !== expectedRevision
|
||||
) {
|
||||
return { status: 'stale' }
|
||||
}
|
||||
|
||||
const nextLines = transform(currentSnapshot.lines)
|
||||
|
||||
if (nextLines === null || nextLines === currentSnapshot.lines) {
|
||||
return { status: 'unchanged', snapshot: currentSnapshot }
|
||||
}
|
||||
|
||||
const nextSnapshot: PersistedCartSnapshot = {
|
||||
revision: createCartRevision(currentSnapshot.revision),
|
||||
lines: nextLines
|
||||
}
|
||||
|
||||
if (!writeJsonStorage(CART_STORAGE_KEY, nextSnapshot)) {
|
||||
return { status: 'unavailable' }
|
||||
}
|
||||
|
||||
return { status: 'updated', snapshot: nextSnapshot }
|
||||
})
|
||||
} catch {
|
||||
return { status: 'unavailable' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сверяет expected revision с persisted authority под тем же lock.
|
||||
*/
|
||||
export const isPersistedCartRevisionCurrent = async (
|
||||
expectedRevision: number
|
||||
): Promise<PersistedCartRevisionStatus> => {
|
||||
try {
|
||||
return await withBrowserStorageLock(CART_STORAGE_LOCK, () => {
|
||||
const result = readPersistedCartSnapshot()
|
||||
|
||||
if (result.status === 'unavailable') {
|
||||
return 'unavailable'
|
||||
}
|
||||
|
||||
return result.snapshot?.revision === expectedRevision ? 'current' : 'stale'
|
||||
})
|
||||
} catch {
|
||||
return 'unavailable'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Подписывает cart owner на committed snapshots других вкладок.
|
||||
*/
|
||||
export const subscribePersistedCartSnapshot = (
|
||||
listener: (result: PersistedCartReadResult) => void
|
||||
): (() => void) => {
|
||||
/**
|
||||
* Читает committed snapshot после cross-tab storage event.
|
||||
*/
|
||||
const handleStorage = (event: StorageEvent): void => {
|
||||
if (event.key === CART_STORAGE_KEY) {
|
||||
listener(readPersistedCartSnapshot())
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('storage', handleStorage)
|
||||
|
||||
return () => window.removeEventListener('storage', handleStorage)
|
||||
}
|
||||
225
examples/demo-frontend/src/domains/cart/cart.provider.tsx
Normal file
225
examples/demo-frontend/src/domains/cart/cart.provider.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { CartContext } from './cart.context'
|
||||
import {
|
||||
addCartProduct,
|
||||
calculateCartTotals,
|
||||
reconcileCartProducts,
|
||||
removeCartProduct,
|
||||
setCartProductQuantity
|
||||
} from './cart.logic'
|
||||
import {
|
||||
initializePersistedCartSnapshot,
|
||||
isPersistedCartRevisionCurrent,
|
||||
resetPersistedCartSnapshot,
|
||||
subscribePersistedCartSnapshot,
|
||||
updatePersistedCartSnapshot
|
||||
} from './cart.persistence'
|
||||
import type { PersistedCartSnapshot } from './cart.persistence'
|
||||
import type { CartProviderProps } from './types/cart-provider-props.type'
|
||||
import type { CartContextValue, CartLine, CartReconciliationStatus } from './types/cart.type'
|
||||
import type { Product } from '@/domains/catalog'
|
||||
|
||||
const CART_STORAGE_ERROR = {
|
||||
code: 'storage-unavailable',
|
||||
message: 'Cart storage is unavailable. Changes are paused to prevent cross-tab data loss.'
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Владеет корзиной в application scope и сохраняет её между перезагрузками.
|
||||
*
|
||||
* Используется для:
|
||||
* - координации каталога и checkout composition
|
||||
* - восстановления валидного cart snapshot из browser storage
|
||||
*/
|
||||
export const CartProvider = (props: CartProviderProps) => {
|
||||
const { children } = props
|
||||
const [cartState, setCartState] = useState({
|
||||
lines: [] as CartLine[],
|
||||
revision: 0
|
||||
})
|
||||
const cartStateRef = useRef(cartState)
|
||||
const [isHydrated, setIsHydrated] = useState(false)
|
||||
const [error, setError] = useState<CartContextValue['error']>(null)
|
||||
const lines = cartState.lines
|
||||
|
||||
/**
|
||||
* Публикует только текущий или более новый persisted cart snapshot.
|
||||
*/
|
||||
const publishSnapshot = (snapshot: PersistedCartSnapshot): void => {
|
||||
if (snapshot.revision < cartStateRef.current.revision) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextState = {
|
||||
lines: snapshot.lines,
|
||||
revision: snapshot.revision
|
||||
}
|
||||
|
||||
cartStateRef.current = nextState
|
||||
setCartState(nextState)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true
|
||||
|
||||
void initializePersistedCartSnapshot().then((snapshot) => {
|
||||
if (isActive) {
|
||||
if (snapshot !== null) {
|
||||
publishSnapshot(snapshot)
|
||||
setError(null)
|
||||
} else {
|
||||
setError(CART_STORAGE_ERROR)
|
||||
}
|
||||
|
||||
setIsHydrated(true)
|
||||
}
|
||||
})
|
||||
|
||||
const unsubscribe = subscribePersistedCartSnapshot((result) => {
|
||||
if (isActive && result.status === 'ready' && result.snapshot !== null) {
|
||||
publishSnapshot(result.snapshot)
|
||||
setError(null)
|
||||
} else if (isActive && result.status === 'unavailable') {
|
||||
setError(CART_STORAGE_ERROR)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
isActive = false
|
||||
unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Применяет cart transform к последнему cross-tab authority snapshot.
|
||||
*/
|
||||
const updateLines = async (
|
||||
transform: (currentLines: CartLine[]) => CartLine[]
|
||||
): Promise<void> => {
|
||||
const result = await updatePersistedCartSnapshot(transform)
|
||||
|
||||
if (result.status === 'updated' || result.status === 'unchanged') {
|
||||
publishSnapshot(result.snapshot)
|
||||
setError(null)
|
||||
} else if (result.status === 'unavailable') {
|
||||
setError(CART_STORAGE_ERROR)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавляет одну доступную единицу продукта.
|
||||
*/
|
||||
const addProduct = async (product: Product): Promise<void> => {
|
||||
await updateLines((currentLines) => addCartProduct(currentLines, product))
|
||||
}
|
||||
|
||||
/**
|
||||
* Изменяет количество выбранного продукта.
|
||||
*/
|
||||
const setQuantity = async (productId: string, quantity: number): Promise<void> => {
|
||||
await updateLines((currentLines) => {
|
||||
return setCartProductQuantity(currentLines, productId, quantity)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет продукт из корзины.
|
||||
*/
|
||||
const removeProduct = async (productId: string): Promise<void> => {
|
||||
await updateLines((currentLines) => removeCartProduct(currentLines, productId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет stale snapshots перед checkout и сообщает о расхождении.
|
||||
*/
|
||||
const reconcileProducts = (
|
||||
products: Product[],
|
||||
expectedRevision: number
|
||||
): Promise<CartReconciliationStatus> => {
|
||||
return updatePersistedCartSnapshot((currentLines) => {
|
||||
const reconciliation = reconcileCartProducts(currentLines, products)
|
||||
|
||||
return reconciliation.hasChanges ? reconciliation.lines : null
|
||||
}, expectedRevision).then((result) => {
|
||||
if (result.status === 'unavailable') {
|
||||
setError(CART_STORAGE_ERROR)
|
||||
return 'unavailable'
|
||||
}
|
||||
|
||||
if (result.status === 'stale') {
|
||||
return 'stale'
|
||||
}
|
||||
|
||||
publishSnapshot(result.snapshot)
|
||||
setError(null)
|
||||
|
||||
return result.status === 'updated' ? 'updated' : 'unchanged'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет revision перед необратимым продолжением async checkout.
|
||||
*/
|
||||
const isCurrentRevision: CartContextValue['isCurrentRevision'] = (revision) => {
|
||||
return isPersistedCartRevisionCurrent(revision).then((status) => {
|
||||
if (status === 'unavailable') {
|
||||
setError(CART_STORAGE_ERROR)
|
||||
}
|
||||
|
||||
return status
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Завершает checkout очисткой всех строк.
|
||||
*/
|
||||
const clearCart: CartContextValue['clearCart'] = async (expectedRevision) => {
|
||||
const result = await updatePersistedCartSnapshot(() => [], expectedRevision)
|
||||
|
||||
if (result.status === 'unavailable') {
|
||||
if (expectedRevision === undefined) {
|
||||
const recoveredSnapshot = await resetPersistedCartSnapshot(
|
||||
cartStateRef.current.revision
|
||||
)
|
||||
|
||||
if (recoveredSnapshot !== null) {
|
||||
publishSnapshot(recoveredSnapshot)
|
||||
setError(null)
|
||||
return 'cleared'
|
||||
}
|
||||
}
|
||||
|
||||
setError(CART_STORAGE_ERROR)
|
||||
return 'unavailable'
|
||||
}
|
||||
|
||||
if (result.status === 'stale') {
|
||||
return 'stale'
|
||||
}
|
||||
|
||||
publishSnapshot(result.snapshot)
|
||||
setError(null)
|
||||
|
||||
return 'cleared'
|
||||
}
|
||||
|
||||
const totals = calculateCartTotals(lines)
|
||||
const value: CartContextValue = {
|
||||
...totals,
|
||||
lines,
|
||||
isHydrated,
|
||||
revision: cartState.revision,
|
||||
error,
|
||||
addProduct,
|
||||
setQuantity,
|
||||
removeProduct,
|
||||
reconcileProducts,
|
||||
isCurrentRevision,
|
||||
clearCart
|
||||
}
|
||||
|
||||
return <CartContext.Provider value={value}>{children}</CartContext.Provider>
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { useContext } from 'react'
|
||||
|
||||
import { CartContext } from '../cart.context'
|
||||
import type { CartContextValue } from '../types/cart.type'
|
||||
|
||||
/**
|
||||
* Возвращает application-scoped API корзины.
|
||||
*/
|
||||
export const useCart = (): CartContextValue => {
|
||||
const context = useContext(CartContext)
|
||||
|
||||
if (context === null) {
|
||||
throw new Error('useCart must be used inside CartProvider')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
15
examples/demo-frontend/src/domains/cart/index.ts
Normal file
15
examples/demo-frontend/src/domains/cart/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
|
||||
export { CartProvider } from './cart.provider'
|
||||
export { getCartProductQuantityLimit } from './cart.logic'
|
||||
export { useCart } from './hooks/use-cart.hook'
|
||||
export type { CartProviderProps } from './types/cart-provider-props.type'
|
||||
export type {
|
||||
CartContextValue,
|
||||
CartClearStatus,
|
||||
CartError,
|
||||
CartLine,
|
||||
CartReconciliation,
|
||||
CartRevisionStatus,
|
||||
CartTotals
|
||||
} from './types/cart.type'
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { Product } from '@/domains/catalog'
|
||||
|
||||
import {
|
||||
addCartProduct,
|
||||
calculateCartTotals,
|
||||
reconcileCartProducts,
|
||||
setCartProductQuantity
|
||||
} from '../cart.logic'
|
||||
|
||||
const PRODUCT: Product = {
|
||||
id: 'product-test',
|
||||
name: 'Test object',
|
||||
slug: 'test-object',
|
||||
description: 'A deterministic product for cart owner tests.',
|
||||
priceCents: 2500,
|
||||
currency: 'USD',
|
||||
categoryId: 'category-test',
|
||||
stock: 2,
|
||||
rating: 4.5,
|
||||
imageUrl: 'https://picsum.photos/seed/test/640/480',
|
||||
createdAt: '2026-07-20T10:00:00.000Z',
|
||||
version: 1
|
||||
}
|
||||
|
||||
describe('cart domain logic', () => {
|
||||
it('caps quantity at the current product stock', () => {
|
||||
const once = addCartProduct([], PRODUCT)
|
||||
const twice = addCartProduct(once, PRODUCT)
|
||||
const threeAttempts = addCartProduct(twice, PRODUCT)
|
||||
|
||||
expect(threeAttempts).toEqual([{ product: PRODUCT, quantity: 2 }])
|
||||
})
|
||||
|
||||
it('caps high-stock products at the order line contract limit', () => {
|
||||
const highStockProduct = { ...PRODUCT, stock: 100 }
|
||||
const lines = addCartProduct([], highStockProduct)
|
||||
|
||||
expect(setCartProductQuantity(lines, highStockProduct.id, 21)).toEqual([
|
||||
{ product: highStockProduct, quantity: 20 }
|
||||
])
|
||||
})
|
||||
|
||||
it('removes a line when quantity becomes zero', () => {
|
||||
const lines = addCartProduct([], PRODUCT)
|
||||
|
||||
expect(setCartProductQuantity(lines, PRODUCT.id, 0)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects checkout for mixed currencies while retaining item totals', () => {
|
||||
const eurProduct: Product = { ...PRODUCT, id: 'product-eur', currency: 'EUR' }
|
||||
const totals = calculateCartTotals([
|
||||
{ product: PRODUCT, quantity: 1 },
|
||||
{ product: eurProduct, quantity: 1 }
|
||||
])
|
||||
|
||||
expect(totals).toEqual({
|
||||
itemCount: 2,
|
||||
subtotalCents: 5000,
|
||||
currency: null
|
||||
})
|
||||
})
|
||||
|
||||
it('reconciles a stale price and version before checkout', () => {
|
||||
const lines = [{ product: PRODUCT, quantity: 1 }]
|
||||
const latestProduct = { ...PRODUCT, priceCents: 3100, version: 2 }
|
||||
|
||||
expect(reconcileCartProducts(lines, [latestProduct])).toEqual({
|
||||
lines: [{ product: latestProduct, quantity: 1 }],
|
||||
hasChanges: true
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,140 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
hydrateCartLines,
|
||||
initializePersistedCartSnapshot,
|
||||
readPersistedCartSnapshot,
|
||||
resetPersistedCartSnapshot,
|
||||
updatePersistedCartSnapshot
|
||||
} from '../cart.persistence'
|
||||
|
||||
const STORED_LINE = {
|
||||
product: {
|
||||
id: 'product-test',
|
||||
name: 'Test object',
|
||||
slug: 'test-object',
|
||||
description: 'Persisted cart fixture.',
|
||||
priceCents: 2500,
|
||||
currency: 'USD' as const,
|
||||
categoryId: 'category-test',
|
||||
stock: 2,
|
||||
rating: 4.5,
|
||||
imageUrl: 'https://picsum.photos/seed/test/640/480',
|
||||
createdAt: '2026-07-20T10:00:00.000Z',
|
||||
version: 1
|
||||
},
|
||||
quantity: 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт localStorage для persisted cart CAS tests.
|
||||
*/
|
||||
const createStorage = (): Storage => {
|
||||
const values = new Map<string, string>()
|
||||
|
||||
return {
|
||||
get length() {
|
||||
return values.size
|
||||
},
|
||||
clear: () => values.clear(),
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => [...values.keys()][index] ?? null,
|
||||
removeItem: (key) => values.delete(key),
|
||||
setItem: (key, value) => values.set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: createStorage(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('cart persistence boundary', () => {
|
||||
it('rejects a persisted product image outside the configured host', () => {
|
||||
const storedLines = [
|
||||
{
|
||||
...STORED_LINE,
|
||||
product: {
|
||||
...STORED_LINE.product,
|
||||
imageUrl: 'https://example.com/untrusted.png'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
expect(hydrateCartLines(storedLines)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a legacy quantity above the order line limit', () => {
|
||||
const storedLines = [
|
||||
{
|
||||
...STORED_LINE,
|
||||
product: { ...STORED_LINE.product, stock: 100 },
|
||||
quantity: 21
|
||||
}
|
||||
]
|
||||
|
||||
expect(hydrateCartLines(storedLines)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a fractional persisted quantity', () => {
|
||||
expect(hydrateCartLines([{ ...STORED_LINE, quantity: 1.5 }])).toEqual([])
|
||||
})
|
||||
|
||||
it('does not clear a newer cross-tab snapshot before its storage event arrives', async () => {
|
||||
const initialSnapshot = await initializePersistedCartSnapshot()
|
||||
|
||||
expect(initialSnapshot).not.toBeNull()
|
||||
|
||||
if (initialSnapshot === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const checkoutSnapshot = await updatePersistedCartSnapshot(() => [STORED_LINE])
|
||||
|
||||
expect(checkoutSnapshot.status).toBe('updated')
|
||||
|
||||
if (checkoutSnapshot.status !== 'updated') {
|
||||
return
|
||||
}
|
||||
|
||||
await updatePersistedCartSnapshot((lines) => {
|
||||
const line = lines[0]
|
||||
|
||||
return line === undefined ? lines : [{ ...line, quantity: 2 }]
|
||||
})
|
||||
|
||||
const staleClear = await updatePersistedCartSnapshot(
|
||||
() => [],
|
||||
checkoutSnapshot.snapshot.revision
|
||||
)
|
||||
const current = readPersistedCartSnapshot()
|
||||
|
||||
expect(staleClear).toEqual({ status: 'stale' })
|
||||
expect(current.status === 'ready' ? current.snapshot?.lines[0]?.quantity : null).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps forced recovery above the local revision when the clock moves backwards', async () => {
|
||||
const currentSnapshot = await initializePersistedCartSnapshot()
|
||||
|
||||
expect(currentSnapshot).not.toBeNull()
|
||||
|
||||
if (currentSnapshot === null) {
|
||||
return
|
||||
}
|
||||
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1)
|
||||
window.localStorage.setItem('demo-frontend:cart', '{broken-json')
|
||||
|
||||
const recoveredSnapshot = await resetPersistedCartSnapshot(currentSnapshot.revision)
|
||||
|
||||
expect(recoveredSnapshot?.revision).toBeGreaterThan(currentSnapshot.revision)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Props application-scoped cart provider.
|
||||
*/
|
||||
export type CartProviderProps = {
|
||||
/** Поддерево, использующее cart API. */
|
||||
children: ReactNode
|
||||
}
|
||||
87
examples/demo-frontend/src/domains/cart/types/cart.type.ts
Normal file
87
examples/demo-frontend/src/domains/cart/types/cart.type.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { CatalogCurrency, Product } from '@/domains/catalog'
|
||||
|
||||
/**
|
||||
* Одна продуктовая позиция корзины.
|
||||
*/
|
||||
export type CartLine = {
|
||||
/** Снимок продукта на момент добавления. */
|
||||
product: Product
|
||||
/** Количество единиц от 1 до backend-лимита 20. */
|
||||
quantity: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Рассчитанные итоги текущей корзины.
|
||||
*/
|
||||
export type CartTotals = {
|
||||
/** Суммарное число единиц. */
|
||||
itemCount: number
|
||||
/** Сумма в минимальных единицах при единой валюте. */
|
||||
subtotalCents: number
|
||||
/** Единая валюта либо null для смешанной корзины. */
|
||||
currency: CatalogCurrency | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Результат сверки cart snapshots с authority catalog.
|
||||
*/
|
||||
export type CartReconciliation = {
|
||||
/** Строки с актуальными product snapshots. */
|
||||
lines: CartLine[]
|
||||
/** Изменился ли price, currency, stock, version или доступность. */
|
||||
hasChanges: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Результат CAS-сверки checkout snapshot с authority catalog.
|
||||
*/
|
||||
export type CartReconciliationStatus = 'stale' | 'unavailable' | 'unchanged' | 'updated'
|
||||
|
||||
/**
|
||||
* Результат conditional cart clear после checkout или fixture transition.
|
||||
*/
|
||||
export type CartClearStatus = 'cleared' | 'stale' | 'unavailable'
|
||||
|
||||
/**
|
||||
* Результат persisted revision precondition.
|
||||
*/
|
||||
export type CartRevisionStatus = 'current' | 'stale' | 'unavailable'
|
||||
|
||||
/**
|
||||
* Recoverable failure persisted cart repository.
|
||||
*/
|
||||
export type CartError = {
|
||||
/** Стабильный код storage failure. */
|
||||
code: 'storage-unavailable'
|
||||
/** Сообщение для application-level feedback. */
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Публичный API cart-домена в application scope.
|
||||
*/
|
||||
export type CartContextValue = CartTotals & {
|
||||
/** Позиции корзины. */
|
||||
lines: CartLine[]
|
||||
/** Завершено ли восстановление persisted-снимка. */
|
||||
isHydrated: boolean
|
||||
/** Revision текущего immutable cart snapshot. */
|
||||
revision: number
|
||||
/** Последний recoverable repository failure. */
|
||||
error: CartError | null
|
||||
/** Добавляет одну единицу продукта с учётом stock. */
|
||||
addProduct: (product: Product) => Promise<void>
|
||||
/** Изменяет количество или удаляет строку при нуле. */
|
||||
setQuantity: (productId: string, quantity: number) => Promise<void>
|
||||
/** Удаляет продукт из корзины. */
|
||||
removeProduct: (productId: string) => Promise<void>
|
||||
/** Заменяет snapshots только для ожидаемой cart revision. */
|
||||
reconcileProducts: (
|
||||
products: Product[],
|
||||
expectedRevision: number
|
||||
) => Promise<CartReconciliationStatus>
|
||||
/** Проверяет, что async workflow всё ещё относится к cart snapshot. */
|
||||
isCurrentRevision: (revision: number) => Promise<CartRevisionStatus>
|
||||
/** Очищает корзину безусловно либо только для ожидаемой revision. */
|
||||
clearCart: (expectedRevision?: number) => Promise<CartClearStatus>
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { CreateProductInput, UpdateProductInput } from './types/catalog-command.type'
|
||||
import type { CatalogError } from './types/catalog-error.type'
|
||||
|
||||
export const PRODUCT_IMAGE_PLACEHOLDER = '/product-placeholder.svg'
|
||||
|
||||
/**
|
||||
* Проверяет, что remote image может быть безопасно отрисован Next Image.
|
||||
*/
|
||||
export const isSupportedProductImageUrl = (value: string): boolean => {
|
||||
if (value === PRODUCT_IMAGE_PLACEHOLDER) {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(value)
|
||||
|
||||
return url.protocol === 'https:' && url.hostname === 'picsum.photos'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Нормализует внешний image URL к гарантированно отображаемому product source.
|
||||
*/
|
||||
export const normalizeProductImageUrl = (value: string): string => {
|
||||
return isSupportedProductImageUrl(value) ? value : PRODUCT_IMAGE_PLACEHOLDER
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет frontend product contract до необратимой backend mutation.
|
||||
*/
|
||||
export const validateProductInput = (
|
||||
input: CreateProductInput | UpdateProductInput
|
||||
): CatalogError | null => {
|
||||
if (
|
||||
input.imageUrl !== undefined &&
|
||||
(
|
||||
input.imageUrl === PRODUCT_IMAGE_PLACEHOLDER ||
|
||||
!isSupportedProductImageUrl(input.imageUrl)
|
||||
)
|
||||
) {
|
||||
return {
|
||||
code: 'validation',
|
||||
message: 'Use an HTTPS image hosted on picsum.photos.'
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { readSimpleApiFailure } from '@/infra/simple-rest-api'
|
||||
|
||||
import type { CatalogError } from '../types/catalog-error.type'
|
||||
|
||||
/**
|
||||
* Переводит technical failure в ожидаемую ошибку catalog-домена.
|
||||
*/
|
||||
export const mapCatalogError = (error: unknown): CatalogError => {
|
||||
const failure = readSimpleApiFailure(error)
|
||||
|
||||
if (failure.code === 'SESSION_CHANGED') {
|
||||
return { code: 'forbidden', message: 'The active admin session changed. Start again.' }
|
||||
}
|
||||
|
||||
if (failure.status === 404) {
|
||||
return { code: 'not-found', message: 'The requested product no longer exists.' }
|
||||
}
|
||||
|
||||
if (failure.status === 409) {
|
||||
return { code: 'conflict', message: 'This product changed. Reload it before saving again.' }
|
||||
}
|
||||
|
||||
if (failure.status === 403) {
|
||||
return { code: 'forbidden', message: 'Administrator access is required.' }
|
||||
}
|
||||
|
||||
if (failure.status === 400 || failure.status === 422) {
|
||||
return { code: 'validation', message: 'Check the submitted product fields.' }
|
||||
}
|
||||
|
||||
if (failure.status === 0) {
|
||||
return { code: 'service-unavailable', message: failure.message }
|
||||
}
|
||||
|
||||
return { code: 'unknown', message: 'The catalog request could not be completed.' }
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
createSimpleAuthSessionRequest,
|
||||
simpleRestApi,
|
||||
useSimpleRestApiCache
|
||||
} from '@/infra/simple-rest-api'
|
||||
|
||||
import { mapCatalogError } from '../errors/catalog-error.mapper'
|
||||
import { mapProduct } from '../mappers/catalog.mapper'
|
||||
import { validateProductInput } from '../catalog.validation'
|
||||
import type { CatalogCommands } from '../types/catalog-command.type'
|
||||
|
||||
/**
|
||||
* Возвращает catalog commands, привязанные к текущему REST cache scope.
|
||||
*/
|
||||
export const useCatalogCommands = (): CatalogCommands => {
|
||||
const cache = useSimpleRestApiCache()
|
||||
|
||||
/**
|
||||
* Создаёт продукт с domain validation и backend RBAC.
|
||||
*/
|
||||
const createProduct: CatalogCommands['createProduct'] = async (input, sessionKey) => {
|
||||
const validationError = validateProductInput(input)
|
||||
|
||||
if (validationError !== null) {
|
||||
return { isSuccess: false, error: validationError }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await simpleRestApi.products.simpleProductsCreate(
|
||||
input,
|
||||
createSimpleAuthSessionRequest(sessionKey)
|
||||
)
|
||||
const product = mapProduct(response.data)
|
||||
|
||||
await cache.invalidateCatalog()
|
||||
|
||||
return { isSuccess: true, data: product }
|
||||
} catch (error) {
|
||||
return { isSuccess: false, error: mapCatalogError(error) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохраняет продукт с optimistic-lock версией последнего чтения.
|
||||
*/
|
||||
const updateProduct: CatalogCommands['updateProduct'] = async (input, sessionKey) => {
|
||||
const validationError = validateProductInput(input)
|
||||
|
||||
if (validationError !== null) {
|
||||
return { isSuccess: false, error: validationError }
|
||||
}
|
||||
|
||||
const { id, ...changes } = input
|
||||
|
||||
try {
|
||||
const response = await simpleRestApi.products.simpleProductsUpdate(
|
||||
{ id },
|
||||
changes,
|
||||
createSimpleAuthSessionRequest(sessionKey)
|
||||
)
|
||||
const product = mapProduct(response.data)
|
||||
|
||||
await cache.invalidateCatalog()
|
||||
|
||||
return { isSuccess: true, data: product }
|
||||
} catch (error) {
|
||||
return { isSuccess: false, error: mapCatalogError(error) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет продукт и ревалидирует только catalog resources.
|
||||
*/
|
||||
const removeProduct: CatalogCommands['removeProduct'] = async (productId, sessionKey) => {
|
||||
try {
|
||||
await simpleRestApi.products.simpleProductsRemove(
|
||||
{ id: productId },
|
||||
createSimpleAuthSessionRequest(sessionKey)
|
||||
)
|
||||
await cache.invalidateCatalog()
|
||||
|
||||
return { isSuccess: true, data: { id: productId } }
|
||||
} catch (error) {
|
||||
return { isSuccess: false, error: mapCatalogError(error) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает свежие product snapshots перед checkout или conflict recovery.
|
||||
*/
|
||||
const loadProducts: CatalogCommands['loadProducts'] = async (productIds) => {
|
||||
const uniqueProductIds = [...new Set(productIds)]
|
||||
|
||||
try {
|
||||
const responses = await Promise.all(
|
||||
uniqueProductIds.map((productId) =>
|
||||
simpleRestApi.products.simpleProductsGet({ id: productId })
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
isSuccess: true,
|
||||
data: responses.map((response) => mapProduct(response.data))
|
||||
}
|
||||
} catch (error) {
|
||||
return { isSuccess: false, error: mapCatalogError(error) }
|
||||
}
|
||||
}
|
||||
|
||||
return { createProduct, updateProduct, removeProduct, loadProducts }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user