Compare commits
421 Commits
main
..
29d240424b
| Author | SHA1 | Date | |
|---|---|---|---|
| 29d240424b | |||
| ca8ed0da62 | |||
| a036802e03 | |||
| 4ddf95dadf | |||
| ef3cc25dd4 | |||
| 5b9c2f4c46 | |||
| 21aa19b5d1 | |||
| c6d509b1a6 | |||
| 12222ddf43 | |||
| f34c05095a | |||
| ba375aee12 | |||
| 79f48bd1d0 | |||
| 0a276657fb | |||
| ef68cc3ce8 | |||
| 8adda7e463 | |||
| 9c1c824f10 | |||
| 7000fbffa7 | |||
| 966731d3e1 | |||
| f15f331de5 | |||
| 2889cd9545 | |||
| ae36123fa7 | |||
| 3e00c402b5 | |||
| 9aea74db96 | |||
| 8e1f977d43 | |||
| 39c9808eaa | |||
| 0bfff541a3 | |||
| 8133e0cf63 | |||
| 599b66503d | |||
| b0b2872cf8 | |||
| ed424a3b0b | |||
| 45627206c0 | |||
| 24b3b4063d | |||
| 6615d97203 | |||
| 36e75ab24a | |||
| d254c3c813 | |||
| 7642edc154 | |||
| 4ca04878cb | |||
| 39286f0fe0 | |||
| 9502a0c550 | |||
| 30bb25c416 | |||
| e173977daf | |||
| e2d4423e2e | |||
| f6414adf2f | |||
| 8f3bd7aa3b | |||
| b392884503 | |||
| 5071d70746 | |||
| 4eb93aac54 | |||
| 73f85f7439 | |||
| dae23599af | |||
| e93fc7a972 | |||
| c01070cb09 | |||
| e092299a11 | |||
| 4b8b86e1b8 | |||
| 82a0beb97c | |||
| e5e1e01c7e | |||
| 09c5e0cd50 | |||
| af582a813f | |||
| 0576cc1251 | |||
| 69813b0fd0 | |||
| 849e96511e | |||
| a40c68141e | |||
| 9784ac3cb2 | |||
| f24308bb56 | |||
| 0771209c5d | |||
| bd8722cfcb | |||
| bedf98245b | |||
| c9e3917129 | |||
| d87abb6425 | |||
| 45aee539a2 | |||
| cb3b2e64ad | |||
| fc2a04dafc | |||
| b17e571772 | |||
| ddae0e8583 | |||
| 4aba164c78 | |||
| 9f5c2f8637 | |||
| 1cfabab4c6 | |||
| af6b249248 | |||
| 74fe39829d | |||
| e4012d8133 | |||
| 56ad07cb56 | |||
| ca302c4e2d | |||
| 9ce3375088 | |||
| c46e19f95e | |||
| c9342f833b | |||
| d7e355dc78 | |||
| 0dd5f8b8ff | |||
| 8a4fd53bc4 | |||
| c2b685c0dc | |||
| 3b10d8764d | |||
| 261233442e | |||
| bc417375b5 | |||
| 39699d77ce | |||
| ae83e2ae5f | |||
| 4b89c42a72 | |||
| 80e3cd1b30 | |||
| 8474ee0f80 | |||
| c81b7b1e2d | |||
| 5516b3309c | |||
| 19dd5f213c | |||
| 42c83b5d4e | |||
| 5ef3861e84 | |||
| b757f18bfb | |||
| 6f29da65cc | |||
| 649ebb0256 | |||
| 6e046e0e35 | |||
| af8107ebe0 | |||
| 93c098a088 | |||
| 69f7e4f9e8 | |||
| 873d98eb1e | |||
| 5b88a3c9a5 | |||
| 83ae974017 | |||
| dc1c004a82 | |||
| 82f11e0492 | |||
| 53f02e1782 | |||
| e0e94f4439 | |||
| f6c9d1e5a9 | |||
| 96f06c79b4 | |||
| 88fedd675a | |||
| 8d4ff3ef62 | |||
| e9b4edc792 | |||
| 971a08997b | |||
| c2c4099fd7 | |||
| 2fe426b70a | |||
| 75841342c6 | |||
| 755e9dcad3 | |||
| 1041af32e5 | |||
| e52588686a | |||
| 1e98720751 | |||
| dba8e902bf | |||
| ff2271ecb1 | |||
| d0d7eab77e | |||
| bd9bdc0352 | |||
| d660663b72 | |||
| 4bcced4e08 | |||
| 347fcac6a7 | |||
| eee200ae04 | |||
| 8001d7d32c | |||
| fd720572e7 | |||
| 5fdf49658f | |||
| 51cc5832c3 | |||
| 8ed2f0e9ba | |||
| e22f084940 | |||
| 54022d72ff | |||
| 1b9cc8ac57 | |||
| b58ad6cc45 | |||
| 7e5ed9cefa | |||
| bb78782b39 | |||
| e85ebc203c | |||
| d60270336e | |||
| 13cc1fa2b8 | |||
| f0af519ec1 | |||
| 9d7e7949b9 | |||
| bead725036 | |||
| caa9b926e3 | |||
| 0f2ac862de | |||
| cc94917c5f | |||
| 3d0dbdd0a5 | |||
| 5644a2ede2 | |||
| 20e4b1e0ab | |||
| 02c7d7ba36 | |||
| a96944328d | |||
| 3b627e8e2f | |||
| 86523cda71 | |||
| a5e875292d | |||
| a84045a68d | |||
| 4381121f25 | |||
| e2a04d04a3 | |||
| 5127d4a093 | |||
| 55dc58cff8 | |||
| 6b89f42269 | |||
| 3212d6c185 | |||
| 76c8564e77 | |||
| 8fb01126b8 | |||
| bc85fa8e84 | |||
| b38b24f158 | |||
| c903db439d | |||
| 237106f2a4 | |||
| 955368d898 | |||
| f39d4e82ff | |||
| 2b5c7fff5e | |||
| b3b539b6fb | |||
| 49f24d7482 | |||
| be9a9bad8e | |||
| fa276eb7f3 | |||
| e273c29c36 | |||
| 03e60e46f3 | |||
| b1530ef705 | |||
| 68bbbf8895 | |||
| da13ce2848 | |||
| c9fb9cc8fc | |||
| d79d02d5d1 | |||
| ad43ff98b6 | |||
| 22282c5f4e | |||
| d51266446f | |||
| b7c11fbce6 | |||
| f02c615dd9 | |||
| cf61a5c44f | |||
| e468625cfc | |||
| 9696a4dcc3 | |||
| eb30640b49 | |||
| 669b9aa45d | |||
| b2ccc2a256 | |||
| 5651403d2e | |||
| 39d6a1604c | |||
| 6d23aafcc1 | |||
| afc763c522 | |||
| be65f2330e | |||
| 6bedf0b28a | |||
| abb14a49e0 | |||
| c9fa05b7bf | |||
| 5f180fffaf | |||
| bb7b40ac45 | |||
| c3e4f5bdd2 | |||
| 924d7b7b77 | |||
| 4fa4a91ddc | |||
| f6729210db | |||
| 367ea1e501 | |||
| b7895b3fe1 | |||
| 44c95502f8 | |||
| c5775c7f5d | |||
| e09fe7211a | |||
| 57da755ea1 | |||
| 7e7bade80c | |||
| d69647ffe3 | |||
| 7a9e44bc5c | |||
| 2751332356 | |||
| d1e4cc67aa | |||
| 52290e162e | |||
| 0dfa428931 | |||
| 37be5eef08 | |||
| ff7a4b6bba | |||
| 098c4c2b54 | |||
| d056399b3b | |||
| 47124a01a7 | |||
| 058fa26e12 | |||
| a176955521 | |||
| 76cd19e3ab | |||
| 7117978800 | |||
| 41b95d7122 | |||
| 1837b36b14 | |||
| ae6f86041a | |||
| 3177413acd | |||
| faac332138 | |||
| 698293e2f1 | |||
| dcf601d4a2 | |||
| 317b910710 | |||
| 7d0854a294 | |||
| 8d45155b54 | |||
| abadbbd4c4 | |||
| a3556367c6 | |||
| 3879e4b388 | |||
| e2cea63af0 | |||
| dad644190a | |||
| 7bba78b4c0 | |||
| 585c565b7b | |||
| fc7bc43c9f | |||
| 98631e1d1a | |||
| b06ba64365 | |||
| af5376d0e1 | |||
| c32d5e6aff | |||
| 1873681fa6 | |||
| bf22aaf917 | |||
| 76d215e4dc | |||
| e8f5bba9bf | |||
| ec2c70e3ae | |||
| 54f5ec78c3 | |||
| 00b74e56d7 | |||
| 8d9c250eb7 | |||
| 6fde248dc5 | |||
| d2d2f721cd | |||
| 32a4406cb8 | |||
| cc7e46b447 | |||
| ce49f75100 | |||
| 36880c298c | |||
| d931545a2e | |||
| 01bd9f8968 | |||
| 8257a19292 | |||
| cb4661dc13 | |||
| 0b01b61e48 | |||
| 17b683f131 | |||
| 57275514bf | |||
| 348ffd940c | |||
| 5eadbd0d0e | |||
| dbe36ce6fd | |||
| 777ba6ec15 | |||
| e7cc518d7f | |||
| f01ede6ee9 | |||
| 2ffa11be50 | |||
| 22ac9e381d | |||
| 4952ed6371 | |||
| 7250875a5c | |||
| 5adbe9baa7 | |||
| f8867f6457 | |||
| 3972133155 | |||
| c25b4f97f2 | |||
| 9999b28d49 | |||
| 0ee9e76a30 | |||
| d0b3c97803 | |||
| 2f67c37502 | |||
| 7421384161 | |||
| 29f3aba4ae | |||
| 6912008a2c | |||
| 6054ef4c06 | |||
| dfec821545 | |||
| ea0d6bdb91 | |||
| 912724082e | |||
| 1d36f6a31b | |||
| 84cdccaa17 | |||
| e73a0ae09a | |||
| 3f83a9be8e | |||
| 4a424b68a2 | |||
| e0a045d5df | |||
| 8f3d1ae5ef | |||
| 79c85b0a88 | |||
| 86f8569840 | |||
| 09ada62daf | |||
| dcbcb42acd | |||
| 4816d098da | |||
| d18546c45a | |||
| f0365d0b98 | |||
| 35dee985f7 | |||
| 5411f8ae24 | |||
| cf6b5da4fc | |||
| 02172f7995 | |||
| 5637bb7db9 | |||
| 9226bcc571 | |||
| 248f8766aa | |||
| c8281a39e5 | |||
| f36439de38 | |||
| 347089b173 | |||
| f855568687 | |||
| 2db6258b33 | |||
| 518879dabb | |||
| 0a3aa2b891 | |||
| d499aeb735 | |||
| 7e231dbdd8 | |||
| 56bdcc0351 | |||
| dc448d6538 | |||
| d73d88d034 | |||
| b22c3f312c | |||
| 3e5e495b9f | |||
| 5de2694a14 | |||
| 8b79c01985 | |||
| 1739f52ad3 | |||
| be48606ae3 | |||
| 551c9b027c | |||
| 48dc9de456 | |||
| 25901ae224 | |||
| ed475be289 | |||
| d8798de49a | |||
| 50eb427f5c | |||
| 9c238bd542 | |||
| 35ddb17247 | |||
| 8965eb6dad | |||
| b4f436cbdf | |||
| b52471ec97 | |||
| 5856a9eaf6 | |||
| 301f3eee5c | |||
| 5246a4e52e | |||
| ec1d2c4b1a | |||
| db5e3c4d52 | |||
| 0bef02bc6d | |||
| c37743eee6 | |||
| 66b0558a42 | |||
| 906dc61d0a | |||
| 78fc1d4d96 | |||
| 1b7ec703ee | |||
| 10ffa21c66 | |||
| 89d605adf4 | |||
| c5634deb51 | |||
| 1de7649276 | |||
| 298c4f63d5 | |||
| 4b027c643a | |||
| 001742a856 | |||
| 8a39eb9ce7 | |||
| d5075813a2 | |||
| 8632601490 | |||
| 3b85f2cb57 | |||
| a577cec9a9 | |||
| 434859b606 | |||
| 5dfcbeaa23 | |||
| 4ffafc41c7 | |||
| ce9883f8c9 | |||
| 8165f75a78 | |||
| c6b542bd95 | |||
| a06f9cf2c4 | |||
| 3c9797af4a | |||
| c424a9cbef | |||
| 40483679de | |||
| c6228dfaab | |||
| 83bbf9d263 | |||
| 519c647f65 | |||
| 57fa4adf08 | |||
| 33e387d05c | |||
| 130c12a1d3 | |||
| 212484d062 | |||
| 7a92991cff | |||
| 4eda6d0f81 | |||
| 20096c1eec | |||
| df4435dd67 | |||
| 517cd23a55 | |||
| e67d8bdc0a | |||
| f56d6a79fb | |||
| 5ddde15fd3 | |||
| 1e376caecc | |||
| 97537a8717 | |||
| 6c07488964 | |||
| ebe1ede25c | |||
| 6885e39017 | |||
| fe10f25b8c | |||
| 9139a24093 | |||
| 123d86091d | |||
| f26223091a | |||
| bfc9661d22 | |||
| c1773e5c57 | |||
| 326521c9e6 | |||
| f6b6959268 | |||
| 3f7fdb1e15 | |||
| d40edf97e7 | |||
| 2148fd7a12 | |||
| 55480d4aa5 |
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
description: Основной промт/правила для проекта craftshop (client+server, FSD, ESLint/Prettier)
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Craftshop: постоянный промт для агента
|
||||||
|
|
||||||
|
## Контекст и цель
|
||||||
|
- Проект: магазин изделий ручной работы (витрина + админка для загрузки/редактирования данных).
|
||||||
|
- ОС: Windows. Отвечать пользователю **по-русски**.
|
||||||
|
|
||||||
|
## Стек и структура
|
||||||
|
- **Frontend**: Vite + React + TypeScript, axios, @tanstack/react-query, MUI.
|
||||||
|
- **Архитектура фронта**: **FSD** (`app/pages/widgets/features/entities/shared`), alias `@` → `client/src`.
|
||||||
|
- **Backend**: Node.js + Fastify + Prisma + SQLite.
|
||||||
|
- Данные управляются через фронтенд‑админку; доступ к админ‑API проверяется серверным `verifyAdmin` (JWT пользователя + совпадение `request.user.email` с `ADMIN_EMAIL`).
|
||||||
|
|
||||||
|
## Правила работы с кодом
|
||||||
|
- Всегда придерживаться **FSD границ**: нижние слои не импортируют верхние.
|
||||||
|
- Для запросов: использовать `apiClient` (axios) и **React Query** (queryKey стабильные, invalidate после мутаций).
|
||||||
|
- UI: использовать компоненты **MUI**, без “самописного” дизайна там, где есть готовые компоненты.
|
||||||
|
- Не добавлять зависимости без необходимости; если добавляешь — ставь последние стабильные версии и обновляй README при изменении запуска/скриптов.
|
||||||
|
|
||||||
|
## Качество и запуск
|
||||||
|
- После изменений на фронте: `client` → `npm run lint` и при необходимости `npm run lint:fix`, затем `npm run format:check`.
|
||||||
|
- Форматирование: Prettier конфиги лежат в `client/.prettierrc.json`, `.prettierignore`, `.editorconfig`.
|
||||||
|
|
||||||
|
## Бэкенд соглашения
|
||||||
|
- Не ломать публичные роуты `/api/categories`, `/api/products`.
|
||||||
|
- Админ‑роуты должны возвращать понятные ошибки (400/401/404/409) и валидировать входные данные.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
description: Актуальные требования к Vite proxy для локальной разработки
|
||||||
|
globs: client/vite.config.ts
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Frontend Dev Server Proxy
|
||||||
|
|
||||||
|
- В `client/vite.config.ts` должны проксироваться и API, и загрузки файлов.
|
||||||
|
- Обязательные прокси:
|
||||||
|
- `'/api' -> 'http://127.0.0.1:3333'`
|
||||||
|
- `'/uploads' -> 'http://127.0.0.1:3333'`
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
description: Правила использования RichTextMessageContent (TipTap) на фронтенде
|
||||||
|
globs: client/src/**/*.tsx
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Frontend Rich Text (TipTap)
|
||||||
|
|
||||||
|
- Для отображения rich text использовать общий компонент `shared/ui/RichTextMessageContent`.
|
||||||
|
- Не дублировать стили ProseMirror локально на страницах и в виджетах без необходимости.
|
||||||
|
- Для контекста отзывов передавать `tone="review"`.
|
||||||
|
- Для переписок по заказам передавать `tone="chat"`.
|
||||||
|
- `tone="default"` использовать только в нейтральных/общих сценариях.
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
name: Deploy
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ main ]
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Deploy
|
|
||||||
run: bash /opt/deploy-shop.sh server main
|
|
||||||
@@ -5,5 +5,13 @@ dist
|
|||||||
scripts/deploy.env
|
scripts/deploy.env
|
||||||
server/prisma/dev.db
|
server/prisma/dev.db
|
||||||
server/prisma/dev.db-journal
|
server/prisma/dev.db-journal
|
||||||
server/uploads/
|
.deployed-commit
|
||||||
|
|
||||||
|
# Image resize cache
|
||||||
uploads/.cache/
|
uploads/.cache/
|
||||||
|
|
||||||
|
# Server uploads directory (images)
|
||||||
|
server/uploads/
|
||||||
|
|
||||||
|
# Plans and design docs
|
||||||
|
.agents
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Spec: Image Processing Refactor
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Current image handling uses on-demand resize via `/uploads-resized/` route. Admin uploads save originals as-is (jpg/png/webp), and resize happens on first request. User uploads (reviews, 2MB limit) also use on-demand resize.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. **User images (reviews, ≤2MB):** Improve size error messages to be user-friendly
|
||||||
|
2. **Admin images (products, ≤20MB):** Eager processing at upload time
|
||||||
|
- Generate all resize widths (320, 640, 1024, 1600) in AVIF + WebP
|
||||||
|
- Convert original to WebP (delete source file)
|
||||||
|
- Full-screen viewer shows original in WebP (no width limit)
|
||||||
|
- Thumbnails use resized versions from cache
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Server Changes
|
||||||
|
|
||||||
|
#### 1. `server/src/lib/upload-images.js`
|
||||||
|
- Add `eager` parameter to `persistMultipartImages`
|
||||||
|
- When `eager: true`, after saving each file:
|
||||||
|
1. Call `generateAllSizes(uuid, subdir, fullPath)` — generates all sizes from original
|
||||||
|
2. Call `convertOriginalToWebp(uuid, subdir)` — converts original to WebP, deletes source
|
||||||
|
3. Update URL to use `.webp` extension (replace original extension)
|
||||||
|
|
||||||
|
#### 2. `server/src/lib/image-resize.js`
|
||||||
|
- Add `generateAllSizes(uuid, subdir, originalPath)`:
|
||||||
|
- For each width in [320, 640, 1024, 1600]:
|
||||||
|
- Generate AVIF and WebP in `.cache/<subdir>/`
|
||||||
|
- Uses original file path (before conversion to WebP)
|
||||||
|
- Add `convertOriginalToWebp(uuid, subdir)`:
|
||||||
|
- Find original file (jpg/png)
|
||||||
|
- Convert to WebP (quality 80) at same location with `.webp` extension
|
||||||
|
- Delete original jpg/png file
|
||||||
|
- Return new `.webp` path
|
||||||
|
|
||||||
|
#### 3. `server/src/routes/api/admin-products.js`
|
||||||
|
- Pass `eager: true` to `persistMultipartImages`
|
||||||
|
|
||||||
|
#### 4. `server/src/routes/api/public-reviews.js`
|
||||||
|
- Improve error message for file too large (413)
|
||||||
|
|
||||||
|
### Client Changes
|
||||||
|
|
||||||
|
#### 1. `client/src/entities/product/api/product-api.ts`
|
||||||
|
- Add pre-upload size check for review images
|
||||||
|
- Clear error message: "Файл «<name>» слишком большой (максимум 2 МБ)"
|
||||||
|
|
||||||
|
#### 2. `client/src/shared/ui/OptimizedImage.tsx`
|
||||||
|
- Update `buildSrcSet` to use cached AVIF/WebP directly
|
||||||
|
- Full-screen viewer: use original `.webp` URL (no `?w=`)
|
||||||
|
- Remove fallback to original format for upload URLs
|
||||||
|
|
||||||
|
#### 3. `client/src/features/product-review/ui/ReviewDialog.tsx`
|
||||||
|
- Show user-friendly error message for oversized files
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
### Admin Upload (Eager)
|
||||||
|
1. Client sends FormData to `POST /api/admin/uploads`
|
||||||
|
2. Server saves original (e.g., `uuid.jpg`)
|
||||||
|
3. Server generates all sizes in `.cache/` from original
|
||||||
|
4. Server converts original to WebP (`uuid.webp`), deletes `uuid.jpg`
|
||||||
|
5. Returns URLs with `.webp` extension (e.g., `/uploads/<uuid>.webp`)
|
||||||
|
6. Client displays using OptimizedImage with srcset from cache
|
||||||
|
|
||||||
|
### User Upload (Reviews)
|
||||||
|
1. Client validates file size ≤2MB before upload
|
||||||
|
2. Server validates and saves original
|
||||||
|
3. On-demand resize still works (existing flow)
|
||||||
|
4. Clear error messages at both client and server
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### User Upload Size Error
|
||||||
|
- **Client:** Pre-upload check with message "Файл «<name>» слишком большой (максимум 2 МБ)"
|
||||||
|
- **Server:** 413 with "Файл слишком большой (максимум 2 МБ)"
|
||||||
|
|
||||||
|
### Admin Upload Processing Error
|
||||||
|
- If sharp fails: return 500 with "Ошибка обработки изображения"
|
||||||
|
- If file not found after save: return 500 with "Внутренняя ошибка сервера"
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Server Tests
|
||||||
|
- Test `generateAllSizes` creates all width+format combinations
|
||||||
|
- Test `convertOriginalToWebp` converts and deletes original
|
||||||
|
- Test `persistMultipartImages` with `eager: true`
|
||||||
|
- Test error messages for oversized files
|
||||||
|
|
||||||
|
### Client Tests
|
||||||
|
- Test pre-upload size validation for reviews
|
||||||
|
- Test OptimizedImage srcset generation for WebP originals
|
||||||
|
- Test error message display in ReviewDialog
|
||||||
@@ -0,0 +1,543 @@
|
|||||||
|
# Image Processing Refactor Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Refactor image processing to use eager generation for admin product images and improve error messages for user uploads.
|
||||||
|
|
||||||
|
**Architecture:** Add eager processing functions to `image-resize.js`, integrate into `upload-images.js` via `eager` flag, update client-side validation and error handling.
|
||||||
|
|
||||||
|
**Tech Stack:** Node.js, Fastify, sharp, React, TypeScript, MUI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add eager processing functions to image-resize.js
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server/src/lib/image-resize.js`
|
||||||
|
- Test: `server/src/lib/__tests__/image-resize.test.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests for new functions**
|
||||||
|
|
||||||
|
Add to `server/src/lib/__tests__/image-resize.test.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { generateAllSizes, convertOriginalToWebp, findOriginalFile } from '../image-resize.js'
|
||||||
|
|
||||||
|
const TEST_UPLOADS_DIR = path.join(process.cwd(), 'uploads-test-eager')
|
||||||
|
const TEST_CACHE_DIR = path.join(TEST_UPLOADS_DIR, '.cache')
|
||||||
|
|
||||||
|
describe('eager image processing', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await fs.promises.mkdir(TEST_UPLOADS_DIR, { recursive: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await fs.promises.rm(TEST_UPLOADS_DIR, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generateAllSizes creates all width+format combinations', async () => {
|
||||||
|
// Create a test PNG image using sharp
|
||||||
|
const sharp = (await import('sharp')).default
|
||||||
|
const testImagePath = path.join(TEST_UPLOADS_DIR, 'test-uuid.png')
|
||||||
|
await sharp({ create: { width: 2000, height: 1500, channels: 3, background: { r: 255, g: 0, b: 0 } } })
|
||||||
|
.png()
|
||||||
|
.toFile(testImagePath)
|
||||||
|
|
||||||
|
await generateAllSizes('test-uuid', '', testImagePath)
|
||||||
|
|
||||||
|
// Check all cache files exist
|
||||||
|
for (const width of [320, 640, 1024, 1600]) {
|
||||||
|
for (const format of ['avif', 'webp']) {
|
||||||
|
const cachePath = path.join(TEST_CACHE_DIR, `test-uuid_w${width}.${format}`)
|
||||||
|
const exists = await fs.promises.access(cachePath).then(() => true).catch(() => false)
|
||||||
|
expect(exists).toBe(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('convertOriginalToWebp converts and deletes original', async () => {
|
||||||
|
const sharp = (await import('sharp')).default
|
||||||
|
const testImagePath = path.join(TEST_UPLOADS_DIR, 'test-uuid2.png')
|
||||||
|
await sharp({ create: { width: 800, height: 600, channels: 3, background: { r: 0, g: 255, b: 0 } } })
|
||||||
|
.png()
|
||||||
|
.toFile(testImagePath)
|
||||||
|
|
||||||
|
const result = await convertOriginalToWebp('test-uuid2', '')
|
||||||
|
|
||||||
|
expect(result).toBe('/uploads/test-uuid2.webp')
|
||||||
|
const pngExists = await fs.promises.access(testImagePath).then(() => true).catch(() => false)
|
||||||
|
expect(pngExists).toBe(false)
|
||||||
|
const webpPath = path.join(TEST_UPLOADS_DIR, 'test-uuid2.webp')
|
||||||
|
const webpExists = await fs.promises.access(webpPath).then(() => true).catch(() => false)
|
||||||
|
expect(webpExists).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `cd server && npm test -- --run image-resize.test.js`
|
||||||
|
Expected: FAIL — `generateAllSizes` and `convertOriginalToWebp` are not defined
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement generateAllSizes and convertOriginalToWebp**
|
||||||
|
|
||||||
|
Add to `server/src/lib/image-resize.js` before the final `export` line:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* Generate all resize widths in AVIF + WebP for eager processing.
|
||||||
|
* @param {string} uuid - UUID without extension
|
||||||
|
* @param {string} subdir - Subdirectory (e.g., 'reviews') or empty
|
||||||
|
* @param {string} originalPath - Full path to the original file
|
||||||
|
*/
|
||||||
|
export async function generateAllSizes(uuid, subdir, originalPath) {
|
||||||
|
const cacheSubdir = subdir ? subdir : ''
|
||||||
|
const cacheDir = path.join(CACHE_DIR, cacheSubdir)
|
||||||
|
await fs.promises.mkdir(cacheDir, { recursive: true })
|
||||||
|
|
||||||
|
const sharp = (await import('sharp')).default
|
||||||
|
|
||||||
|
for (const width of VALID_WIDTHS) {
|
||||||
|
for (const format of SUPPORTED_FORMATS) {
|
||||||
|
const cacheFileName = `${uuid}_w${width}.${format}`
|
||||||
|
const cachePath = path.join(CACHE_DIR, cacheSubdir, cacheFileName)
|
||||||
|
|
||||||
|
const pipeline = sharp(originalPath).resize(width, null, { withoutEnlargement: true })
|
||||||
|
const options = format === 'avif' ? { quality: 75, effort: 4 } : { quality: 80 }
|
||||||
|
await pipeline[format](options).toFile(cachePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert original file to WebP and delete the source file.
|
||||||
|
* @param {string} uuid - UUID without extension
|
||||||
|
* @param {string} subdir - Subdirectory (e.g., 'reviews') or empty
|
||||||
|
* @returns {string} New URL path like `/uploads/<uuid>.webp`
|
||||||
|
*/
|
||||||
|
export async function convertOriginalToWebp(uuid, subdir) {
|
||||||
|
const uploadsDir = path.join(process.cwd(), 'uploads')
|
||||||
|
const targetDir = subdir ? path.join(uploadsDir, subdir) : uploadsDir
|
||||||
|
|
||||||
|
// Find original file
|
||||||
|
const originalPath = await findOriginalFile(uuid, subdir)
|
||||||
|
if (!originalPath) {
|
||||||
|
throw new Error(`Original file not found for UUID: ${uuid}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalExt = path.extname(originalPath).toLowerCase()
|
||||||
|
const webpPath = path.join(targetDir, `${uuid}.webp`)
|
||||||
|
|
||||||
|
// Convert to WebP
|
||||||
|
const sharp = (await import('sharp')).default
|
||||||
|
await sharp(originalPath).webp({ quality: 80 }).toFile(webpPath)
|
||||||
|
|
||||||
|
// Delete original if it's not already WebP
|
||||||
|
if (originalExt !== '.webp') {
|
||||||
|
await fs.promises.unlink(originalPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return subdir ? `/uploads/${subdir}/${uuid}.webp` : `/uploads/${uuid}.webp`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `cd server && npm test -- --run image-resize.test.js`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server/src/lib/image-resize.js server/src/lib/__tests__/image-resize.test.js
|
||||||
|
git commit -m "feat: add eager image processing functions (generateAllSizes, convertOriginalToWebp)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Integrate eager processing into upload-images.js
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server/src/lib/upload-images.js`
|
||||||
|
- Test: `server/src/lib/__tests__/upload-images.test.js` (create if not exists)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test for eager mode**
|
||||||
|
|
||||||
|
Create `server/src/lib/__tests__/upload-images.test.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { persistMultipartImages, uploadError } from '../upload-images.js'
|
||||||
|
|
||||||
|
const TEST_UPLOADS_DIR = path.join(process.cwd(), 'uploads-test-persist')
|
||||||
|
|
||||||
|
describe('persistMultipartImages with eager mode', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await fs.promises.mkdir(TEST_UPLOADS_DIR, { recursive: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await fs.promises.rm(TEST_UPLOADS_DIR, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns WebP URLs when eager=true', async () => {
|
||||||
|
// This test verifies the function signature accepts eager parameter
|
||||||
|
// Full integration test requires mocking multipart request
|
||||||
|
// For now, test that the function doesn't throw with eager option
|
||||||
|
const mockRequest = {
|
||||||
|
isMultipart: () => true,
|
||||||
|
parts: async function* () {
|
||||||
|
// Mock part with a small PNG buffer
|
||||||
|
const pngHeader = Buffer.from([
|
||||||
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
|
||||||
|
...new Array(100).fill(0), // dummy data
|
||||||
|
])
|
||||||
|
yield {
|
||||||
|
file: true,
|
||||||
|
filename: 'test.png',
|
||||||
|
toBuffer: async () => pngHeader,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should not throw with eager option
|
||||||
|
try {
|
||||||
|
await persistMultipartImages(mockRequest, {
|
||||||
|
maxFiles: 1,
|
||||||
|
maxFileBytes: 20 * 1024 * 1024,
|
||||||
|
subdir: '',
|
||||||
|
eager: true,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
// If sharp is not available or PNG is invalid, that's expected in unit test
|
||||||
|
// The key is that the function accepts the eager parameter
|
||||||
|
expect(err.message).not.toContain('eager')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd server && npm test -- --run upload-images.test.js`
|
||||||
|
Expected: FAIL — `eager` parameter is not handled
|
||||||
|
|
||||||
|
- [ ] **Step 3: Modify persistMultipartImages to support eager mode**
|
||||||
|
|
||||||
|
Replace the `persistMultipartImages` function in `server/src/lib/upload-images.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export async function persistMultipartImages(request, { maxFiles = 10, maxFileBytes, subdir = '', eager = false }) {
|
||||||
|
if (!request.isMultipart()) {
|
||||||
|
throw uploadError('Ожидается multipart/form-data')
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadsDir = path.join(process.cwd(), 'uploads')
|
||||||
|
const targetDir = subdir ? path.join(uploadsDir, subdir) : uploadsDir
|
||||||
|
await fs.promises.mkdir(targetDir, { recursive: true })
|
||||||
|
|
||||||
|
const urls = []
|
||||||
|
const parts = request.parts({
|
||||||
|
limits: {
|
||||||
|
fileSize: maxFileBytes,
|
||||||
|
files: maxFiles,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
for await (const part of parts) {
|
||||||
|
if (!part.file) continue
|
||||||
|
if (urls.length >= maxFiles) {
|
||||||
|
throw uploadError(`Можно загрузить не более ${maxFiles} файл(ов)`)
|
||||||
|
}
|
||||||
|
const ext = safeImageExt(part.filename)
|
||||||
|
if (!ext) {
|
||||||
|
throw uploadError('Разрешены только файлы: png, jpg, jpeg, webp')
|
||||||
|
}
|
||||||
|
|
||||||
|
const uuid = crypto.randomUUID()
|
||||||
|
const fileName = `${uuid}${ext}`
|
||||||
|
const fullPath = path.join(targetDir, fileName)
|
||||||
|
await fs.promises.writeFile(fullPath, await part.toBuffer())
|
||||||
|
|
||||||
|
let finalUrl = subdir ? `/uploads/${subdir}/${fileName}` : `/uploads/${fileName}`
|
||||||
|
|
||||||
|
if (eager) {
|
||||||
|
const { generateAllSizes, convertOriginalToWebp } = await import('./image-resize.js')
|
||||||
|
await generateAllSizes(uuid, subdir, fullPath)
|
||||||
|
finalUrl = await convertOriginalToWebp(uuid, subdir)
|
||||||
|
}
|
||||||
|
|
||||||
|
urls.push(finalUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urls.length === 0) {
|
||||||
|
throw uploadError(
|
||||||
|
'Файлы не получены. Проверьте, что запрос multipart/form-data и поля — файлы изображений (png, jpg, webp).',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return urls
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd server && npm test -- --run upload-images.test.js`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server/src/lib/upload-images.js server/src/lib/__tests__/upload-images.test.js
|
||||||
|
git commit -m "feat: add eager mode to persistMultipartImages"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Enable eager mode in admin upload route
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server/src/routes/api/admin-products.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update admin upload route to use eager mode**
|
||||||
|
|
||||||
|
Modify the `POST /api/admin/uploads` route in `server/src/routes/api/admin-products.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
fastify.post(
|
||||||
|
'/api/admin/uploads',
|
||||||
|
{ preHandler: [fastify.verifyAdmin] },
|
||||||
|
async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const urls = await persistMultipartImages(request, {
|
||||||
|
maxFiles: 10,
|
||||||
|
maxFileBytes: getProductImageMaxFileBytes(),
|
||||||
|
eager: true,
|
||||||
|
})
|
||||||
|
await upsertGalleryImagesByUrls(urls)
|
||||||
|
return { urls }
|
||||||
|
} catch (error) {
|
||||||
|
let message = error instanceof Error ? error.message : 'Не удалось загрузить файлы'
|
||||||
|
let statusCode =
|
||||||
|
error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode)
|
||||||
|
? Number(error.statusCode)
|
||||||
|
: 400
|
||||||
|
if (isMultipartFileTooLargeError(error)) {
|
||||||
|
message = formatFileTooLargeMessage(getProductImageMaxFileBytes())
|
||||||
|
statusCode = 413
|
||||||
|
}
|
||||||
|
return reply.code(statusCode).send({ error: message })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add server/src/routes/api/admin-products.js
|
||||||
|
git commit -m "feat: enable eager image processing for admin uploads"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Improve user upload error messages
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `client/src/entities/product/api/reviews-api.ts`
|
||||||
|
- Modify: `client/src/shared/constants/upload-limits.ts`
|
||||||
|
- Modify: `client/src/features/product-review/ui/ReviewDialog.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add client-side size validation for review images**
|
||||||
|
|
||||||
|
Add to `client/src/shared/constants/upload-limits.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const OTHER_UPLOAD_MAX_FILE_BYTES = 2 * 1024 * 1024 // 2 MB
|
||||||
|
|
||||||
|
export function formatOtherUploadMaxSizeHint(): string {
|
||||||
|
return `${Math.round(OTHER_UPLOAD_MAX_FILE_BYTES / (1024 * 1024))} МБ`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add pre-upload size check in reviews-api.ts**
|
||||||
|
|
||||||
|
Modify `uploadReviewImage` in `client/src/entities/product/api/reviews-api.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { OTHER_UPLOAD_MAX_FILE_BYTES, formatOtherUploadMaxSizeHint } from '@/shared/constants/upload-limits'
|
||||||
|
|
||||||
|
export async function uploadReviewImage(file: File): Promise<{ url: string }> {
|
||||||
|
if (file.size > OTHER_UPLOAD_MAX_FILE_BYTES) {
|
||||||
|
throw new Error(
|
||||||
|
`Файл «${file.name}» слишком большой (максимум ${formatOtherUploadMaxSizeHint()}).`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file, file.name)
|
||||||
|
const { data } = await apiClient.post<{ url: string }>('reviews/upload-image', fd)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update ReviewDialog to show user-friendly error message**
|
||||||
|
|
||||||
|
Modify the uploadError display in `client/src/features/product-review/ui/ReviewDialog.tsx`:
|
||||||
|
|
||||||
|
Replace:
|
||||||
|
```tsx
|
||||||
|
{uploadError ? (
|
||||||
|
<Alert severity="error" sx={{ mt: 2 }}>
|
||||||
|
Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
```
|
||||||
|
|
||||||
|
With:
|
||||||
|
```tsx
|
||||||
|
{uploadError ? (
|
||||||
|
<Alert severity="error" sx={{ mt: 2 }}>
|
||||||
|
{uploadError instanceof Error ? uploadError.message : 'Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.'}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add client/src/shared/constants/upload-limits.ts client/src/entities/product/api/reviews-api.ts client/src/features/product-review/ui/ReviewDialog.tsx
|
||||||
|
git commit -m "feat: improve error messages for user upload size validation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Update OptimizedImage for WebP originals
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `client/src/shared/ui/OptimizedImage.tsx`
|
||||||
|
- Test: `client/src/shared/ui/__tests__/OptimizedImage.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update parseUploadUrl to handle .webp originals**
|
||||||
|
|
||||||
|
Modify `parseUploadUrl` in `client/src/shared/ui/OptimizedImage.tsx`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function parseUploadUrl(src: string): { uuid: string; ext: string; subdir: string } | null {
|
||||||
|
const match = src.match(/^\/uploads(?:\/(reviews))?\/([^.\\/]+)\.(png|jpe?g|webp)/i)
|
||||||
|
if (!match) return null
|
||||||
|
return { subdir: match[1] || '', uuid: match[2], ext: match[3].toLowerCase() }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update buildSrcSet to use cached AVIF/WebP directly**
|
||||||
|
|
||||||
|
Modify `buildSrcSet` and `buildFallbackSrc`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function buildSrcSet(src: string, widths: number[]): string | null {
|
||||||
|
const parsed = parseUploadUrl(src)
|
||||||
|
if (!parsed) return null
|
||||||
|
|
||||||
|
const pathPrefix = parsed.subdir ? `${parsed.subdir}/` : ''
|
||||||
|
return widths.map((w) => `/uploads-resized/${pathPrefix}${parsed.uuid}.avif?w=${w} ${w}w`).join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFallbackSrc(src: string, width: number): string {
|
||||||
|
const parsed = parseUploadUrl(src)
|
||||||
|
if (!parsed) return src
|
||||||
|
const pathPrefix = parsed.subdir ? `${parsed.subdir}/` : ''
|
||||||
|
return `/uploads-resized/${pathPrefix}${parsed.uuid}.webp?w=${width}`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add original WebP URL getter for full-screen mode**
|
||||||
|
|
||||||
|
Add to `client/src/shared/ui/OptimizedImage.tsx`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/** Get the original WebP URL for full-screen display (no resize) */
|
||||||
|
export function getOriginalWebpUrl(src: string): string {
|
||||||
|
const parsed = parseUploadUrl(src)
|
||||||
|
if (!parsed) return src
|
||||||
|
const pathPrefix = parsed.subdir ? `${parsed.subdir}/` : ''
|
||||||
|
return `/uploads/${pathPrefix}${parsed.uuid}.webp`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add client/src/shared/ui/OptimizedImage.tsx
|
||||||
|
git commit -m "feat: update OptimizedImage for WebP originals and add getOriginalWebpUrl"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Update ProductPage full-screen viewer
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `client/src/pages/product/ui/ProductPage.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Find full-screen image viewer code**
|
||||||
|
|
||||||
|
Search for the full-screen image viewer in ProductPage.tsx. Look for where the original image URL is used.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Use getOriginalWebpUrl for full-screen display**
|
||||||
|
|
||||||
|
Import and use `getOriginalWebpUrl`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getOriginalWebpUrl } from '@/shared/ui/OptimizedImage'
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the full-screen `<img>` src with:
|
||||||
|
```typescript
|
||||||
|
getOriginalWebpUrl(imageUrl)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add client/src/pages/product/ui/ProductPage.tsx
|
||||||
|
git commit -m "feat: use WebP original for full-screen product image viewer"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Run full test suite and lint
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run server tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server && npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run client lint and format check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client && npm run lint && npm run format:check
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run client tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client && npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run client build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit any fixes**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "fix: address lint and test issues"
|
||||||
|
```
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
# Design: Доработка товара — удаление «под заказ», обязательные quantity и категория
|
||||||
|
|
||||||
|
**Дата:** 2026-05-15
|
||||||
|
**Статус:** На согласовании
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Упростить модель товара: убрать концепцию «под заказ», сделать количество и категорию обязательными полями. Категория «Не указано» остаётся технической заглушкой для переноса товаров при удалении категории, но не видна в каталоге и не выбирается при редактировании.
|
||||||
|
|
||||||
|
## Архитектура изменений
|
||||||
|
|
||||||
|
### 1. База данных (Prisma)
|
||||||
|
|
||||||
|
**Миграция:**
|
||||||
|
- Перед удалением полей: все товары с `inStock = false` получают `quantity = 0`
|
||||||
|
- Удалить поля `inStock` и `leadTimeDays` из модели `Product`
|
||||||
|
- Статус наличия определяется исключительно по `quantity`:
|
||||||
|
- `quantity > 0` → «В наличии»
|
||||||
|
- `quantity = 0` → «Нет в наличии»
|
||||||
|
|
||||||
|
**`server/prisma/schema.prisma`:**
|
||||||
|
```prisma
|
||||||
|
model Product {
|
||||||
|
// ... остальные поля без изменений ...
|
||||||
|
quantity Int @default(0)
|
||||||
|
// УДАЛЕНО: inStock Boolean @default(true)
|
||||||
|
// УДАЛЕНО: leadTimeDays Int?
|
||||||
|
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict)
|
||||||
|
categoryId String
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Сервер — валидация и CRUD
|
||||||
|
|
||||||
|
**`server/src/routes/api/admin-products.js`:**
|
||||||
|
|
||||||
|
**CREATE (POST):**
|
||||||
|
- `quantity` — required, `Int >= 0` (было nullable)
|
||||||
|
- `categoryId` — required (было: при пустом → авто-назначение «Не указано»)
|
||||||
|
- Удалить валидацию `leadTimeDays` при `!inStock`
|
||||||
|
- Удалить принудительную установку `quantity = 1` для «под заказ»
|
||||||
|
- Вернуть 400: `'Укажите категорию'` если `categoryId` отсутствует
|
||||||
|
|
||||||
|
**UPDATE (PATCH):**
|
||||||
|
- `quantity` — required, `Int >= 0` (было nullable)
|
||||||
|
- `categoryId` — required (было: при пустом → «Не указано»)
|
||||||
|
- Удалить логику очистки `leadTimeDays` при `inStock = true`
|
||||||
|
- Удалить принудительную установку `quantity = 1`
|
||||||
|
- Вернуть 400 при отсутствии `categoryId`
|
||||||
|
|
||||||
|
**JSON Schema:**
|
||||||
|
- `CREATE_PRODUCT_SCHEMA`: убрать `leadTimeDays`, сделать `quantity` required (убрать `nullable`)
|
||||||
|
- `PATCH_PRODUCT_SCHEMA`: убрать `leadTimeDays`, `quantity` — если передан, то `>= 0`
|
||||||
|
|
||||||
|
**`server/src/routes/api/public-catalog.js`:**
|
||||||
|
- Удалить ветку `availability === 'in_stock'` и `availability === 'made_to_order'`
|
||||||
|
- Фильтрация «в наличии» больше не нужна — все товары в каталоге
|
||||||
|
|
||||||
|
### 3. Клиент — админка (две страницы)
|
||||||
|
|
||||||
|
**`client/src/pages/admin/ui/AdminPage.tsx`** и **`client/src/pages/admin-products/ui/AdminProductsPage.tsx`:**
|
||||||
|
|
||||||
|
**FormState:**
|
||||||
|
- Удалить `inStock: boolean` и `leadTimeDays: string`
|
||||||
|
- `quantity: string` — без nullable-семантики
|
||||||
|
|
||||||
|
**UI:**
|
||||||
|
- Удалить Switch «В наличии / Под заказ»
|
||||||
|
- Удалить TextField «Срок исполнения, дней»
|
||||||
|
- TextField «Количество»:
|
||||||
|
- Без helper «Оставьте пустым...»
|
||||||
|
- Новый helper: «0 = нет в наличии»
|
||||||
|
- Валидация: не может быть пустым, `parseInt >= 0`
|
||||||
|
- Select «Категория»:
|
||||||
|
- Удалить `<MenuItem value="">` с «Не указано»
|
||||||
|
- Валидация: не даёт сохранить без выбранной категории
|
||||||
|
- Показать ошибку при попытке сохранить без категории
|
||||||
|
|
||||||
|
**Submit-валидация:**
|
||||||
|
- Удалить проверку `leadTimeDays` при `!inStock`
|
||||||
|
- Добавить проверку: `categoryId` не пустой → blocking error
|
||||||
|
- Добавить проверку: `quantity` не пустой → blocking error
|
||||||
|
|
||||||
|
### 4. Клиент — каталог
|
||||||
|
|
||||||
|
**`client/src/entities/product/ui/ProductCard.tsx`:**
|
||||||
|
- Удалить логику `'Под заказ · {leadTimeDays} дн.'`
|
||||||
|
- Новый статус:
|
||||||
|
- `quantity > 0` → «В наличии» (зелёный)
|
||||||
|
- `quantity === 0` → «Нет в наличии» (серый/red)
|
||||||
|
|
||||||
|
**`client/src/pages/product/ui/ProductPage.tsx`:**
|
||||||
|
- Удалить chip `'Под заказ · {leadTimeDays} дн.'`
|
||||||
|
- Удалить alert `'Этот товар изготавливается под заказ...'`
|
||||||
|
- Статус определяется по `quantity`
|
||||||
|
|
||||||
|
**`client/src/pages/checkout/ui/CheckoutPage.tsx`:**
|
||||||
|
- Удалить определение made-to-order товаров в корзине
|
||||||
|
- Удалить info alert о доставке после изготовления
|
||||||
|
|
||||||
|
### 5. Клиент — фильтры
|
||||||
|
|
||||||
|
**`client/src/pages/home/lib/use-product-filters.ts`:**
|
||||||
|
- Удалить `availability: 'all' | 'in_stock' | 'made_to_order'` из state
|
||||||
|
- Удалить `availability` из параметров `fetchPublicProducts()`
|
||||||
|
|
||||||
|
**`client/src/pages/home/ui/ProductFilters.tsx`:**
|
||||||
|
- Удалить `ToggleButtonGroup` с `'all'`, `'in_stock'`, `'made_to_order'`
|
||||||
|
- Удалить отображение категории «Не указано» из списка чипов (фильтр `cat.slug !== 'ne-ukazano'`)
|
||||||
|
|
||||||
|
### 6. Категория «Не указано» — что остаётся
|
||||||
|
|
||||||
|
| Где | Что происходит |
|
||||||
|
|---|---|
|
||||||
|
| `server/src/lib/default-category.js` | **Остаётся** — функция `getOrCreateUnspecifiedCategory()` |
|
||||||
|
| `server/src/index.js` | **Остаётся** — вызов при старте |
|
||||||
|
| `server/src/routes/api/admin-categories.js` | **Остаётся** — нельзя удалить/переименовать; при удалении категории товары переезжают в «Не указано» |
|
||||||
|
| Админка категорий | **Остаётся** — кнопка удаления заблокирована |
|
||||||
|
| Фильтры каталога | **Скрыта** — не показывается в чипах |
|
||||||
|
| Форма товара | **Скрыта** — не выбирается в Select |
|
||||||
|
|
||||||
|
## Статус товара — новая логика
|
||||||
|
|
||||||
|
```
|
||||||
|
quantity > 0 → «В наличии» (зелёный chip/badge)
|
||||||
|
quantity = 0 → «Нет в наличии» (серый chip/badge)
|
||||||
|
```
|
||||||
|
|
||||||
|
Никаких других статусов. Поле `inStock` больше не существует.
|
||||||
|
|
||||||
|
## Файлы для изменения
|
||||||
|
|
||||||
|
### Сервер
|
||||||
|
| Файл | Изменения |
|
||||||
|
|---|---|
|
||||||
|
| `server/prisma/schema.prisma` | Удалить `inStock`, `leadTimeDays` |
|
||||||
|
| `server/src/routes/api/admin-products.js` | Валидация, schema, убрать логику под заказ |
|
||||||
|
| `server/src/routes/api/public-catalog.js` | Убрать фильтр availability |
|
||||||
|
|
||||||
|
### Клиент
|
||||||
|
| Файл | Изменения |
|
||||||
|
|---|---|
|
||||||
|
| `client/src/pages/admin/ui/AdminPage.tsx` | FormState, UI, валидация |
|
||||||
|
| `client/src/pages/admin-products/ui/AdminProductsPage.tsx` | FormState, UI, валидация |
|
||||||
|
| `client/src/entities/product/ui/ProductCard.tsx` | Статус по quantity |
|
||||||
|
| `client/src/pages/product/ui/ProductPage.tsx` | Убрать под заказ UI |
|
||||||
|
| `client/src/pages/checkout/ui/CheckoutPage.tsx` | Убрать made-to-order detection |
|
||||||
|
| `client/src/pages/home/ui/ProductFilters.tsx` | Убрать availability toggle, скрыть «Не указано» |
|
||||||
|
| `client/src/pages/home/lib/use-product-filters.ts` | Убрать `availability` |
|
||||||
|
|
||||||
|
## Миграция данных
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// В Prisma migration:
|
||||||
|
// 1. UPDATE Product SET quantity = 0 WHERE inStock = false
|
||||||
|
// 2. ALTER TABLE Product DROP COLUMN inStock
|
||||||
|
// 3. ALTER TABLE Product DROP COLUMN leadTimeDays
|
||||||
|
```
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
|
**Сервер:**
|
||||||
|
- CREATE без categoryId → 400
|
||||||
|
- CREATE без quantity → 400
|
||||||
|
- CREATE с quantity = 0 → OK
|
||||||
|
- PATCH без categoryId → 400
|
||||||
|
- PATCH с quantity = 0 → OK
|
||||||
|
|
||||||
|
**Клиент:**
|
||||||
|
- Форма не сохраняется без категории
|
||||||
|
- Форма не сохраняется без количества
|
||||||
|
- Фильтры не содержат «Под заказ» и «Не указано»
|
||||||
|
- Карточка товара показывает «Нет в наличии» при quantity = 0
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
# Приведение Политики конфиденциальности и Пользовательского соглашения в соответствие с проектом
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Убрать из юридических документов упоминания несуществующих функций (аналитика, рекламные рассылки, персонализация, Яндекс.Метрика) и исправить неточности (OAuth, cookie, IP-логирование, дублирование данных оператора).
|
||||||
|
|
||||||
|
**Architecture:** 4 задачи: унификация данных оператора в shared/config, правка Политики конфиденциальности, правка Пользовательского соглашения, финальная проверка.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript, React, MUI — изменения только в статическом JSX-тексте и shared/config.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Файловая структура изменений
|
||||||
|
|
||||||
|
| Файл | Что делаем |
|
||||||
|
| ---------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||||
|
| `client/src/shared/config/index.ts` | Добавляем `STORE_OP_NAME`, `STORE_OP_INN`, `STORE_OP_OGRN`, `STORE_OP_ADDR` |
|
||||||
|
| `client/src/pages/privacy-policy/ui/PrivacyPolicyPage.tsx` | Импорт из config, правка пунктов 2, 3, 5, 6, 7, добавляем дату |
|
||||||
|
| `client/src/pages/terms/ui/TermsPage.tsx` | Импорт из config, правка пунктов 1, 3, 7, 8, 9, убираем противоречие 6.1 vs 2.2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Вынести данные оператора в shared/config
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `client/src/shared/config/index.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Добавить константы оператора в shared/config**
|
||||||
|
|
||||||
|
Вставить после строки 17 (перед `export const VK_URL`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const STORE_OP_NAME =
|
||||||
|
"Индивидуальный предприниматель Новоселова Наталия Владимировна";
|
||||||
|
export const STORE_OP_INN = "402900832341";
|
||||||
|
export const STORE_OP_OGRN = "305402922700051";
|
||||||
|
export const STORE_OP_ADDR = "248000, Россия, г. Калуга, ул. Никитина, д. 12А";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Проверить синтаксис**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client && npx tsc -b --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Исправить Политику конфиденциальности
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `client/src/pages/privacy-policy/ui/PrivacyPolicyPage.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Заменить импорт и локальные константы**
|
||||||
|
|
||||||
|
Заменить строки 4-10 (импорт STORE_EMAIL + локальные константы).
|
||||||
|
|
||||||
|
Было:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { STORE_EMAIL } from "@/shared/config";
|
||||||
|
|
||||||
|
const OP_NAME =
|
||||||
|
"Индивидуальный предприниматель Новоселова Наталия Владимировна";
|
||||||
|
const OP_INN = "402900832341";
|
||||||
|
const OP_OGRN = "305402922700051";
|
||||||
|
const OP_ADDR = "248000, Россия, г. Калуга, ул. Никитина, д. 12А";
|
||||||
|
const SITE_URL = window.location.origin;
|
||||||
|
```
|
||||||
|
|
||||||
|
Стало:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
STORE_EMAIL,
|
||||||
|
STORE_OP_NAME,
|
||||||
|
STORE_OP_INN,
|
||||||
|
STORE_OP_OGRN,
|
||||||
|
STORE_OP_ADDR,
|
||||||
|
STORE_PUBLIC_SITE_URL,
|
||||||
|
} from "@/shared/config";
|
||||||
|
|
||||||
|
const SITE_URL =
|
||||||
|
STORE_PUBLIC_SITE_URL ||
|
||||||
|
(typeof window !== "undefined" ? window.location.origin : "");
|
||||||
|
```
|
||||||
|
|
||||||
|
И заменить `OP_NAME` → `STORE_OP_NAME`, `OP_INN` → `STORE_OP_INN`, `OP_OGRN` → `STORE_OP_OGRN`, `OP_ADDR` → `STORE_OP_ADDR` во всём файле (replaceAll).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Исправить раздел 2 — актуальный список собираемых данных**
|
||||||
|
|
||||||
|
Заменить `items` в секции 2.
|
||||||
|
|
||||||
|
Было:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
items: [
|
||||||
|
'2.1. Оператор обрабатывает следующие персональные данные Пользователей:',
|
||||||
|
'— фамилия, имя, отчество;',
|
||||||
|
'— адрес электронной почты;',
|
||||||
|
'— номер телефона;',
|
||||||
|
'— данные файлов cookie;',
|
||||||
|
'— данные о действиях на сайте (аналитика);',
|
||||||
|
'— адрес доставки и геолокационные координаты.',
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
Стало:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
items: [
|
||||||
|
'2.1. Оператор обрабатывает следующие персональные данные Пользователей:',
|
||||||
|
'— адрес электронной почты;',
|
||||||
|
'— имя (отображаемое имя, может быть указано Пользователем добровольно);',
|
||||||
|
'— номер телефона (указывается Пользователем добровольно при оформлении доставки);',
|
||||||
|
'— адрес доставки и геолокационные координаты (указываются Пользователем при оформлении заказа);',
|
||||||
|
'— аутентификационные данные (сессионные cookie для поддержания входа в Личный кабинет).',
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Исправить раздел 3 — убрать несуществующую персонализацию**
|
||||||
|
|
||||||
|
Было:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
items: [
|
||||||
|
'3.1. Оператор обрабатывает персональные данные в следующих целях:',
|
||||||
|
'— идентификация Пользователя;',
|
||||||
|
'— оказание услуг / продажа товаров;',
|
||||||
|
'— направление уведомлений и информационных сообщений;',
|
||||||
|
'— улучшение качества работы сайта;',
|
||||||
|
'— построение персонализированных предложений и рекомендаций.',
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
Стало:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
items: [
|
||||||
|
'3.1. Оператор обрабатывает персональные данные в следующих целях:',
|
||||||
|
'— идентификация и аутентификация Пользователя;',
|
||||||
|
'— оказание услуг / продажа товаров и оформление доставки;',
|
||||||
|
'— направление транзакционных уведомлений о статусе заказов и информационных сообщений;',
|
||||||
|
'— улучшение качества работы сайта.',
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Исправить раздел 5 — убрать неавтоматизированную обработку и нереалистичный срок**
|
||||||
|
|
||||||
|
Было:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
items: [
|
||||||
|
'5.1. Обработка осуществляется путём сбора, записи, систематизации, накопления, хранения, уточнения, извлечения, использования, передачи, обезличивания, блокирования, удаления и уничтожения персональных данных.',
|
||||||
|
'5.2. Обработка осуществляется автоматизированным и неавтоматизированным способами.',
|
||||||
|
'5.3. Срок хранения персональных данных: не более 7 лет с момента последнего обращения Пользователя либо до момента отзыва согласия на обработку.',
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
Стало:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
items: [
|
||||||
|
'5.1. Обработка осуществляется путём сбора, записи, систематизации, накопления, хранения, уточнения, извлечения, использования, передачи, блокирования, удаления и уничтожения персональных данных.',
|
||||||
|
'5.2. Обработка осуществляется автоматизированным способом с использованием программных средств Сайта.',
|
||||||
|
'5.3. Срок хранения персональных данных: до достижения целей обработки либо до момента отзыва Пользователем согласия на обработку.',
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Исправить раздел 6 — Яндекс.Метрика → ЮKassa**
|
||||||
|
|
||||||
|
Было:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
items: [
|
||||||
|
'6.1. Оператор может передать персональные данные третьим лицам в следующих случаях:',
|
||||||
|
'— с согласия субъекта;',
|
||||||
|
'— по требованию законодательства РФ;',
|
||||||
|
'— для выполнения договорных обязательств (перечень третьих лиц): службы доставки, платёжные агрегаторы, сервисы аналитики (Яндекс.Метрика).',
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
Стало:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
items: [
|
||||||
|
'6.1. Оператор может передать персональные данные третьим лицам в следующих случаях:',
|
||||||
|
'— с согласия субъекта;',
|
||||||
|
'— по требованию законодательства РФ;',
|
||||||
|
'— для выполнения договорных обязательств (перечень третьих лиц): службы доставки, платёжный сервис (ЮKassa).',
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Добавить дату обновления**
|
||||||
|
|
||||||
|
Заменить текст подзаголовка (строка 99):
|
||||||
|
|
||||||
|
```
|
||||||
|
Политика в отношении обработки персональных данных.
|
||||||
|
```
|
||||||
|
|
||||||
|
на:
|
||||||
|
|
||||||
|
```
|
||||||
|
Последнее обновление: 23 мая 2026 г.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Проверить линтер**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client && npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 0 новых ошибок.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Исправить Пользовательское соглашение
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `client/src/pages/terms/ui/TermsPage.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Заменить локальные константы на импорт из config**
|
||||||
|
|
||||||
|
Заменить строки 4-11.
|
||||||
|
|
||||||
|
Было:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
STORE_EMAIL,
|
||||||
|
STORE_PHONE,
|
||||||
|
STORE_PUBLIC_SITE_URL,
|
||||||
|
} from "@/shared/config";
|
||||||
|
|
||||||
|
const SITE_URL =
|
||||||
|
STORE_PUBLIC_SITE_URL ||
|
||||||
|
(typeof window !== "undefined" ? window.location.origin : "");
|
||||||
|
|
||||||
|
const OP_NAME =
|
||||||
|
"Индивидуальный предприниматель Новоселова Наталия Владимировна";
|
||||||
|
const OP_INN = "402900832341";
|
||||||
|
const OP_OGRN = "305402922700051";
|
||||||
|
const OP_ADDR = "248000, Россия, г. Калуга, ул. Никитина, д. 12А";
|
||||||
|
```
|
||||||
|
|
||||||
|
Стало:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
STORE_EMAIL,
|
||||||
|
STORE_PHONE,
|
||||||
|
STORE_PUBLIC_SITE_URL,
|
||||||
|
STORE_OP_NAME,
|
||||||
|
STORE_OP_INN,
|
||||||
|
STORE_OP_OGRN,
|
||||||
|
STORE_OP_ADDR,
|
||||||
|
} from "@/shared/config";
|
||||||
|
|
||||||
|
const SITE_URL =
|
||||||
|
STORE_PUBLIC_SITE_URL ||
|
||||||
|
(typeof window !== "undefined" ? window.location.origin : "");
|
||||||
|
```
|
||||||
|
|
||||||
|
Заменить `OP_NAME` → `STORE_OP_NAME`, `OP_INN` → `STORE_OP_INN`, `OP_OGRN` → `STORE_OP_OGRN`, `OP_ADDR` → `STORE_OP_ADDR` во всём файле (replaceAll).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Дополнить раздел 1 — упомянуть OAuth и вход по коду**
|
||||||
|
|
||||||
|
В секции 1, в определении «Аутентификационные данные». Найти:
|
||||||
|
|
||||||
|
```
|
||||||
|
'— Аутентификационные данные Пользователя — адрес электронной почты Пользователя и пароль (код доступа), которые в совокупности признаются простой электронной подписью Пользователя.',
|
||||||
|
```
|
||||||
|
|
||||||
|
Заменить на:
|
||||||
|
|
||||||
|
```
|
||||||
|
'— Аутентификационные данные Пользователя — адрес электронной почты и пароль (код доступа), либо данные, полученные через сервисы авторизации третьих лиц (VK ID, Яндекс ID), либо одноразовый код, направляемый на электронную почту. Совокупность аутентификационных данных признаётся простой электронной подписью Пользователя.',
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Убрать «рекламные» сообщения из п. 3.7**
|
||||||
|
|
||||||
|
Найти:
|
||||||
|
|
||||||
|
```
|
||||||
|
'3.7. При регистрации Пользователь даёт согласие на получение информационных и рекламных сообщений от Администратора на указанный адрес электронной почты.',
|
||||||
|
```
|
||||||
|
|
||||||
|
Заменить на:
|
||||||
|
|
||||||
|
```
|
||||||
|
'3.7. При регистрации Пользователь даёт согласие на получение транзакционных уведомлений (статус заказа, сообщения в чате заказа, статус оплаты) на указанный адрес электронной почты.',
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Исправить противоречие 6.1 vs 2.2 («гарантирует» vs «as is»)**
|
||||||
|
|
||||||
|
Найти в секции 6:
|
||||||
|
|
||||||
|
```
|
||||||
|
'6.1. Администратор гарантирует достоверность и полноту только той информации, которую он разместил на Сайте самостоятельно.',
|
||||||
|
```
|
||||||
|
|
||||||
|
Заменить на:
|
||||||
|
|
||||||
|
```
|
||||||
|
'6.1. Администратор прилагает разумные усилия для обеспечения достоверности и полноты информации, размещённой на Сайте, однако не даёт явных гарантий точности такой информации.',
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Исправить раздел 7 — указать реальных третьих лиц (ЮKassa, OSM вместо рекламы/аналитики)**
|
||||||
|
|
||||||
|
Заменить всю секцию 7.
|
||||||
|
|
||||||
|
Было:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
title: '7. Доступ к ресурсам третьих лиц',
|
||||||
|
items: [
|
||||||
|
'7.1. Доступ Пользователя к Сайту может вызывать обращение к интернет-ресурсам третьих лиц (реклама, сбор статистики).',
|
||||||
|
'7.2. Владельцы таких ресурсов имеют техническую возможность собирать информацию о Пользователях и самостоятельно определяют условия её использования.',
|
||||||
|
'7.3. При переходе на сторонние ресурсы Пользователи самостоятельно определяют пределы использования своей информации согласно правилам соответствующих ресурсов.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
Стало:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
title: '7. Доступ к ресурсам третьих лиц',
|
||||||
|
items: [
|
||||||
|
'7.1. Для обеспечения функциональности Сайта используются сервисы третьих лиц: платёжный сервис ЮKassa (для обработки онлайн-платежей).
|
||||||
|
'7.2. Владельцы указанных ресурсов имеют собственную политику конфиденциальности и самостоятельно определяют условия обработки получаемой информации.',
|
||||||
|
'7.3. При переходе на сторонние ресурсы Пользователи самостоятельно определяют пределы использования своей информации согласно правилам соответствующих ресурсов.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Исправить раздел 8 — Cookie только для сессии, не для аналитики**
|
||||||
|
|
||||||
|
Заменить всю секцию 8.
|
||||||
|
|
||||||
|
Было:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
title: '8. Информация, хранящаяся на стороне браузера',
|
||||||
|
items: [
|
||||||
|
'8.1. Администратор использует cookie-файлы для определения уникального идентификатора доступа Пользователя к Сайту.',
|
||||||
|
'8.2. Цели использования cookie:',
|
||||||
|
'— поддержка функциональности Сайта, требующей использования cookie;',
|
||||||
|
'— измерение аудитории Сайта;',
|
||||||
|
'— определение статистических предпочтений Пользователей;',
|
||||||
|
'— исследование корреляции статистических данных.',
|
||||||
|
'8.3. Пользователь может запретить использование cookie в настройках браузера, однако это может привести к частичной или полной потере функциональности Сайта.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
Стало:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
title: '8. Информация, хранящаяся на стороне браузера',
|
||||||
|
items: [
|
||||||
|
'8.1. Администратор использует сессионные cookie-файлы исключительно для поддержания аутентификации Пользователя в Личном кабинете.',
|
||||||
|
'8.2. Сайт не использует cookie для сбора статистики, отслеживания действий Пользователя или показа рекламы.',
|
||||||
|
'8.3. Пользователь может запретить использование cookie в настройках браузера, однако это приведёт к невозможности входа в Личный кабинет и использования функций, требующих аутентификации.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Исправить раздел 9.3–9.4 — актуальный перечень данных и целей**
|
||||||
|
|
||||||
|
Заменить строки 9.3 и 9.4 в секции 9.
|
||||||
|
|
||||||
|
Было:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
'9.3. Администратор обрабатывает следующие персональные данные: Ф. И. О., адрес электронной почты, номер телефона, IP-адрес, тип браузера, данные о действиях на Сайте.',
|
||||||
|
'9.4. Цели обработки персональных данных: обеспечение функционирования Сайта, оказание информационной поддержки, предоставление персонализированных сервисов, направление информационных сообщений.',
|
||||||
|
```
|
||||||
|
|
||||||
|
Стало:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
'9.3. Администратор обрабатывает следующие персональные данные: адрес электронной почты, имя (при добровольном указании), номер телефона (при оформлении доставки), адрес доставки.',
|
||||||
|
'9.4. Цели обработки персональных данных: обеспечение функционирования Сайта, аутентификация Пользователя, оформление и доставка заказов, направление транзакционных уведомлений.',
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Проверить линтер**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client && npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 0 новых ошибок.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Финальная проверка
|
||||||
|
|
||||||
|
**Files:** No modifications, verification only.
|
||||||
|
|
||||||
|
- [ ] **Step 1: TypeScript check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client && npx tsc -b --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no errors.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Lint**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client && npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 0 errors (warnings OK).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Сборка**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: успешная сборка.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Format check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client && npm run format:check
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: все файлы отформатированы (или отформатировать через `npm run format`).
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Юридические документы: приведение к реальности + удаление аккаунта + cookie-баннер
|
||||||
|
|
||||||
|
## Данные оператора
|
||||||
|
|
||||||
|
- **Имя:** Комарова Лариса Николаевна (самозанятый)
|
||||||
|
- **ИНН:** 402900832341 (тестовый)
|
||||||
|
- **Адрес:** 34, ул. Мира, кв. 34, Лысьва, Пермский край, 618909
|
||||||
|
- **Сайт:** https://любимыйкреатив.рф
|
||||||
|
- **ОГРН:** отсутствует (самозанятый)
|
||||||
|
|
||||||
|
## Задачи
|
||||||
|
|
||||||
|
### 1. Shared config — обновить данные оператора
|
||||||
|
Файл: `client/src/shared/config/index.ts`
|
||||||
|
- `STORE_OP_NAME`, `STORE_OP_TYPE`, `STORE_OP_INN` (тестовый), `STORE_OP_ADDR`
|
||||||
|
- Убрать `STORE_OP_OGRN`
|
||||||
|
- `STORE_PUBLIC_SITE_URL`: https://любимыйкреатив.рф
|
||||||
|
|
||||||
|
### 2. Политика конфиденциальности
|
||||||
|
Файл: `client/src/pages/privacy-policy/ui/PrivacyPolicyPage.tsx`
|
||||||
|
- Импорт из config, убрать локальные константы
|
||||||
|
- Оператор: самозанятый вместо ИП, без ОГРН
|
||||||
|
- Раздел 2: только email, имя, телефон (при доставке), адрес, сессионные cookie (без ФИО, аналитики)
|
||||||
|
- Раздел 3: без персонализации
|
||||||
|
- Раздел 5: только автообработка, срок до достижения целей
|
||||||
|
- Раздел 6: Яндекс.Метрика → ЮKassa
|
||||||
|
- Раздел 7: добавить право на самоудаление (п. 7.3)
|
||||||
|
- Добавить дату обновления
|
||||||
|
- Перенести cookie-раздел из Соглашения в Политику
|
||||||
|
|
||||||
|
### 3. Пользовательское соглашение
|
||||||
|
Файл: `client/src/pages/terms/ui/TermsPage.tsx`
|
||||||
|
- Импорт из config, убрать локальные константы
|
||||||
|
- Оператор: самозанятый, без ОГРН
|
||||||
|
- Раздел 1: упомянуть OAuth и вход по коду
|
||||||
|
- П. 3.7: «рекламные» → «транзакционные»
|
||||||
|
- П. 6.1: убрать противоречие с «as is»
|
||||||
|
- Раздел 7: реальные третьи лица (ЮKassa, OSM)
|
||||||
|
- Раздел 8: cookie только для сессии, не для аналитики
|
||||||
|
- П. 9.3-9.4: без IP/браузера/персонализации, только реальные данные
|
||||||
|
|
||||||
|
### 4. Cookie-баннер
|
||||||
|
Новый компонент: `client/src/shared/ui/CookieConsentBanner.tsx`
|
||||||
|
- Снизу, фиксированный, localStorage
|
||||||
|
- Текст о cookie и ссылка на Политику
|
||||||
|
- Кнопка «Понятно»
|
||||||
|
- Рендер в MainLayout перед футером
|
||||||
|
|
||||||
|
### 5. Текст согласия на формах входа/регистрации
|
||||||
|
- AuthPasswordForm, AuthCodeForm
|
||||||
|
- Под кнопкой отправки: «Нажимая «Продолжить», вы принимаете пользовательское соглашение и политику конфиденциальности»
|
||||||
|
- Ссылки на /terms и /privacy
|
||||||
|
|
||||||
|
### 6. Удаление аккаунта
|
||||||
|
**Сервер:** `DELETE /api/me` в `server/src/routes/auth.js`
|
||||||
|
- Проверка активных заказов (не DONE, не CANCELLED)
|
||||||
|
- Если нет активных — каскадное удаление
|
||||||
|
- Если есть — 400 с перечнем заказов
|
||||||
|
|
||||||
|
**Клиент:** секция в SettingsPage
|
||||||
|
- Кнопка «Удалить аккаунт» (outlined, error)
|
||||||
|
- Tooltip при активных заказах
|
||||||
|
- Диалог подтверждения
|
||||||
|
- После удаления — редирект на /
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
12063
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
12189
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
12688
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
12844
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
12996
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
13143
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1476
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1531
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1616
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1702
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
5700
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"reason":"idle timeout","timestamp":1779612416287}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
7319
|
||||||
@@ -1,48 +1,83 @@
|
|||||||
# AGENTS.md — shop-server
|
# AGENTS.md — shop (craftshop monorepo)
|
||||||
|
|
||||||
## Project structure
|
## Project structure
|
||||||
|
|
||||||
- `server/` — Fastify + Prisma + SQLite backend
|
- `client/` — frontend (React + Vite + TypeScript + MUI), **FSD architecture**: `app/pages/widgets/features/entities/shared`
|
||||||
- `shared/constants/` — JS + .d.ts shared with client (order statuses, delivery carriers, payment methods, upload limits)
|
- `server/` — backend (Fastify + Prisma + SQLite)
|
||||||
|
- `shared/constants/` — JS + `.d.ts` files shared between client and server (order statuses, delivery carriers, payment methods, upload limits)
|
||||||
|
|
||||||
## Developer commands
|
## Developer commands
|
||||||
|
|
||||||
|
### Client (`cd client`)
|
||||||
|
|
||||||
| Command | What it does |
|
| Command | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `npm run dev` | node --env-file=.env --watch src/index.js (requires Node 20.6+) |
|
| `npm run dev` | Vite dev server on `:5173`, proxies `/api` and `/uploads` to `http://127.0.0.1:3333` |
|
||||||
| `npm run dev:classic` | node --watch src/index.js (loads .env via dotenv) |
|
| `npm run build` | Runs `tsc -b` first, then `vite build` |
|
||||||
| `npm run lint` | ESLint (flat config) |
|
| `npm run lint` | ESLint (flat config) |
|
||||||
| `npm run lint:fix` | ESLint with --fix |
|
| `npm run lint:fix` | ESLint with `--fix` |
|
||||||
| `npm run format` | Prettier write all |
|
| `npm run format` | Prettier write all |
|
||||||
| `npm run format:check` | Prettier check only |
|
| `npm run format:check` | Prettier check only |
|
||||||
| `npm test` | vitest run |
|
| `npm test` | vitest run |
|
||||||
| `npm run db:reset:test` | Reset SQLite DB + re-run migrations + seed (uses .env) |
|
| `npm run test:watch` | vitest watch mode |
|
||||||
|
|
||||||
|
### Server (`cd server`)
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `npm run dev` | `node --env-file=.env --watch src/index.js` (requires Node 20.6+) |
|
||||||
|
| `npm run dev:classic` | `node --watch src/index.js` (loads `.env` via dotenv) |
|
||||||
|
| `npm run lint` | ESLint (flat config) |
|
||||||
|
| `npm run lint:fix` | ESLint with `--fix` |
|
||||||
|
| `npm run format` | Prettier write all |
|
||||||
|
| `npm run format:check` | Prettier check only |
|
||||||
|
| `npm test` | vitest run |
|
||||||
|
| `npm run db:reset:test` | Reset SQLite DB + re-run migrations + seed (uses `.env`) |
|
||||||
|
|
||||||
|
### Build order (when changing both packages)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server && npm run db:migrate # if schema changed
|
||||||
|
cd server && npm test # server tests first
|
||||||
|
cd client && npm run lint && npm run format:check && npm test # then client
|
||||||
|
cd client && npm run build # full typecheck + build
|
||||||
|
```
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- **Language**: Отвечай пользователю на русском.
|
- **Language**: Отвечай пользователю **на русском**.
|
||||||
- **Single quotes**, no semicolons, trailing commas, 120 print width (Prettier + ESLint enforce).
|
- **Single quotes**, no semicolons, trailing commas, 120 print width (Prettier + ESLint enforce).
|
||||||
- **Alias**: @shared → shared/ (configured in vitest.config.js for tests).
|
- **FSD import boundaries** enforced by `eslint-plugin-boundaries`. Lower layers cannot import upper layers. If ESLint complains about an import, the architecture is wrong.
|
||||||
- **Admin access**: Only users with email matching ADMIN_EMAIL env var can access admin routes. Server auto-creates the admin user on startup.
|
- **Aliases**: `@/` → `client/src/`, `@shared/` → `shared/` (configured in both vite.config.ts and tsconfig).
|
||||||
- **Server helpers**: slugify, parseMaterialsInput, mapProductForApi are decorated on fastify instance, accessed via request.server.*.
|
- **API requests**: Use `apiClient` (axios wrapper from `shared/api/`) with `@tanstack/react-query`. Invalidate queries after mutations.
|
||||||
|
- **UI**: Prefer MUI components over custom HTML/CSS.
|
||||||
|
- **`no-console`**: ESLint error; use `console.warn/error/info` only.
|
||||||
|
- **Admin access**: Only users with email matching `ADMIN_EMAIL` env var can access admin routes. Server auto-creates the admin user on startup.
|
||||||
|
- **Server helpers**: `slugify`, `parseMaterialsInput`, `mapProductForApi` are decorated on fastify instance, accessed via `request.server.*`.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
- Vitest with globals enabled.
|
- **Client**: vitest + jsdom + @testing-library/react. Setup file: `client/src/testing/setup.ts`.
|
||||||
- Test files live in __tests__/ directories next to the code they test.
|
- **Server**: vitest with globals enabled.
|
||||||
|
- Test files live in `__tests__/` directories next to the code they test.
|
||||||
|
|
||||||
## OAuth
|
## OAuth
|
||||||
|
|
||||||
- VK callback: {SERVER_PUBLIC_URL}/api/auth/oauth/vk/callback
|
- VK callback: `{SERVER_PUBLIC_URL}/api/auth/oauth/vk/callback`
|
||||||
- Yandex callback: {SERVER_PUBLIC_URL}/api/auth/oauth/yandex/callback
|
- Yandex callback: `{SERVER_PUBLIC_URL}/api/auth/oauth/yandex/callback`
|
||||||
|
- Required env vars: `VK_CLIENT_ID`, `VK_CLIENT_SECRET`, `YANDEX_CLIENT_ID`, `YANDEX_CLIENT_SECRET`, `SERVER_PUBLIC_URL`, `CLIENT_PUBLIC_URL`
|
||||||
|
|
||||||
## Deployment
|
## Infrastructure (deployment)
|
||||||
|
|
||||||
- Gitea CI/CD deploys to the server machine on push to main
|
- **VPS** runs Nginx Proxy Manager (NPM), connected via Netbird peer-to-peer VPN to the dev machine
|
||||||
- Traffic flow: Browser → Domain → Nginx (server machine) → Fastify (3333)
|
- **Local dev machine** runs the project (server + client), also a Netbird peer
|
||||||
- trustProxy: true on Fastify
|
- **Traffic flow**: Browser → Domain (A record → VPS IP) → NPM → Netbird tunnel → Local dev machine (`server:3333`)
|
||||||
|
- NPM manages SSL, domains, and proxy hosts
|
||||||
|
- `trustProxy: true` on Fastify — `request.ip` works correctly through NPM/Netbird chain
|
||||||
|
|
||||||
## Notable quirks
|
## Notable quirks
|
||||||
|
|
||||||
- .env is gitignored. Copy .env.example to .env for local dev.
|
- `.env` is gitignored. Copy `.env.example` to `.env` for local dev.
|
||||||
- db:reset:test runs prisma migrate reset --force, which destroys all data.
|
- Vite dev server (client) relies on backend running at `127.0.0.1:3333`. Start server first.
|
||||||
|
- Rich text rendering uses `shared/ui/RichTextMessageContent` (TipTap). Pass `tone="review"`, `tone="chat"`, or `tone="default"`.
|
||||||
|
- `db:reset:test` runs `prisma migrate reset --force`, which destroys all data.
|
||||||
|
|||||||
@@ -1 +1,145 @@
|
|||||||
# Shop Server\n\nCraftshop API server\n\n## Deploy\nAuto-deploy via Gitea Actions on push to main
|
# Магазин изделий ручной работы https://любимыйкреатив.рф
|
||||||
|
|
||||||
|
Цель проекта — витрина и админка для магазина изделий ручного труда (игрушки, сувениры и т.п.) с простой загрузкой/редактированием данных через фронтенд‑админку.
|
||||||
|
|
||||||
|
Проект сделан как **монорепозиторий**:
|
||||||
|
|
||||||
|
- `client/` — фронтенд (витрина + админка)
|
||||||
|
- `server/` — бэкенд API + БД
|
||||||
|
|
||||||
|
## Стек
|
||||||
|
|
||||||
|
### Фронтенд
|
||||||
|
|
||||||
|
- **React** + **Vite**
|
||||||
|
- **axios**
|
||||||
|
- **@tanstack/react-query**
|
||||||
|
- **MUI (@mui/material)** + emotion
|
||||||
|
- **React Router**
|
||||||
|
- **Архитектура**: **FSD (Feature-Sliced Design)** — слои `app/pages/widgets/features/entities/shared`
|
||||||
|
- **Качество**: ESLint (flat config) + Prettier, границы FSD (`eslint-plugin-boundaries`)
|
||||||
|
|
||||||
|
### Бэкенд
|
||||||
|
|
||||||
|
- **Node.js**
|
||||||
|
- **Fastify** (+ CORS)
|
||||||
|
- **Prisma** (миграции)
|
||||||
|
- **SQLite** (локальная БД; легко сменить на Postgres через `DATABASE_URL`)
|
||||||
|
|
||||||
|
## Основные подходы и договорённости
|
||||||
|
|
||||||
|
### FSD на фронте
|
||||||
|
|
||||||
|
- Импорты между слоями ограничены правилами `boundaries` (например `features` может импортировать `entities/shared`, но не наоборот).
|
||||||
|
- Alias `@` указывает на `client/src` (см. `client/vite.config.ts` и `client/tsconfig.app.json`).
|
||||||
|
|
||||||
|
### Данные и админка
|
||||||
|
|
||||||
|
- Данные загружаются/редактируются через **админку на фронте**.
|
||||||
|
- Админ‑роуты бэкенда доступны только авторизованному пользователю с email из `ADMIN_EMAIL` в `server/.env`.
|
||||||
|
|
||||||
|
### Форматирование и линтинг (client)
|
||||||
|
|
||||||
|
- Prettier конфиг: `client/.prettierrc.json`
|
||||||
|
- Ignore: `client/.prettierignore`
|
||||||
|
- EditorConfig: `client/.editorconfig`
|
||||||
|
- Команды:
|
||||||
|
- `npm run lint` / `npm run lint:fix`
|
||||||
|
- `npm run format` / `npm run format:check`
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
### Бэкенд
|
||||||
|
|
||||||
|
**Вариант A — типовой `.env`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
cp .env.example .env # укажите ADMIN_EMAIL
|
||||||
|
npm install
|
||||||
|
npx prisma migrate dev # если база ещё не создана
|
||||||
|
npx prisma db seed # опционально: тестовые категории и товары
|
||||||
|
npm run dev:classic # загрузка из `.env`
|
||||||
|
```
|
||||||
|
|
||||||
|
**Вариант B — `.env` файл** (нужен **Node.js 20.6+** из‑за `node --env-file`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
cp .env.example .env # укажите ADMIN_EMAIL и другие настройки
|
||||||
|
npm install
|
||||||
|
npm run dev # переменные из `.env`
|
||||||
|
```
|
||||||
|
|
||||||
|
Очистка БД до «чистого» тестового состояния (SQLite + миграции + seed): в `server/` выполните `npm run db:reset:test`.
|
||||||
|
|
||||||
|
Сервер: `http://127.0.0.1:3333`. Проверка: `GET /health`.
|
||||||
|
|
||||||
|
### Фронтенд
|
||||||
|
|
||||||
|
В другом терминале:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Откройте `http://localhost:5173`. Запросы к `/api` проксируются на бэкенд (см. `client/vite.config.ts`).
|
||||||
|
|
||||||
|
## Админка
|
||||||
|
|
||||||
|
Раздел админки доступен только по прямой ссылке `/admin` и только для пользователя с email из `ADMIN_EMAIL`. Если такого пользователя нет в БД, сервер создаёт его автоматически при старте.
|
||||||
|
|
||||||
|
Для боевого размещения фронта и API на разных доменах задайте `VITE_API_URL` (например `https://api.example.com/api`) и **CORS_ORIGIN** на сервере.
|
||||||
|
|
||||||
|
### OAuth VK и Яндекс
|
||||||
|
|
||||||
|
В `server/.env` задайте `VK_CLIENT_ID`, `VK_CLIENT_SECRET`, `YANDEX_CLIENT_ID`, `YANDEX_CLIENT_SECRET`, а также **точные** публичные адреса:
|
||||||
|
|
||||||
|
- `SERVER_PUBLIC_URL` — базовый URL API (без завершающего `/`), например `https://api.example.com` или `http://127.0.0.1:3333`.
|
||||||
|
- `CLIENT_PUBLIC_URL` — базовый URL витрины, куда бэкенд редиректит после входа с JWT в query: `/auth/callback?token=...`, например `http://127.0.0.1:5173`.
|
||||||
|
|
||||||
|
**Redirect URI в кабинетах провайдеров** (должны совпадать с тем, что шлёт сервер при авторизации):
|
||||||
|
|
||||||
|
- VK: `{SERVER_PUBLIC_URL}/api/auth/oauth/vk/callback`
|
||||||
|
- Яндекс: `{SERVER_PUBLIC_URL}/api/auth/oauth/yandex/callback`
|
||||||
|
|
||||||
|
Старт входа с витрины: кнопки на странице `/auth` ведут на `GET /api/auth/oauth/vk` и `GET /api/auth/oauth/yandex` (полный URL — тот же origin, что и API: при прокси Vite это `/api/...` относительно фронта; при отдельном домене API — из `VITE_API_URL`).
|
||||||
|
|
||||||
|
### Футер витрины (опционально)
|
||||||
|
|
||||||
|
В `client/.env` можно задать `VITE_STORE_EMAIL`, `VITE_STORE_PHONE`, `VITE_STORE_SOCIAL_NOTE` для блока контактов в подвале. Для страницы «Политика конфиденциальности» задайте **`VITE_PUBLIC_SITE_URL`** (например `https://example.com`, без завершающего `/`) — иначе в dev подставится текущий origin; на проде лучше указать явно перед `npm run build`.
|
||||||
|
|
||||||
|
## API (кратко)
|
||||||
|
|
||||||
|
Публичные:
|
||||||
|
|
||||||
|
- `GET /api/categories`
|
||||||
|
- `GET /api/products?categorySlug=...`
|
||||||
|
|
||||||
|
Админ:
|
||||||
|
|
||||||
|
- `GET /api/admin/products`
|
||||||
|
- `POST /api/admin/products`
|
||||||
|
- `PATCH /api/admin/products/:id`
|
||||||
|
- `DELETE /api/admin/products/:id`
|
||||||
|
- `POST /api/admin/categories`
|
||||||
|
|
||||||
|
## Деплой
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Заполнить scripts/deploy.env (DEPLOY_HOST, DEPLOY_PATH и т.д.)
|
||||||
|
|
||||||
|
# Первичная настройка LXC: см. scripts/SERVER_SETUP.md
|
||||||
|
|
||||||
|
# Деплой только изменившихся компонентов:
|
||||||
|
./scripts/deploy-auto.sh
|
||||||
|
|
||||||
|
# Полный деплой (игнорировать diff):
|
||||||
|
./scripts/deploy-auto.sh --force
|
||||||
|
|
||||||
|
# Только фронт или только бэкенд:
|
||||||
|
./scripts/deploy-auto.sh --frontend-only
|
||||||
|
./scripts/deploy-auto.sh --backend-only
|
||||||
|
```
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# План рефакторинга shop
|
||||||
|
|
||||||
|
> Составлен на основе анализа кода и правил `.cursor/rules`.
|
||||||
|
> Дата: 2026-05-13
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Статус выполнения
|
||||||
|
|
||||||
|
- ✅ 1.1 Сервер: разбить `routes/auth.js` → 6 модулей
|
||||||
|
- ✅ 1.2 Клиент: разбить `AdminPage.tsx` → `AdminProductsPage` + `AdminCategoriesPage`
|
||||||
|
- ✅ 1.3 Клиент: разбить `OrderDetailPage.tsx` (чаты, оплата, отзывы → features)
|
||||||
|
- ✅ 2.2 FSD: роутинг из `App.tsx` → `app/routes/index.tsx`
|
||||||
|
- ✅ 3.1 Клиент: разбить `AppHeader.tsx` (UserMenu, CartBadge, NavigationDrawer)
|
||||||
|
- ✅ 4.1 Effector: рефакторинг `auth.ts` (persist, sample, createErrorStore)
|
||||||
|
- ✅ 2.1 Недостающие сегменты FSD (catalog-slider, gallery, info, address-map-picker)
|
||||||
|
- ✅ 2.3 Дублирование констант клиент/сервер
|
||||||
|
- ✅ 3.2 HomePage (вынесены фильтры в хук и компонент)
|
||||||
|
- ✅ 3.3 AdminOrdersPage, AdminUsersPage (shared AdminDialog + AdminTable)
|
||||||
|
- ✅ 5.1 fastify.decorate вместо параметров
|
||||||
|
- ✅ 5.2 Валидация через Fastify Schema
|
||||||
|
- ✅ 6.1 Error Boundary
|
||||||
|
- ✅ 6.2 Тесты
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Критические точки (высокий приоритет) — ✅ Выполнено
|
||||||
|
|
||||||
|
### 1.1 Сервер: разбить `server/src/routes/auth.js` (892 → ~200 строк)
|
||||||
|
|
||||||
|
| Файл | Роуты |
|
||||||
|
|---|---|
|
||||||
|
| `routes/auth.js` | `/api/auth/request-code`, `/api/auth/verify-code`, `/api/me`, `/api/me/change-email/*`, `/api/me/profile` |
|
||||||
|
| `routes/user-addresses.js` | `/api/me/addresses` (6 роутов CRUD + default) |
|
||||||
|
| `routes/user-cart.js` | `/api/me/cart` (4 роута CRUD) |
|
||||||
|
| `routes/user-orders.js` | `/api/me/orders` (создание, список, деталь, подтверждение, review-eligibility) |
|
||||||
|
| `routes/user-payments.js` | `/api/me/orders/:id/pay` |
|
||||||
|
| `routes/user-messages.js` | `/api/me/orders/:id/messages`, unread-count, conversations, mark-read |
|
||||||
|
|
||||||
|
### 1.2 Клиент: разбить `AdminPage.tsx` (891 → 604 + 295 строк)
|
||||||
|
|
||||||
|
- `pages/admin-products/` + `pages/admin-categories/`
|
||||||
|
- `AdminLayoutPage` — новый нав-айтем «Категории», роут `/admin/categories`
|
||||||
|
|
||||||
|
### 1.3 Клиент: разбить `OrderDetailPage.tsx` (609 → 258 строк)
|
||||||
|
|
||||||
|
- `features/order-chat/` — чат по заказу
|
||||||
|
- `features/order-payment/` — секция оплаты + модалка (`OrderPaymentSection`, `PaymentDialog`)
|
||||||
|
- `features/product-review/` — секция отзывов + модалка (`ReviewSection`, `ReviewDialog`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. FSD-архитектура
|
||||||
|
|
||||||
|
### 2.1 Создать недостающие сегменты ✅
|
||||||
|
|
||||||
|
| Слайс | Что сделано |
|
||||||
|
|---|---|
|
||||||
|
| `entities/catalog-slider` | `model/types.ts`, `index.ts` (barrel), импорты обновлены |
|
||||||
|
| `entities/gallery` | `ui/GalleryGrid.tsx`, `index.ts`, импорты обновлены |
|
||||||
|
| `entities/info` | `model/types.ts`, `index.ts`, импорты обновлены |
|
||||||
|
| `features/address-map-picker` | `api/map-geocoding.ts`, `model/types.ts`, `index.ts`, импорты обновлены |
|
||||||
|
|
||||||
|
### 2.2 Вынести роутинг из `App.tsx` → `app/routes/` ✅
|
||||||
|
|
||||||
|
`AppRoutes` в `app/routes/index.tsx`. `App.tsx` — чистая точка входа.
|
||||||
|
|
||||||
|
### 2.3 Устранить дублирование констант клиент/сервер ✅
|
||||||
|
|
||||||
|
Создан `shared/constants/` с каноничными значениями (`order-status.js`, `delivery-carrier.js`, `upload-limits.js`, `payment-method.js`). Все клиентские и серверные константы импортируются оттуда. Vite + tsconfig настроены на `@shared/*` alias.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Клиентские компоненты
|
||||||
|
|
||||||
|
### 3.1 `AppHeader.tsx` (406 → 293 строк) ✅
|
||||||
|
- `UserMenu` → `features/user/user-menu/`
|
||||||
|
- `CartBadge` → `features/cart/cart-badge/`
|
||||||
|
- `NavigationDrawer` → `widgets/navigation-drawer/`
|
||||||
|
|
||||||
|
### 3.2 `HomePage.tsx` (414 → 157 строк) ✅
|
||||||
|
|
||||||
|
- `useProductFilters` хук в `pages/home/lib/`
|
||||||
|
- `ProductFilters` компонент в `pages/home/ui/`
|
||||||
|
- Фильтры, сортировка, масштаб карточек вынесены из страницы
|
||||||
|
|
||||||
|
### 3.3 `AdminOrdersPage.tsx`, `AdminUsersPage.tsx` ✅
|
||||||
|
|
||||||
|
- `shared/ui/AdminTable/` — компонент таблицы с loading/error/skeleton
|
||||||
|
- `shared/ui/AdminDialog/` — компонент диалога с loading/error/title/actions
|
||||||
|
- `AdminUsersPage`: таблица и диалог заменены на общие компоненты
|
||||||
|
- `AdminOrdersPage`: диалог заменён на `AdminDialog`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Effector + состояние — ✅ Выполнено
|
||||||
|
|
||||||
|
### 4.1 `shared/model/auth.ts` (96 → 83 строк)
|
||||||
|
|
||||||
|
- `.watch()` → `sample` + `persistTokenFx`
|
||||||
|
- Убран `tokenPersistInitialized` флаг
|
||||||
|
- `createErrorStore(effect)` — общий шаблон сторов ошибок
|
||||||
|
- `readStoredToken` → `shared/lib/persist-token.ts` (re-export из auth.ts)
|
||||||
|
- Создан `shared/lib/create-error-store.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Сервер (низкий приоритет)
|
||||||
|
|
||||||
|
### 5.1 `fastify.decorate` вместо передачи зависимостей параметрами ✅
|
||||||
|
|
||||||
|
`slugify`, `parseMaterialsInput`, `mapProductForApi` декорированы на fastify в `api.js`. Роуты используют `request.server.*` вместо получения через параметры.
|
||||||
|
|
||||||
|
### 5.2 Валидация через Fastify Schema ✅
|
||||||
|
|
||||||
|
Добавлены JSON Schema для:
|
||||||
|
- `POST /api/admin/products` — body
|
||||||
|
- `PATCH /api/admin/products/:id` — body
|
||||||
|
- `GET /api/products` — querystring (фильтры, пагинация)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Инфраструктура (низкий приоритет)
|
||||||
|
|
||||||
|
### 6.1 Error Boundary ✅
|
||||||
|
|
||||||
|
Создан `shared/ui/ErrorBoundary/ErrorBoundary.tsx` — class-компонент с `getDerivedStateFromError` / `componentDidCatch`.
|
||||||
|
- Отображает MUI `Alert` с заголовком «Что-то пошло не так» и кнопкой «Попробовать снова».
|
||||||
|
- Поддерживает кастомный `fallback` и колбэк `onError`.
|
||||||
|
- Интегрирован в `App.tsx`: `<ErrorBoundary><AppRoutes /></ErrorBoundary>`.
|
||||||
|
|
||||||
|
### 6.2 Тесты ✅
|
||||||
|
|
||||||
|
**Клиент (vitest + jsdom + @testing-library/react):**
|
||||||
|
- `shared/lib/__tests__/get-error-message.test.ts` — 4 теста
|
||||||
|
- `shared/lib/__tests__/format-price.test.ts` — 3 теста
|
||||||
|
- `shared/lib/__tests__/group-orders-by-status.test.ts` — 3 теста
|
||||||
|
- `shared/ui/ErrorBoundary/__tests__/ErrorBoundary.test.tsx` — 4 теста (рендер, падение, кастомный fallback, сброс)
|
||||||
|
|
||||||
|
**Сервер (vitest):**
|
||||||
|
- `src/lib/__tests__/escape-html.test.js` — 4 теста
|
||||||
|
- `src/lib/__tests__/order-status.test.js` — 9 тестов (`canTransitionAdminOrderStatus`)
|
||||||
|
|
||||||
|
**Команды:** `npm test` (vitest run), `npm run test:watch` (vitest).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Сводка изменений
|
||||||
|
|
||||||
|
| Область | Файлов создано | Файлов изменено |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Server routes | 0 | 4 (декораты + схемы) |
|
||||||
|
| Client pages | 3 | 2 (HomePage, AdminOrdersPage, AdminUsersPage) |
|
||||||
|
| Client entities | 6 | 2 (barrel, GalleryGrid, model types) |
|
||||||
|
| Client features | 3 | 2 (map-geocoding, AddressMapPicker) |
|
||||||
|
| Client shared/ui | 3 | 0 (AdminDialog, AdminTable, ErrorBoundary) |
|
||||||
|
| Client app config | 0 | 2 (vite.config, tsconfig) |
|
||||||
|
| Client tests | 4 | 0 (vitest config, setup, 3 test files) |
|
||||||
|
| Server tests | 2 | 0 (vitest config, 2 test files) |
|
||||||
|
| Shared constants | 8 | 0 (order-status, delivery-carrier, etc.) |
|
||||||
|
| Server constants | 0 | 3 (order-status, delivery-carrier, upload-limits) |
|
||||||
|
| **Итого** | **29** | **15** |
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[{*.md,*.mdx}]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.vite
|
||||||
|
coverage
|
||||||
|
*.min.*
|
||||||
|
package-lock.json
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"semi": false,
|
||||||
|
"printWidth": 120,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"jsxSingleQuote": false,
|
||||||
|
"arrowParens": "always"
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import eslint from '@eslint/js'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import importX from 'eslint-plugin-import-x'
|
||||||
|
import react from 'eslint-plugin-react'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import jsxA11y from 'eslint-plugin-jsx-a11y'
|
||||||
|
import eslintConfigPrettier from 'eslint-config-prettier'
|
||||||
|
import eslintPluginPrettier from 'eslint-plugin-prettier'
|
||||||
|
import globals from 'globals'
|
||||||
|
import boundaries from 'eslint-plugin-boundaries'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
|
||||||
|
const fsdPathGroups = [
|
||||||
|
{ pattern: 'app/**', group: 'internal', position: 'before' },
|
||||||
|
{ pattern: 'pages/**', group: 'internal', position: 'before' },
|
||||||
|
{ pattern: 'widgets/**', group: 'internal', position: 'before' },
|
||||||
|
{ pattern: 'features/**', group: 'internal', position: 'before' },
|
||||||
|
{ pattern: 'entities/**', group: 'internal', position: 'before' },
|
||||||
|
{ pattern: 'shared/**', group: 'internal', position: 'before' },
|
||||||
|
// alias вида "@/shared/..."
|
||||||
|
{ pattern: '@/**', group: 'internal', position: 'before' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Правила + FSD-границы. */
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ['dist/**', 'node_modules/**'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'react-plugin-settings',
|
||||||
|
settings: { react: { version: '19' } },
|
||||||
|
},
|
||||||
|
eslint.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
importX.flatConfigs.recommended,
|
||||||
|
importX.flatConfigs.typescript,
|
||||||
|
react.configs.flat.recommended,
|
||||||
|
react.configs.flat['jsx-runtime'],
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
jsxA11y.flatConfigs.recommended,
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
globals: { ...globals.browser, ...globals.es2021 },
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
'import/internal-regex': '^(@/)?(app|pages|widgets|features|entities|shared)(/|$)',
|
||||||
|
'import/resolver': {
|
||||||
|
typescript: { project: './tsconfig.json' },
|
||||||
|
node: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'no-console': ['error', { allow: ['warn', 'error', 'info'] }],
|
||||||
|
quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: false }],
|
||||||
|
'max-len': [
|
||||||
|
'warn',
|
||||||
|
{
|
||||||
|
code: 120,
|
||||||
|
ignoreStrings: true,
|
||||||
|
ignoreTrailingComments: true,
|
||||||
|
ignoreTemplateLiterals: true,
|
||||||
|
ignoreComments: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'import-x/extensions': [
|
||||||
|
'warn',
|
||||||
|
{
|
||||||
|
js: 'never',
|
||||||
|
jsx: 'never',
|
||||||
|
ts: 'never',
|
||||||
|
tsx: 'never',
|
||||||
|
json: 'always',
|
||||||
|
svg: 'always',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'import-x/prefer-default-export': 'off',
|
||||||
|
'import-x/no-extraneous-dependencies': 'off',
|
||||||
|
'import-x/no-cycle': 'warn',
|
||||||
|
'import-x/order': [
|
||||||
|
'warn',
|
||||||
|
{
|
||||||
|
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object', 'type'],
|
||||||
|
pathGroups: [
|
||||||
|
{ pattern: 'react', group: 'external', position: 'before' },
|
||||||
|
{ pattern: 'react-dom', group: 'external', position: 'before' },
|
||||||
|
{ pattern: '@mui/**', group: 'external', position: 'before' },
|
||||||
|
...fsdPathGroups,
|
||||||
|
],
|
||||||
|
pathGroupsExcludedImportTypes: ['react'],
|
||||||
|
'newlines-between': 'never',
|
||||||
|
alphabetize: { order: 'asc', caseInsensitive: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'react/prop-types': 'off',
|
||||||
|
'react/jsx-curly-brace-presence': ['error', { props: 'never', children: 'ignore' }],
|
||||||
|
'react/display-name': 'off',
|
||||||
|
'react/no-unescaped-entities': 'warn',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'warn',
|
||||||
|
'@typescript-eslint/no-unused-vars': ['error', { args: 'none' }],
|
||||||
|
'no-unused-vars': 'off',
|
||||||
|
'@typescript-eslint/no-shadow': 'off',
|
||||||
|
'no-shadow': 'off',
|
||||||
|
'@typescript-eslint/no-use-before-define': 'error',
|
||||||
|
'no-use-before-define': 'off',
|
||||||
|
'consistent-return': 'off',
|
||||||
|
'@typescript-eslint/no-empty-function': 'warn',
|
||||||
|
'@typescript-eslint/no-unnecessary-type-constraint': 'warn',
|
||||||
|
'class-methods-use-this': 'warn',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
plugins: { prettier: eslintPluginPrettier },
|
||||||
|
rules: { 'prettier/prettier': ['warn', { endOfLine: 'lf' }] },
|
||||||
|
},
|
||||||
|
eslintConfigPrettier,
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
plugins: { boundaries },
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
'import/resolver': {
|
||||||
|
typescript: { project: './tsconfig.json' },
|
||||||
|
},
|
||||||
|
'boundaries/include': ['src/**/*'],
|
||||||
|
'boundaries/elements': [
|
||||||
|
{ type: 'app', pattern: 'src/app/**' },
|
||||||
|
{ type: 'pages', pattern: 'src/pages/**' },
|
||||||
|
{ type: 'widgets', pattern: 'src/widgets/**' },
|
||||||
|
{ type: 'features', pattern: 'src/features/**' },
|
||||||
|
{ type: 'entities', pattern: 'src/entities/**' },
|
||||||
|
{ type: 'shared', pattern: 'src/shared/**' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'boundaries/no-unknown': 'off',
|
||||||
|
'boundaries/no-unknown-files': 'off',
|
||||||
|
'boundaries/dependencies': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
default: 'disallow',
|
||||||
|
checkUnknownLocals: true,
|
||||||
|
rules: [
|
||||||
|
{ from: { type: 'shared' }, allow: { to: { type: 'shared' } } },
|
||||||
|
{
|
||||||
|
from: { type: 'entities' },
|
||||||
|
allow: { to: { type: ['entities', 'shared'] } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: { type: 'features' },
|
||||||
|
allow: { to: { type: ['features', 'entities', 'shared'] } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: { type: 'widgets' },
|
||||||
|
allow: {
|
||||||
|
to: { type: ['widgets', 'features', 'entities', 'shared'] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: { type: 'pages' },
|
||||||
|
allow: {
|
||||||
|
to: {
|
||||||
|
type: ['pages', 'widgets', 'features', 'entities', 'shared'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: { type: 'app' },
|
||||||
|
allow: {
|
||||||
|
to: {
|
||||||
|
type: ['app', 'pages', 'widgets', 'features', 'entities', 'shared'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['src/app/providers/theme-controller.tsx'],
|
||||||
|
rules: { 'react-refresh/only-export-components': 'off' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['src/pages/**/ui/**/*.tsx'],
|
||||||
|
rules: { 'react-hooks/incompatible-library': 'off' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['eslint.config.js'],
|
||||||
|
rules: {
|
||||||
|
'import-x/no-unresolved': 'off',
|
||||||
|
'import-x/no-named-as-default': 'off',
|
||||||
|
'import-x/no-named-as-default-member': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon-32.png" sizes="32x32" />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon-48.png" sizes="48x48" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<link rel="preconnect" href="https://xn--80abekoceifm0c0a5irb.xn--p1ai" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Любимый Креатив — изделия ручной работы: игрушки, сувениры и другие уникальные товары с душой и вниманием к деталям."
|
||||||
|
/>
|
||||||
|
<meta name="theme-color" content="#1976d2" />
|
||||||
|
<title>Любимый Креатив — Изделия ручной работы</title>
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:title" content="Любимый Креатив — Изделия ручной работы" />
|
||||||
|
<meta property="og:description" content="Игрушки, сувениры и другие уникальные изделия ручной работы." />
|
||||||
|
<meta property="og:image" content="/favicon-128.png" />
|
||||||
|
<meta property="og:locale" content="ru_RU" />
|
||||||
|
<link rel="preload" href="/fonts/Outfit-Bold.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
|
||||||
|
<link rel="preload" href="/fonts/Outfit-Medium.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
|
||||||
|
<link rel="canonical" href="https://любимыйкреатив.рф/" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
{
|
||||||
|
"name": "client",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
|
"format": "prettier . --write --ignore-unknown",
|
||||||
|
"format:check": "prettier . --check --ignore-unknown",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dicebear/adventurer": "^9.4.2",
|
||||||
|
"@dicebear/avataaars": "^9.4.2",
|
||||||
|
"@dicebear/big-ears": "^9.4.2",
|
||||||
|
"@dicebear/big-smile": "^9.4.2",
|
||||||
|
"@dicebear/bottts": "^9.4.2",
|
||||||
|
"@dicebear/core": "^9.4.2",
|
||||||
|
"@dicebear/croodles": "^9.4.2",
|
||||||
|
"@dicebear/fun-emoji": "^9.4.2",
|
||||||
|
"@dicebear/identicon": "^9.4.2",
|
||||||
|
"@dicebear/initials": "^9.4.2",
|
||||||
|
"@dicebear/lorelei": "^9.4.2",
|
||||||
|
"@dicebear/micah": "^9.4.2",
|
||||||
|
"@dicebear/notionists": "^9.4.2",
|
||||||
|
"@dicebear/pixel-art": "^9.4.2",
|
||||||
|
"@dicebear/rings": "^9.4.2",
|
||||||
|
"@dicebear/shapes": "^9.4.2",
|
||||||
|
"@dicebear/thumbs": "^9.4.2",
|
||||||
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@emotion/styled": "^11.14.1",
|
||||||
|
"@mui/icons-material": "^9.0.0",
|
||||||
|
"@mui/material": "^9.0.0",
|
||||||
|
"@tanstack/react-query": "^5.100.5",
|
||||||
|
"@tiptap/extension-placeholder": "^3.22.5",
|
||||||
|
"@tiptap/react": "^3.22.5",
|
||||||
|
"@tiptap/starter-kit": "^3.22.5",
|
||||||
|
"axios": "^1.15.2",
|
||||||
|
"effector": "^23.4.4",
|
||||||
|
"effector-react": "^23.3.0",
|
||||||
|
"lucide-react": "^1.14.0",
|
||||||
|
"maplibre-gl": "^5.24.0",
|
||||||
|
"react": "^19.2.5",
|
||||||
|
"react-dom": "^19.2.5",
|
||||||
|
"react-hook-form": "^7.74.0",
|
||||||
|
"react-map-gl": "^8.1.1",
|
||||||
|
"react-router-dom": "^7.14.2",
|
||||||
|
"swiper": "^12.1.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/node": "^24.12.2",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-import-resolver-typescript": "^4.4.4",
|
||||||
|
"eslint-plugin-boundaries": "^6.0.2",
|
||||||
|
"eslint-plugin-import-x": "^4.16.2",
|
||||||
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
|
"eslint-plugin-prettier": "^5.5.5",
|
||||||
|
"eslint-plugin-react": "^7.37.5",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.5.0",
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"typescript-eslint": "^8.58.2",
|
||||||
|
"vite": "^8.0.10",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 516 B |
|
After Width: | Height: | Size: 774 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#546E7A"/><text x="16" y="23" text-anchor="middle" font-size="22" fill="white" font-family="sans-serif" font-weight="bold">К</text></svg>
|
||||||
|
After Width: | Height: | Size: 240 B |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,106 @@
|
|||||||
|
Политика в отношении обработки персональных данных
|
||||||
|
1. Общие положения
|
||||||
|
Настоящая политика обработки персональных данных составлена в соответствии с требованиями Федерального закона от 27.07.2006. №152-ФЗ «О персональных данных» и определяет порядок обработки персональных данных и меры по обеспечению безопасности персональных данных, предпринимаемые индивидуальным предпринимателем Новоселовой Наталией Владимировной (далее – Оператор).
|
||||||
|
|
||||||
|
1.1. Оператор ставит своей важнейшей целью и условием осуществления своей деятельности соблюдение прав и свобод человека и гражданина при обработке его персональных данных, в том числе защиты прав на неприкосновенность частной жизни, личную и семейную тайну.
|
||||||
|
|
||||||
|
1.2. Настоящая политика Оператора в отношении обработки персональных данных (далее – Политика) применяется ко всей информации, которую Оператор может получить о посетителях веб-сайта www.craftedtoys.ru. Оператор не контролирует и не несет ответственности за сайты третьих лиц, на которые Пользователь может перейти по ссылкам, доступным на www.craftedroys.ru.
|
||||||
|
|
||||||
|
2. Основные понятия, используемые в Политике
|
||||||
|
2.1. Автоматизированная обработка персональных данных – обработка персональных данных с помощью средств вычислительной техники;
|
||||||
|
|
||||||
|
2.2. Блокирование персональных данных – временное прекращение обработки персональных данных (за исключением случаев, если обработка необходима для уточнения персональных данных);
|
||||||
|
|
||||||
|
2.3. Веб-сайт – совокупность графических и информационных материалов, а также программ для ЭВМ и баз данных, обеспечивающих их доступность в сети интернет по сетевому адресу www.craftedtoys.ru;
|
||||||
|
|
||||||
|
2.4. Информационная система персональных данных — совокупность содержащихся в базах данных персональных данных, и обеспечивающих их обработку информационных технологий и технических средств;
|
||||||
|
|
||||||
|
2.5. Обезличивание персональных данных — действия, в результате которых невозможно определить без использования дополнительной информации принадлежность персональных данных конкретному Пользователю или иному субъекту персональных данных;
|
||||||
|
|
||||||
|
2.6. Обработка персональных данных – любое действие (операция) или совокупность действий (операций), совершаемых с использованием средств автоматизации или без использования таких средств с персональными данными, включая сбор, запись, систематизацию, накопление, хранение, уточнение (обновление, изменение), извлечение, использование, передачу (распространение, предоставление, доступ), обезличивание, блокирование, удаление, уничтожение персональных данных; Оператор осуществляет обработку данных пользователя до момента подачи им заявления на отзыв согласия на обработку персональных данных
|
||||||
|
|
||||||
|
2.7. Оператор – Администрация сайта, индивидуальный предприниматель Индивидуальный предприниматель Новоселова Наталия Владимировна
|
||||||
|
|
||||||
|
ИНН 402900832341
|
||||||
|
|
||||||
|
ОГРНИП 305402922700051
|
||||||
|
|
||||||
|
Адрес: 248000, Россия, г. Калуга, ул. Никитина, д. 12А
|
||||||
|
|
||||||
|
2.8. Персональные данные – любая информация, относящаяся прямо или косвенно к определенному или определяемому Пользователю веб-сайта www.craftedtoys.ru;
|
||||||
|
|
||||||
|
2.9. Пользователь – любой посетитель веб-сайта www.craftedtoys.ru;
|
||||||
|
|
||||||
|
2.10. Предоставление персональных данных – действия, направленные на раскрытие персональных данных определенному лицу или определенному кругу лиц;
|
||||||
|
|
||||||
|
2.11. Распространение персональных данных – любые действия, направленные на раскрытие персональных данных неопределенному кругу лиц передача персональных данных или на ознакомление с персональными данными неограниченного круга лиц, в том числе обнародование персональных данных в средствах массовой информации, размещение в информационно-телекоммуникационных сетях или предоставление доступа к персональным данным каким-либо иным способом;
|
||||||
|
|
||||||
|
2.12. Уничтожение персональных данных – любые действия, в результате которых персональные данные уничтожаются безвозвратно с невозможностью дальнейшего восстановления содержания персональных данных в информационной системе персональных данных и (или) уничтожаются материальные носители персональных данных.
|
||||||
|
|
||||||
|
3. Оператор может обрабатывать следующие персональные данные Пользователя
|
||||||
|
3.1. Персональная информация, которую Пользователь предоставляет о себе самостоятельно при регистрации (создании учетной записи) или в процессе использования Сайта и его сервисов, включая персональные данные Пользователя. Обязательная для предоставления Сервисов информация помечена специальным образом. Иная информация предоставляется Пользователем на его усмотрение.
|
||||||
|
|
||||||
|
3.2. Данные, которые автоматически передаются сервисам Сайта в процессе их использования с помощью установленного на устройстве Пользователя программного обеспечения (а именно программ Yandex.Metrika (предоставляется ООО “Яндекс”), в том числе IP-адрес, данные файлов cookie, информация о браузере Пользователя (или иной программе, с помощью которой осуществляется доступ к сервисам), технические характеристики оборудования и программного обеспечения, используемых Пользователем, дата и время доступа к сервисам, адреса запрашиваемых страниц, реферер (адрес предыдущей страницы) и иная подобная информация.
|
||||||
|
|
||||||
|
4. Категории собираемых персональных данных и цели их обработки
|
||||||
|
4.1. Сайт собирает и хранит только ту персональную информацию, которая необходима для предоставления информации об услугах или исполнения соглашений и договоров с Пользователем, за исключением случаев, когда законодательством предусмотрено обязательное хранение персональной информации в течение определенного законом срока.
|
||||||
|
|
||||||
|
4.2. Персональную информацию Пользователя Сайт обрабатывает в следующих целях:
|
||||||
|
|
||||||
|
4.2.1. Установления с Пользователем обратной связи, включая направление уведомлений, запросов, касающихся использования Сайта, оказания услуг, обработку запросов и заявок от Пользователя.
|
||||||
|
Для достижения данной цели Оператор собирает и обрабатывает следующие категории персональных данных: имя, адрес электронной почты, контактный номер телефона. К субъектам, персональные данные которых обрабатываются для указанной цели, относятся: физические лица, заинтересованные в получении товаров/работ/услуг от Оператора, физические лица, состоящие в гражданско-правовых и иных договорных отношениях с Оператором, представители юридических лиц - контрагентов Оператора либо потенциально заинтересованных в установлении с ним гражданско-правовых отношений. Указанные персональные данные обрабатываются смешанным способом. Срок обработки и хранения персональных данных, собираемых в соответствии с настоящим пунктом, составляет не более 7 лет с момента получения последней заявки либо иного обращения от Пользователя. При получении Оператором заявления субъекта персональных данных с требованием о прекращении обработки персональных данных Оператор прекращает обработку персональных данных досрочно, а именно в срок, не превышающий десяти рабочих дней с даты получения соответствующего требования. Указанный срок может быть продлен, но не более чем на пять рабочих дней в случае направления оператором в адрес субъекта персональных данных мотивированного уведомления с указанием причин продления срока предоставления запрашиваемой информации.
|
||||||
|
|
||||||
|
4.2.2. Идентификации Пользователя, зарегистрированного на Сайте, для формирования и исполнения персонализированных предложений и соглашений, а также предоставление Пользователю доступа к персонализированным ресурсам Сайта.
|
||||||
|
|
||||||
|
Для достижения данной цели Оператор собирает и обрабатывает следующие категории персональных данных: имя, адрес электронной почты, контактный номер телефона, пользовательский ID, IP-адрес. К субъектам, персональные данные которых обрабатываются для указанной цели, относятся: физические лица, заинтересованные в получении товаров/работ/услуг от Оператора, физические лица, состоящие в гражданско-правовых и иных договорных отношениях с Оператором, представители юридических лиц - контрагентов Оператора либо потенциально заинтересованных в установлении с ним гражданско-правовых отношений. Указанные персональные данные обрабатываются смешанным способом. Срок обработки и хранения персональных данных, собираемых в соответствии с настоящим пунктом, составляет не более 7 лет с момента получения последней заявки либо иного обращения от Пользователя. При получении Оператором заявления субъекта персональных данных с требованием о прекращении обработки персональных данных Оператор прекращает обработку персональных данных досрочно, а именно в срок, не превышающий десяти рабочих дней с даты получения соответствующего требования. Указанный срок может быть продлен, но не более чем на пять рабочих дней в случае направления оператором в адрес субъекта персональных данных мотивированного уведомления с указанием причин продления срока предоставления запрашиваемой информации.
|
||||||
|
|
||||||
|
4.2.3. Предоставления Пользователю эффективной клиентской и технической поддержки.
|
||||||
|
Для достижения данной цели Оператор собирает и обрабатывает следующие категории персональных данных: имя, адрес электронной почты, контактный номер телефона, пользовательский ID, IP-адрес. К субъектам, персональные данные которых обрабатываются для указанной цели, относятся: физические лица, заинтересованные в получении товаров/работ/услуг от Оператора, физические лица, состоящие в гражданско-правовых и иных договорных отношениях с Оператором, представители юридических лиц - контрагентов Оператора либо потенциально заинтересованных в установлении с ним гражданско-правовых отношений. Указанные персональные данные обрабатываются смешанным способом. Срок обработки и хранения персональных данных, собираемых в соответствии с настоящим пунктом составляет не более 7 лет с момента получения последней заявки либо иного обращения от Пользователя. При получении Оператором заявления субъекта персональных данных с требованием о прекращении обработки персональных данных Оператор прекращает обработку персональных данных досрочно, а именно в срок, не превышающий десяти рабочих дней с даты получения соответствующего требования. Указанный срок может быть продлен, но не более чем на пять рабочих дней в случае направления оператором в адрес субъекта персональных данных мотивированного уведомления с указанием причин продления срока предоставления запрашиваемой информации.
|
||||||
|
4.3. Обезличенные данные Пользователей, собираемые с помощью сервисов интернет-статистики (а именно с помощью программ Yandex.Metrika (предоставляется ООО “Яндекс”), служат для сбора информации о действиях Пользователей на сайте, улучшения качества сайта и его содержания.
|
||||||
|
|
||||||
|
Для достижения данной цели Оператор собирает и обрабатывает следующие категории обезличенных данных: IP-адрес, данные файлов cookie, информация о браузере Пользователя (или иной программе, с помощью которой осуществляется доступ к сервисам), технические характеристики оборудования и программного обеспечения, используемых Пользователем, дата и время доступа к сервисам, адреса запрашиваемых страниц, реферер (адрес предыдущей страницы). Указанные данные обрабатываются машинным способом. Срок обработки и хранения обезличенных данных, собираемых в соответствии с настоящим пунктом, составляет не более 3 лет с момента последнего посещения Пользователем Сайта.
|
||||||
|
|
||||||
|
5. Правовые основания обработки персональных данных
|
||||||
|
5.1. Оператор обрабатывает персональные данные Пользователя только в случае их заполнения и/или отправки Пользователем самостоятельно через специальные формы, расположенные на сайте www.craftedtoys.ru. Заполняя соответствующие формы и/или отправляя свои персональные данные Оператору, Пользователь выражает свое согласие с данной Политикой.
|
||||||
|
|
||||||
|
5.2. Оператор обрабатывает обезличенные данные о Пользователе в случае, если это разрешено в настройках браузера Пользователя (включено сохранение файлов «cookie» и использование технологии JavaScript).
|
||||||
|
|
||||||
|
5.3. Обработка персональных данных осуществляется с согласия субъекта персональных данных на обработку его персональных данных;
|
||||||
|
|
||||||
|
|
||||||
|
5.4 Обработка персональных данных необходима для исполнения договора, стороной которого либо выгодоприобретателем или поручителем по которому является субъект персональных данных, а также для заключения договора по инициативе субъекта персональных данных или договора, по которому субъект персональных данных будет являться выгодоприобретателем.
|
||||||
|
|
||||||
|
6. Порядок сбора, хранения, передачи и других видов обработки персональных данных
|
||||||
|
6.1. Персональная информация Пользователей хранится на территории Российской Федерации с соблюдением всех требований, установленных действующим российским законодательством.
|
||||||
|
|
||||||
|
6.2. В отношении персональной информации Пользователя сохраняется ее конфиденциальность, кроме случаев добровольного предоставления Пользователем информации о себе для общего доступа неограниченному кругу лиц (например, публикация отзывов). В таких случаях Пользователь соглашается с тем, что определенная часть его персональной информации становится общедоступной.
|
||||||
|
|
||||||
|
6.3. Сайт вправе передать персональную информацию Пользователя третьим лицам в следующих случаях:
|
||||||
|
|
||||||
|
6.3.1. Пользователь выразил согласие на такие действия и был проинформирован, какому конкретному третьему лицу и какой объем персональных данных будет передан.
|
||||||
|
6.3.2. Передача необходима для использования Пользователем определенного сервиса либо для исполнения определенного соглашения или договора с Пользователем.
|
||||||
|
6.3.3. Передача предусмотрена российским или иным применимым законодательством в рамках установленной законодательством процедуры.
|
||||||
|
6.4. Обработка персональных данных Пользователя осуществляется любым законным способом, в том числе в информационных системах персональных данных с использованием средств автоматизации или без использования таких средств. Обработка персональных данных Пользователей осуществляется в соответствии с Федеральным законом от 27.07.2006 N 152-ФЗ "О персональных данных". Срок обработки и хранения персональных данных, собираемых Оператором на сайте составляет не более 7 лет с момента получения последней заявки либо иного обращения от Пользователя. При получении Оператором заявления субъекта персональных данных с требованием о прекращении обработки персональных данных Оператор прекращает обработку персональных данных в срок, не превышающий десяти рабочих дней с даты получения соответствующего требования, за исключением случаев, предусмотренных пунктами 2 - 11 части 1 статьи 6 Федерального закона “О персональных данных”. Указанный срок может быть продлен, но не более чем на пять рабочих дней в случае направления оператором в адрес субъекта персональных данных мотивированного уведомления с указанием причин продления срока предоставления запрашиваемой информации.
|
||||||
|
|
||||||
|
6.5. При утрате или разглашении персональных данных Администрация Сайта информирует Пользователя об утрате или разглашении персональных данных.
|
||||||
|
|
||||||
|
6.6. Администрация Сайта принимает необходимые организационные и технические меры для защиты персональной информации Пользователя от неправомерного или случайного доступа, уничтожения, изменения, блокирования, копирования, распространения, а также от иных неправомерных действий третьих лиц.
|
||||||
|
|
||||||
|
6.7. Администрация Сайта совместно с Пользователем принимает все необходимые меры по предотвращению убытков или иных отрицательных последствий, вызванных утратой или разглашением персональных данных Пользователя.
|
||||||
|
|
||||||
|
7. Ответственность
|
||||||
|
7.1. Администрация Сайта, не исполнившая свои обязательства, несет ответственность за убытки, понесенные Пользователем в связи с неправомерным использованием персональных данных, в соответствии с законодательством Российской Федерации.
|
||||||
|
|
||||||
|
7.2. В случае утраты или разглашения конфиденциальной информации Администрация Сайта не несет ответственности, если данная конфиденциальная информация:
|
||||||
|
|
||||||
|
7.2.1. Стала публичным достоянием до ее утраты или разглашения.
|
||||||
|
7.2.2. Была получена от третьей стороны до момента ее получения Администрацией Сайта.
|
||||||
|
7.2.3. Была разглашена с согласия Пользователя.
|
||||||
|
8. Заключительные положения:
|
||||||
|
8.1. Администрация Сайта вправе вносить изменения в настоящую Политику конфиденциальности без согласия Пользователя.
|
||||||
|
|
||||||
|
8.2. Новая Политика конфиденциальности вступает в силу с момента ее размещения на Сайте, если иное не предусмотрено новой редакцией Политики конфиденциальности.
|
||||||
|
|
||||||
|
8.3. Все предложения или вопросы по настоящей Политике конфиденциальности следует сообщать на электронный адрес toy75@mail.ru
|
||||||
|
|
||||||
|
8.4. Действующая Политика конфиденциальности размещена на странице по адресу: https://craftedtoys.ru/rules/
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://любимыйкреатив.рф/sitemap.xml
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://любимыйкреатив.рф/</loc>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://любимыйкреатив.рф/info</loc>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://любимыйкреатив.рф/about</loc>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://любимыйкреатив.рф/privacy</loc>
|
||||||
|
<priority>0.5</priority>
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://любимыйкреатив.рф/terms</loc>
|
||||||
|
<priority>0.5</priority>
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { AppProviders } from '@/app/providers/AppProviders'
|
||||||
|
import { AppRoutes } from '@/app/routes'
|
||||||
|
import { NotificationStack } from '@/shared/ui/NotificationStack'
|
||||||
|
import { ErrorBoundary } from '@/shared/ui/ErrorBoundary'
|
||||||
|
import { NoiseOverlay } from '@/shared/ui/NoiseOverlay'
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<AppProviders>
|
||||||
|
<BrowserRouter>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<AppRoutes />
|
||||||
|
</ErrorBoundary>
|
||||||
|
<NotificationStack />
|
||||||
|
<NoiseOverlay />
|
||||||
|
</BrowserRouter>
|
||||||
|
</AppProviders>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import AppBar from '@mui/material/AppBar'
|
||||||
|
import Badge from '@mui/material/Badge'
|
||||||
|
import Box from '@mui/material/Box'
|
||||||
|
import Button from '@mui/material/Button'
|
||||||
|
import IconButton from '@mui/material/IconButton'
|
||||||
|
import { alpha, useTheme } from '@mui/material/styles'
|
||||||
|
import Toolbar from '@mui/material/Toolbar'
|
||||||
|
import Tooltip from '@mui/material/Tooltip'
|
||||||
|
import Typography from '@mui/material/Typography'
|
||||||
|
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useUnit } from 'effector-react'
|
||||||
|
import { Menu, Package } from 'lucide-react'
|
||||||
|
import { Link as RouterLink, useNavigate } from 'react-router-dom'
|
||||||
|
import { useThemeController } from '@/app/providers/theme-controller'
|
||||||
|
import { fetchMyCart } from '@/entities/cart/api/cart-api'
|
||||||
|
import { fetchMyOrders } from '@/entities/order/api/order-api'
|
||||||
|
import { CartBadge } from '@/features/cart/cart-badge'
|
||||||
|
import { UserMenu } from '@/features/user/user-menu'
|
||||||
|
import { STORE_NAME } from '@/shared/config'
|
||||||
|
import { $user, logout, tokenSet } from '@/shared/model/auth'
|
||||||
|
import type { ColorScheme } from '@/shared/model/theme'
|
||||||
|
import { BearLogo } from '@/shared/ui/BearLogo'
|
||||||
|
import { ModeSwitcher } from '@/shared/ui/ModeSwitcher'
|
||||||
|
import { SchemeSwitcher } from '@/shared/ui/SchemeSwitcher'
|
||||||
|
import { NavigationDrawer } from '@/widgets/navigation-drawer'
|
||||||
|
|
||||||
|
type NavItem = { label: string; to: string }
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [{ label: 'Каталог', to: '/' }]
|
||||||
|
|
||||||
|
export const AppHeader = React.memo(function AppHeader() {
|
||||||
|
const { mode, resolvedMode, scheme, setScheme, cycleMode } = useThemeController()
|
||||||
|
const user = useUnit($user)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const isAdmin = Boolean(user?.isAdmin)
|
||||||
|
const headerNavItems = isAdmin ? [...navItems, { label: 'Админка', to: '/admin' }] : navItems
|
||||||
|
const theme = useTheme()
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
||||||
|
|
||||||
|
const cartQuery = useQuery({
|
||||||
|
queryKey: ['me', 'cart'],
|
||||||
|
queryFn: fetchMyCart,
|
||||||
|
enabled: Boolean(user) && !isAdmin,
|
||||||
|
})
|
||||||
|
const cartCount = cartQuery.data?.items?.length ?? 0
|
||||||
|
|
||||||
|
const ordersQuery = useQuery({
|
||||||
|
queryKey: ['me', 'orders'],
|
||||||
|
queryFn: fetchMyOrders,
|
||||||
|
enabled: Boolean(user) && !isAdmin,
|
||||||
|
})
|
||||||
|
const activeOrdersCount = (ordersQuery.data?.items ?? []).filter(
|
||||||
|
(o) => o.status !== 'DONE' && o.status !== 'CANCELLED',
|
||||||
|
).length
|
||||||
|
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false)
|
||||||
|
const [scrolled, setScrolled] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => setScrolled(window.scrollY > 0)
|
||||||
|
handler()
|
||||||
|
window.addEventListener('scroll', handler, { passive: true })
|
||||||
|
return () => window.removeEventListener('scroll', handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const go = (to: string) => {
|
||||||
|
setMobileOpen(false)
|
||||||
|
navigate(to)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onLogout = () => {
|
||||||
|
tokenSet(null)
|
||||||
|
logout()
|
||||||
|
setMobileOpen(false)
|
||||||
|
navigate('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppBar
|
||||||
|
position="sticky"
|
||||||
|
color="primary"
|
||||||
|
elevation={scrolled ? 2 : 0}
|
||||||
|
sx={{
|
||||||
|
borderBottom: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
bgcolor: alpha(theme.palette.primary.main, 0.95),
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
transition: 'box-shadow 0.2s ease, background-color 0.2s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Toolbar
|
||||||
|
sx={{
|
||||||
|
'& .MuiButton-text:hover': { bgcolor: 'rgba(255,255,255,0.12)' },
|
||||||
|
'& .MuiIconButton-root:hover': { bgcolor: 'rgba(255,255,255,0.15)' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isMobile && (
|
||||||
|
<IconButton
|
||||||
|
color="inherit"
|
||||||
|
onClick={() => setMobileOpen(true)}
|
||||||
|
aria-label="Открыть меню"
|
||||||
|
edge="start"
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
>
|
||||||
|
<Menu />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box
|
||||||
|
component={RouterLink}
|
||||||
|
to="/"
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: 'inherit',
|
||||||
|
minWidth: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BearLogo scheme={scheme} sx={{ width: 35, height: 35 }} />
|
||||||
|
<Typography variant="h6" sx={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
{STORE_NAME}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{!isMobile &&
|
||||||
|
headerNavItems.map((i) => (
|
||||||
|
<Button key={i.to} component={RouterLink} to={i.to} color="inherit">
|
||||||
|
{i.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!isAdmin && (
|
||||||
|
<>
|
||||||
|
{user && (
|
||||||
|
<Tooltip title="Заказы">
|
||||||
|
<IconButton
|
||||||
|
color="inherit"
|
||||||
|
sx={{ ml: 1 }}
|
||||||
|
onClick={() => navigate('/me/orders')}
|
||||||
|
aria-label={activeOrdersCount > 0 ? `Заказы (${activeOrdersCount})` : 'Заказы'}
|
||||||
|
>
|
||||||
|
<Badge color="secondary" badgeContent={activeOrdersCount} invisible={activeOrdersCount === 0}>
|
||||||
|
<Package />
|
||||||
|
</Badge>
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CartBadge user={user} cartCount={cartCount} onNavigate={navigate} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isAdmin && <UserMenu user={user} isAdmin={false} onNavigate={navigate} onLogout={onLogout} />}
|
||||||
|
|
||||||
|
{isAdmin && user && !isMobile && (
|
||||||
|
<UserMenu user={user} isAdmin={true} onNavigate={navigate} onLogout={onLogout} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isMobile && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, ml: 1.5 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
bgcolor: 'rgba(255, 255, 255, 0.25)',
|
||||||
|
borderRadius: 3,
|
||||||
|
px: 0.5,
|
||||||
|
py: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SchemeSwitcher value={scheme} onChange={(s: ColorScheme) => setScheme(s)} />
|
||||||
|
</Box>
|
||||||
|
<ModeSwitcher mode={mode} resolvedMode={resolvedMode} onCycleMode={cycleMode} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
|
||||||
|
<NavigationDrawer
|
||||||
|
open={mobileOpen}
|
||||||
|
onClose={() => setMobileOpen(false)}
|
||||||
|
user={user}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
navItems={headerNavItems}
|
||||||
|
scheme={scheme}
|
||||||
|
mode={mode}
|
||||||
|
resolvedMode={resolvedMode}
|
||||||
|
onSchemeChange={(s: ColorScheme) => setScheme(s)}
|
||||||
|
onCycleMode={cycleMode}
|
||||||
|
onNavigate={go}
|
||||||
|
onLogout={onLogout}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { type PropsWithChildren } from 'react'
|
||||||
|
import Box from '@mui/material/Box'
|
||||||
|
import Container from '@mui/material/Container'
|
||||||
|
import Divider from '@mui/material/Divider'
|
||||||
|
import Grid from '@mui/material/Grid'
|
||||||
|
import Link from '@mui/material/Link'
|
||||||
|
import Stack from '@mui/material/Stack'
|
||||||
|
import Typography from '@mui/material/Typography'
|
||||||
|
import { Link as RouterLink } from 'react-router-dom'
|
||||||
|
import { AppHeader } from '@/app/layout/AppHeader'
|
||||||
|
import vkLogoSrc from '@/shared/assets/vk-logo.svg'
|
||||||
|
import { STORE_EMAIL, STORE_NAME, STORE_PHONE, VK_URL } from '@/shared/config'
|
||||||
|
import { CookieConsentBanner } from '@/shared/ui/CookieConsentBanner'
|
||||||
|
import { DemoBanner } from '@/shared/ui/DemoBanner'
|
||||||
|
import { ScrollOnNavigate } from '@/shared/ui/ScrollOnNavigate'
|
||||||
|
import { ScrollToTop } from '@/shared/ui/ScrollToTop'
|
||||||
|
|
||||||
|
export function MainLayout({ children }: PropsWithChildren) {
|
||||||
|
const year = new Date().getFullYear()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh', minWidth: 0, overflowX: 'hidden' }}>
|
||||||
|
<ScrollOnNavigate />
|
||||||
|
<ScrollToTop />
|
||||||
|
<AppHeader />
|
||||||
|
<DemoBanner />
|
||||||
|
|
||||||
|
<Box component="main" sx={{ flex: 1, py: { xs: 3, md: 5 } }}>
|
||||||
|
<Container maxWidth="xl" sx={{ px: { xs: 2, sm: 3, md: 4 } }}>
|
||||||
|
{children}
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
component="footer"
|
||||||
|
sx={{
|
||||||
|
mt: 'auto',
|
||||||
|
borderTop: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
py: { xs: 5, md: 7 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Container maxWidth="xl" sx={{ px: { xs: 2, sm: 3, md: 4 } }}>
|
||||||
|
<Grid container spacing={5}>
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle1"
|
||||||
|
component="h4"
|
||||||
|
gutterBottom
|
||||||
|
sx={{ fontWeight: 700, letterSpacing: '-0.5px' }}
|
||||||
|
>
|
||||||
|
{STORE_NAME}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 1, maxWidth: 260 }}>
|
||||||
|
Изделия ручной работы: вещи с характером и вниманием к деталям.
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle1"
|
||||||
|
component="h4"
|
||||||
|
gutterBottom
|
||||||
|
sx={{ fontWeight: 600, letterSpacing: '-0.25px' }}
|
||||||
|
>
|
||||||
|
Покупателям
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
<Link component={RouterLink} to="/me" color="inherit" underline="hover" variant="body2">
|
||||||
|
Личный кабинет
|
||||||
|
</Link>
|
||||||
|
<Link component={RouterLink} to="/info" color="inherit" underline="hover" variant="body2">
|
||||||
|
О покупке
|
||||||
|
</Link>
|
||||||
|
<Link component={RouterLink} to="/about" color="inherit" underline="hover" variant="body2">
|
||||||
|
О нас
|
||||||
|
</Link>
|
||||||
|
</Stack>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle1"
|
||||||
|
component="h4"
|
||||||
|
gutterBottom
|
||||||
|
sx={{ fontWeight: 600, letterSpacing: '-0.25px' }}
|
||||||
|
>
|
||||||
|
Контакты
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Typography variant="body2">
|
||||||
|
Email:{' '}
|
||||||
|
<Link href={`mailto:${STORE_EMAIL}`} underline="hover">
|
||||||
|
{STORE_EMAIL}
|
||||||
|
</Link>
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
Телефон:{' '}
|
||||||
|
<Link href={`tel:${STORE_PHONE.replace(/\s/g, '')}`} underline="hover">
|
||||||
|
{STORE_PHONE}
|
||||||
|
</Link>
|
||||||
|
</Typography>
|
||||||
|
<Link
|
||||||
|
href={VK_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, '&:hover': { color: '#4A76A8' } }}
|
||||||
|
>
|
||||||
|
<Box component="img" src={vkLogoSrc} alt="" sx={{ width: 20, height: 20 }} />
|
||||||
|
VK
|
||||||
|
</Link>
|
||||||
|
</Stack>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle1"
|
||||||
|
component="h4"
|
||||||
|
gutterBottom
|
||||||
|
sx={{ fontWeight: 600, letterSpacing: '-0.25px' }}
|
||||||
|
>
|
||||||
|
Юридическая информация
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
<Link component={RouterLink} to="/privacy" color="inherit" underline="hover" variant="body2">
|
||||||
|
Политика конфиденциальности
|
||||||
|
</Link>
|
||||||
|
<Link component={RouterLink} to="/terms" color="inherit" underline="hover" variant="body2">
|
||||||
|
Пользовательское соглашение
|
||||||
|
</Link>
|
||||||
|
</Stack>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
<Divider sx={{ my: 4 }} />
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', textAlign: 'center' }}>
|
||||||
|
© {year} {STORE_NAME}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
<CookieConsentBanner />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { MainLayout } from '../MainLayout'
|
||||||
|
|
||||||
|
vi.mock('@/app/layout/AppHeader', () => ({
|
||||||
|
AppHeader: () => <header>Шапка</header>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/shared/ui/CookieConsentBanner', () => ({
|
||||||
|
CookieConsentBanner: () => null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/shared/ui/DemoBanner', () => ({
|
||||||
|
DemoBanner: () => null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/shared/ui/ScrollOnNavigate', () => ({
|
||||||
|
ScrollOnNavigate: () => null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/shared/ui/ScrollToTop', () => ({
|
||||||
|
ScrollToTop: () => null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('MainLayout', () => {
|
||||||
|
it('не задает фиксированную минимальную ширину, которая ломает мобильный экран', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<MainLayout>Контент</MainLayout>
|
||||||
|
</MemoryRouter>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(container.firstElementChild).not.toHaveStyle({ minWidth: '500px' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
import { type PropsWithChildren, useMemo } from 'react'
|
||||||
|
import CssBaseline from '@mui/material/CssBaseline'
|
||||||
|
import { alpha, ThemeProvider, createTheme } from '@mui/material/styles'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { ThemeControllerProvider, useThemeController } from '@/app/providers/theme-controller'
|
||||||
|
import { SseProvider } from './SseProvider'
|
||||||
|
|
||||||
|
function AppThemeInner({ children }: PropsWithChildren) {
|
||||||
|
const controller = useThemeController()
|
||||||
|
const isDark = controller.resolvedMode === 'dark'
|
||||||
|
|
||||||
|
const theme = useMemo(
|
||||||
|
() =>
|
||||||
|
createTheme({
|
||||||
|
palette: (() => {
|
||||||
|
const common = { mode: controller.resolvedMode }
|
||||||
|
|
||||||
|
const text = isDark
|
||||||
|
? { primary: '#F2F2F2', secondary: 'rgba(242,242,242,0.72)', disabled: 'rgba(242,242,242,0.48)' }
|
||||||
|
: { primary: '#1F1B16', secondary: 'rgba(31,27,22,0.72)', disabled: 'rgba(31,27,22,0.48)' }
|
||||||
|
|
||||||
|
const chip = isDark ? { default: '#0E1510', paper: '#121B14' } : { default: '#F6FAF6', paper: '#FFFFFF' }
|
||||||
|
|
||||||
|
switch (controller.scheme) {
|
||||||
|
case 'forest':
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
primary: { main: isDark ? '#8FBC8F' : '#2E8B57' },
|
||||||
|
secondary: { main: isDark ? '#CD853F' : '#8B4513' },
|
||||||
|
info: { main: isDark ? '#4682B4' : '#1E90FF' },
|
||||||
|
success: { main: isDark ? '#90EE90' : '#32CD32' },
|
||||||
|
warning: { main: isDark ? '#FFD700' : '#FFA500' },
|
||||||
|
error: { main: isDark ? '#F08080' : '#CD5C5C' },
|
||||||
|
divider: isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)',
|
||||||
|
text,
|
||||||
|
chip,
|
||||||
|
background: isDark
|
||||||
|
? { default: '#0F1720', paper: '#1A242E' }
|
||||||
|
: { default: '#F8F6F3', paper: '#FFFFFF' },
|
||||||
|
}
|
||||||
|
case 'ocean':
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
primary: { main: isDark ? '#5F9EA0' : '#20B2AA' },
|
||||||
|
secondary: { main: isDark ? '#7B68EE' : '#6A5ACD' },
|
||||||
|
info: { main: isDark ? '#87CEEB' : '#00BFFF' },
|
||||||
|
success: { main: isDark ? '#98FB98' : '#00FA9A' },
|
||||||
|
warning: { main: isDark ? '#FFE4B5' : '#FFDAB9' },
|
||||||
|
error: { main: isDark ? '#FF6347' : '#FF4500' },
|
||||||
|
divider: isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)',
|
||||||
|
text,
|
||||||
|
chip,
|
||||||
|
background: isDark
|
||||||
|
? { default: '#0A1A2A', paper: '#0F1D35' }
|
||||||
|
: { default: '#F0F8FF', paper: '#FFFFFF' },
|
||||||
|
}
|
||||||
|
case 'berry':
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
primary: { main: isDark ? '#9370DB' : '#8A2BE2' },
|
||||||
|
secondary: { main: isDark ? '#FF69B4' : '#FF1493' },
|
||||||
|
info: { main: isDark ? '#00CED1' : '#00BFFF' },
|
||||||
|
success: { main: isDark ? '#00FF7F' : '#7CFC00' },
|
||||||
|
warning: { main: isDark ? '#FFD700' : '#FFA500' },
|
||||||
|
error: { main: isDark ? '#FF4500' : '#FF6347' },
|
||||||
|
divider: isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)',
|
||||||
|
text,
|
||||||
|
chip,
|
||||||
|
background: isDark
|
||||||
|
? { default: '#1A0A1A', paper: '#250E25' }
|
||||||
|
: { default: '#FFF0F5', paper: '#FFFFFF' },
|
||||||
|
}
|
||||||
|
case 'craft':
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
...common,
|
||||||
|
primary: { main: isDark ? '#90A4AE' : '#546E7A' },
|
||||||
|
secondary: { main: isDark ? '#78909C' : '#78909C' },
|
||||||
|
info: { main: isDark ? '#7986CB' : '#3F51B5' },
|
||||||
|
success: { main: isDark ? '#66BB6A' : '#43A047' },
|
||||||
|
warning: { main: isDark ? '#FFB74D' : '#F57C00' },
|
||||||
|
error: { main: isDark ? '#EF5350' : '#D32F2F' },
|
||||||
|
divider: isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)',
|
||||||
|
text,
|
||||||
|
chip,
|
||||||
|
background: isDark
|
||||||
|
? { default: '#121212', paper: '#1E1E1E' }
|
||||||
|
: { default: '#F5F5F5', paper: '#FFFFFF' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
shape: { borderRadius: 12 },
|
||||||
|
typography: {
|
||||||
|
fontFamily: '"Outfit", "Segoe UI", system-ui, sans-serif',
|
||||||
|
h1: { fontWeight: 700, letterSpacing: '-1px', lineHeight: 1.1, textWrap: 'balance' },
|
||||||
|
h2: { fontWeight: 700, letterSpacing: '-0.75px', lineHeight: 1.15, textWrap: 'balance' },
|
||||||
|
h3: { fontWeight: 700, letterSpacing: '-0.5px', lineHeight: 1.2, textWrap: 'balance' },
|
||||||
|
h4: { fontWeight: 700, letterSpacing: '-0.5px', textWrap: 'balance' },
|
||||||
|
h5: { fontWeight: 600, letterSpacing: '-0.25px', textWrap: 'balance' },
|
||||||
|
h6: { fontWeight: 600, textWrap: 'balance' },
|
||||||
|
subtitle1: { fontWeight: 600 },
|
||||||
|
subtitle2: { fontWeight: 500 },
|
||||||
|
body1: { fontSize: '0.875rem', lineHeight: 1.6 },
|
||||||
|
body2: { fontSize: '0.75rem', lineHeight: 1.5 },
|
||||||
|
button: { textTransform: 'none', fontWeight: 600 },
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
MuiButton: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
textTransform: 'none',
|
||||||
|
borderRadius: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
transition: 'all 0.2s ease-in-out',
|
||||||
|
'&:focus-visible': {
|
||||||
|
outline: '2px solid currentColor',
|
||||||
|
outlineOffset: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
contained: {
|
||||||
|
boxShadow: '0 4px 14px 0 rgba(0,0,0,0.12)',
|
||||||
|
'&:hover': {
|
||||||
|
boxShadow: '0 6px 20px 0 rgba(0,0,0,0.18)',
|
||||||
|
transform: 'translateY(-2px)',
|
||||||
|
},
|
||||||
|
'&:active': {
|
||||||
|
boxShadow: '0 2px 8px 0 rgba(0,0,0,0.12)',
|
||||||
|
transform: 'translateY(0) scale(0.98)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
outlined: {
|
||||||
|
border: '1px solid',
|
||||||
|
'&:hover': {
|
||||||
|
boxShadow: '0 2px 8px 0 rgba(0,0,0,0.08)',
|
||||||
|
},
|
||||||
|
'&:active': {
|
||||||
|
boxShadow: 'none',
|
||||||
|
transform: 'scale(0.98)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'action.hover',
|
||||||
|
},
|
||||||
|
'&:active': {
|
||||||
|
backgroundColor: 'action.selected',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiIconButton: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
transition: 'all 0.2s ease-in-out',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'action.hover',
|
||||||
|
transform: 'scale(1.08)',
|
||||||
|
},
|
||||||
|
'&:active': {
|
||||||
|
backgroundColor: 'action.selected',
|
||||||
|
transform: 'scale(0.95)',
|
||||||
|
},
|
||||||
|
'&:focus-visible': {
|
||||||
|
outline: '2px solid currentColor',
|
||||||
|
outlineOffset: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiCard: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
'&:focus-visible': {
|
||||||
|
outline: '2px solid currentColor',
|
||||||
|
outlineOffset: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiLink: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
'&:focus-visible': {
|
||||||
|
outline: '2px solid currentColor',
|
||||||
|
outlineOffset: 2,
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiInputBase: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
'&.Mui-focused': {
|
||||||
|
'& .MuiOutlinedInput-notchedOutline': {
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiAlert: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 12,
|
||||||
|
border: '1px solid',
|
||||||
|
boxShadow: 'none',
|
||||||
|
fontWeight: 500,
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '8px 12px',
|
||||||
|
'& .MuiAlert-icon': {
|
||||||
|
padding: 0,
|
||||||
|
marginRight: 12,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
'& .MuiAlert-message': {
|
||||||
|
padding: 0,
|
||||||
|
},
|
||||||
|
'& .MuiAlert-action': {
|
||||||
|
padding: 0,
|
||||||
|
marginRight: 0,
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
colorSuccess: ({ theme }) => {
|
||||||
|
const isDark = theme.palette.mode === 'dark'
|
||||||
|
const p = theme.palette.success
|
||||||
|
return {
|
||||||
|
bgcolor: isDark ? alpha(p.light, 0.08) : alpha(p.main, 0.08),
|
||||||
|
borderColor: isDark ? alpha(p.light, 0.2) : alpha(p.main, 0.2),
|
||||||
|
color: isDark ? p.light : p.dark,
|
||||||
|
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
|
||||||
|
'&.MuiAlert-outlined': {
|
||||||
|
bgcolor: 'transparent',
|
||||||
|
borderColor: isDark ? alpha(p.light, 0.3) : alpha(p.main, 0.3),
|
||||||
|
color: isDark ? p.light : p.dark,
|
||||||
|
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
|
||||||
|
},
|
||||||
|
'&.MuiAlert-filled': {
|
||||||
|
bgcolor: isDark ? alpha(p.light, 0.15) : p.dark,
|
||||||
|
borderColor: 'transparent',
|
||||||
|
color: isDark ? alpha(p.light, 0.9) : '#FFFFFF',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colorError: ({ theme }) => {
|
||||||
|
const isDark = theme.palette.mode === 'dark'
|
||||||
|
const p = theme.palette.error
|
||||||
|
return {
|
||||||
|
bgcolor: isDark ? alpha(p.light, 0.08) : alpha(p.main, 0.08),
|
||||||
|
borderColor: isDark ? alpha(p.light, 0.2) : alpha(p.main, 0.2),
|
||||||
|
color: isDark ? p.light : p.dark,
|
||||||
|
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
|
||||||
|
'&.MuiAlert-outlined': {
|
||||||
|
bgcolor: 'transparent',
|
||||||
|
borderColor: isDark ? alpha(p.light, 0.3) : alpha(p.main, 0.3),
|
||||||
|
color: isDark ? p.light : p.dark,
|
||||||
|
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
|
||||||
|
},
|
||||||
|
'&.MuiAlert-filled': {
|
||||||
|
bgcolor: isDark ? alpha(p.light, 0.15) : p.dark,
|
||||||
|
borderColor: 'transparent',
|
||||||
|
color: isDark ? alpha(p.light, 0.9) : '#FFFFFF',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colorWarning: ({ theme }) => {
|
||||||
|
const isDark = theme.palette.mode === 'dark'
|
||||||
|
const p = theme.palette.warning
|
||||||
|
return {
|
||||||
|
bgcolor: isDark ? alpha(p.light, 0.08) : alpha(p.main, 0.08),
|
||||||
|
borderColor: isDark ? alpha(p.light, 0.2) : alpha(p.main, 0.2),
|
||||||
|
color: isDark ? p.light : p.dark,
|
||||||
|
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
|
||||||
|
'&.MuiAlert-outlined': {
|
||||||
|
bgcolor: 'transparent',
|
||||||
|
borderColor: isDark ? alpha(p.light, 0.3) : alpha(p.main, 0.3),
|
||||||
|
color: isDark ? p.light : p.dark,
|
||||||
|
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
|
||||||
|
},
|
||||||
|
'&.MuiAlert-filled': {
|
||||||
|
bgcolor: isDark ? alpha(p.light, 0.15) : p.dark,
|
||||||
|
borderColor: 'transparent',
|
||||||
|
color: isDark ? alpha(p.light, 0.9) : '#FFFFFF',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colorInfo: ({ theme }) => {
|
||||||
|
const isDark = theme.palette.mode === 'dark'
|
||||||
|
const p = theme.palette.info
|
||||||
|
return {
|
||||||
|
bgcolor: isDark ? alpha(p.light, 0.08) : alpha(p.main, 0.08),
|
||||||
|
borderColor: isDark ? alpha(p.light, 0.2) : alpha(p.main, 0.2),
|
||||||
|
color: isDark ? p.light : p.dark,
|
||||||
|
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
|
||||||
|
'&.MuiAlert-outlined': {
|
||||||
|
bgcolor: 'transparent',
|
||||||
|
borderColor: isDark ? alpha(p.light, 0.3) : alpha(p.main, 0.3),
|
||||||
|
color: isDark ? p.light : p.dark,
|
||||||
|
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
|
||||||
|
},
|
||||||
|
'&.MuiAlert-filled': {
|
||||||
|
bgcolor: isDark ? alpha(p.light, 0.15) : p.dark,
|
||||||
|
borderColor: 'transparent',
|
||||||
|
color: isDark ? alpha(p.light, 0.9) : '#FFFFFF',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiSnackbarContent: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 12,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.06)',
|
||||||
|
bgcolor: isDark ? '#1E1E1E' : '#FFFFFF',
|
||||||
|
boxShadow: '0 2px 12px rgba(0,0,0,0.06)',
|
||||||
|
color: isDark ? '#F2F2F2' : '#1F1B16',
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[controller.resolvedMode, controller.scheme],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
{children}
|
||||||
|
</ThemeProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppProviders({ children }: PropsWithChildren) {
|
||||||
|
const queryClient = useMemo(
|
||||||
|
() =>
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<SseProvider />
|
||||||
|
<ThemeControllerProvider>
|
||||||
|
<AppThemeInner>{children}</AppThemeInner>
|
||||||
|
</ThemeControllerProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useUnit } from 'effector-react'
|
||||||
|
import { createEventStream } from '@/shared/lib/sse'
|
||||||
|
import { $token } from '@/shared/model/auth'
|
||||||
|
|
||||||
|
export function SseProvider() {
|
||||||
|
const token = useUnit($token)
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const sourceRef = useRef<EventSource | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
if (sourceRef.current) {
|
||||||
|
sourceRef.current.close()
|
||||||
|
sourceRef.current = null
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const es = createEventStream(token)
|
||||||
|
sourceRef.current = es
|
||||||
|
|
||||||
|
function invalidateOrderQueries(orderId: unknown) {
|
||||||
|
if (!orderId) return
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['me', 'orders'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['me', 'orders', orderId] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'orders', 'detail', orderId] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'orders'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'orders', 'summary'] })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEvent(eventName: string) {
|
||||||
|
return function (event: MessageEvent) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data)
|
||||||
|
const orderId = data.orderId
|
||||||
|
|
||||||
|
switch (eventName) {
|
||||||
|
case 'message:new':
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['me', 'messages', 'unread-count'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['me', 'conversations'] })
|
||||||
|
invalidateOrderQueries(orderId)
|
||||||
|
break
|
||||||
|
case 'order:statusChanged':
|
||||||
|
invalidateOrderQueries(orderId)
|
||||||
|
break
|
||||||
|
case 'order:updated':
|
||||||
|
invalidateOrderQueries(orderId)
|
||||||
|
break
|
||||||
|
case 'order:new':
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'orders', 'summary'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'orders'] })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[sse] Failed to parse event data', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageNewHandler = handleEvent('message:new')
|
||||||
|
const orderStatusHandler = handleEvent('order:statusChanged')
|
||||||
|
const orderUpdatedHandler = handleEvent('order:updated')
|
||||||
|
const orderNewHandler = handleEvent('order:new')
|
||||||
|
|
||||||
|
es.addEventListener('message:new', messageNewHandler)
|
||||||
|
es.addEventListener('order:statusChanged', orderStatusHandler)
|
||||||
|
es.addEventListener('order:updated', orderUpdatedHandler)
|
||||||
|
es.addEventListener('order:new', orderNewHandler)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
es.removeEventListener('message:new', messageNewHandler)
|
||||||
|
es.removeEventListener('order:statusChanged', orderStatusHandler)
|
||||||
|
es.removeEventListener('order:updated', orderUpdatedHandler)
|
||||||
|
es.removeEventListener('order:new', orderNewHandler)
|
||||||
|
es.close()
|
||||||
|
sourceRef.current = null
|
||||||
|
}
|
||||||
|
}, [token, queryClient])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { SseProvider } from '../SseProvider'
|
||||||
|
|
||||||
|
const mockInvalidateQueries = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('@tanstack/react-query', async () => {
|
||||||
|
const actual = await vi.importActual('@tanstack/react-query')
|
||||||
|
return { ...actual, useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/shared/model/auth', () => ({
|
||||||
|
$token: {
|
||||||
|
defaultState: null,
|
||||||
|
subscribe: () => () => {},
|
||||||
|
getState: () => null,
|
||||||
|
watch: () => () => {},
|
||||||
|
on: () => {},
|
||||||
|
reset: () => {},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
let mockToken: string | null = null
|
||||||
|
let mockEventHandlers: Record<string, (event: MessageEvent) => void> = {}
|
||||||
|
let mockCloseCalls = 0
|
||||||
|
|
||||||
|
class MockEventSource {
|
||||||
|
url: string
|
||||||
|
constructor(url: string) {
|
||||||
|
this.url = url
|
||||||
|
mockCloseCalls = 0
|
||||||
|
mockEventHandlers = {}
|
||||||
|
}
|
||||||
|
addEventListener(type: string, handler: (event: MessageEvent) => void) {
|
||||||
|
mockEventHandlers[type] = handler
|
||||||
|
}
|
||||||
|
removeEventListener(type: string, _handler: (event: MessageEvent) => void) {
|
||||||
|
delete mockEventHandlers[type]
|
||||||
|
}
|
||||||
|
close() {
|
||||||
|
mockCloseCalls++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('@/shared/lib/sse', () => ({
|
||||||
|
createEventStream: (token: string) => {
|
||||||
|
mockToken = token
|
||||||
|
return new MockEventSource(`/api/sse/stream?token=${token}`) as unknown as EventSource
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('effector-react', async () => {
|
||||||
|
const actual = await vi.importActual('effector-react')
|
||||||
|
return { ...actual, useUnit: () => mockToken }
|
||||||
|
})
|
||||||
|
|
||||||
|
function renderSse() {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<SseProvider />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SseProvider', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
mockToken = null
|
||||||
|
mockInvalidateQueries.mockReset()
|
||||||
|
mockCloseCalls = 0
|
||||||
|
mockEventHandlers = {}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders nothing (returns null)', () => {
|
||||||
|
mockToken = null
|
||||||
|
const { container } = renderSse()
|
||||||
|
expect(container.innerHTML).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not create EventSource when token is null', () => {
|
||||||
|
mockToken = null
|
||||||
|
renderSse()
|
||||||
|
expect(mockToken).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates EventSource when token is set', () => {
|
||||||
|
mockToken = 'test-jwt'
|
||||||
|
renderSse()
|
||||||
|
expect(mockToken).toBe('test-jwt')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes EventSource on unmount', () => {
|
||||||
|
mockToken = 'test-jwt'
|
||||||
|
const { unmount } = renderSse()
|
||||||
|
expect(mockCloseCalls).toBe(0)
|
||||||
|
unmount()
|
||||||
|
expect(mockCloseCalls).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('invalidates unread-count and conversations on message:new', () => {
|
||||||
|
mockToken = 'test-jwt'
|
||||||
|
renderSse()
|
||||||
|
const handler = mockEventHandlers['message:new']
|
||||||
|
expect(handler).toBeDefined()
|
||||||
|
handler(new MessageEvent('message:new', { data: JSON.stringify({ orderId: 'o1' }) }))
|
||||||
|
|
||||||
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'messages', 'unread-count'] })
|
||||||
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'conversations'] })
|
||||||
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders'] })
|
||||||
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o1'] })
|
||||||
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'detail', 'o1'] })
|
||||||
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] })
|
||||||
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'summary'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('invalidates order queries on order:statusChanged', () => {
|
||||||
|
mockToken = 'test-jwt'
|
||||||
|
renderSse()
|
||||||
|
const handler = mockEventHandlers['order:statusChanged']
|
||||||
|
handler(new MessageEvent('order:statusChanged', { data: JSON.stringify({ orderId: 'o2' }) }))
|
||||||
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders'] })
|
||||||
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o2'] })
|
||||||
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'detail', 'o2'] })
|
||||||
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] })
|
||||||
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'summary'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('invalidates order queries on order:updated', () => {
|
||||||
|
mockToken = 'test-jwt'
|
||||||
|
renderSse()
|
||||||
|
const handler = mockEventHandlers['order:updated']
|
||||||
|
handler(new MessageEvent('order:updated', { data: JSON.stringify({ orderId: 'o3' }) }))
|
||||||
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders'] })
|
||||||
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o3'] })
|
||||||
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'detail', 'o3'] })
|
||||||
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] })
|
||||||
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'summary'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('invalidates admin queries on order:new', () => {
|
||||||
|
mockToken = 'test-jwt'
|
||||||
|
renderSse()
|
||||||
|
const handler = mockEventHandlers['order:new']
|
||||||
|
handler(new MessageEvent('order:new', { data: JSON.stringify({ orderId: 'o4' }) }))
|
||||||
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'summary'] })
|
||||||
|
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles invalid JSON gracefully', () => {
|
||||||
|
mockToken = 'test-jwt'
|
||||||
|
renderSse()
|
||||||
|
const handler = mockEventHandlers['message:new']
|
||||||
|
expect(() => {
|
||||||
|
handler(new MessageEvent('message:new', { data: ':heartbit' }))
|
||||||
|
}).not.toThrow()
|
||||||
|
expect(mockInvalidateQueries).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { createContext, type PropsWithChildren, useContext, useEffect, useMemo, useState } from 'react'
|
||||||
|
import type { PaletteMode } from '@mui/material'
|
||||||
|
import type { ColorScheme, ThemeModePreference } from '@/shared/model/theme'
|
||||||
|
|
||||||
|
export type ThemeSettings = {
|
||||||
|
mode: ThemeModePreference
|
||||||
|
scheme: ColorScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ThemeController = ThemeSettings & {
|
||||||
|
/** Итоговый режим, учитывая system. */
|
||||||
|
resolvedMode: PaletteMode
|
||||||
|
setMode: (mode: ThemeModePreference) => void
|
||||||
|
toggleMode: () => void
|
||||||
|
cycleMode: () => void
|
||||||
|
setScheme: (scheme: ColorScheme) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const THEME_STORAGE_KEY = 'craftshop_theme'
|
||||||
|
|
||||||
|
function readStoredTheme(): ThemeSettings | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(THEME_STORAGE_KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
const mode: unknown = parsed?.mode
|
||||||
|
const scheme: unknown = parsed?.scheme
|
||||||
|
const modeOk = mode === 'light' || mode === 'dark' || mode === 'system'
|
||||||
|
const schemeOk = scheme === 'craft' || scheme === 'forest' || scheme === 'ocean' || scheme === 'berry'
|
||||||
|
if (!modeOk || !schemeOk) return null
|
||||||
|
return { mode, scheme }
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[theme] Failed to read stored theme', err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSystemMode(): PaletteMode {
|
||||||
|
if (typeof window === 'undefined') return 'light'
|
||||||
|
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMode(pref: ThemeModePreference): PaletteMode {
|
||||||
|
return pref === 'system' ? getSystemMode() : pref
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeControllerContext = createContext<ThemeController | null>(null)
|
||||||
|
|
||||||
|
export function useThemeController(): ThemeController {
|
||||||
|
const ctx = useContext(ThemeControllerContext)
|
||||||
|
if (!ctx) throw new Error('useThemeController must be used within ThemeControllerProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeControllerProvider({ children }: PropsWithChildren) {
|
||||||
|
const [settings, setSettings] = useState<ThemeSettings>(
|
||||||
|
() => readStoredTheme() ?? { mode: 'system', scheme: 'craft' },
|
||||||
|
)
|
||||||
|
|
||||||
|
const [systemMode, setSystemMode] = useState<PaletteMode>(() => getSystemMode())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mql = window.matchMedia?.('(prefers-color-scheme: dark)')
|
||||||
|
if (!mql) return
|
||||||
|
|
||||||
|
const handler = () => setSystemMode(mql.matches ? 'dark' : 'light')
|
||||||
|
|
||||||
|
// начальное значение
|
||||||
|
handler()
|
||||||
|
|
||||||
|
if (typeof mql.addEventListener === 'function') {
|
||||||
|
mql.addEventListener('change', handler)
|
||||||
|
return () => mql.removeEventListener('change', handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safari старых версий
|
||||||
|
mql.addListener(handler)
|
||||||
|
return () => mql.removeListener(handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(settings))
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[theme] Failed to persist theme setting', err)
|
||||||
|
}
|
||||||
|
}, [settings])
|
||||||
|
|
||||||
|
const resolvedMode = settings.mode === 'system' ? systemMode : settings.mode
|
||||||
|
|
||||||
|
const controller = useMemo<ThemeController>(
|
||||||
|
() => ({
|
||||||
|
mode: settings.mode,
|
||||||
|
resolvedMode,
|
||||||
|
scheme: settings.scheme,
|
||||||
|
setMode: (mode) => setSettings((s) => ({ ...s, mode })),
|
||||||
|
toggleMode: () =>
|
||||||
|
setSettings((s) => ({
|
||||||
|
...s,
|
||||||
|
mode: resolveMode(s.mode) === 'light' ? 'dark' : 'light',
|
||||||
|
})),
|
||||||
|
cycleMode: () =>
|
||||||
|
setSettings((s) => ({
|
||||||
|
...s,
|
||||||
|
mode: s.mode === 'system' ? 'light' : s.mode === 'light' ? 'dark' : 'system',
|
||||||
|
})),
|
||||||
|
setScheme: (scheme) => setSettings((s) => ({ ...s, scheme })),
|
||||||
|
}),
|
||||||
|
[resolvedMode, settings.mode, settings.scheme],
|
||||||
|
)
|
||||||
|
|
||||||
|
return <ThemeControllerContext.Provider value={controller}>{children}</ThemeControllerContext.Provider>
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { lazy, Suspense } from 'react'
|
||||||
|
import { Route, Routes } from 'react-router-dom'
|
||||||
|
import { MainLayout } from '@/app/layout/MainLayout'
|
||||||
|
import { usePageTitleReset } from '@/shared/lib/use-page-title'
|
||||||
|
import { SkeletonPage } from '@/shared/ui/SkeletonPage'
|
||||||
|
|
||||||
|
const AdminLayoutPage = lazy(() => import('@/pages/admin-layout').then((m) => ({ default: m.AdminLayoutPage })))
|
||||||
|
const MeLayoutPage = lazy(() => import('@/pages/me').then((m) => ({ default: m.MeLayoutPage })))
|
||||||
|
|
||||||
|
const HomePage = lazy(() => import('@/pages/home').then((m) => ({ default: m.HomePage })))
|
||||||
|
const AuthPage = lazy(() => import('@/pages/auth').then((m) => ({ default: m.AuthPage })))
|
||||||
|
const AuthCallbackPage = lazy(() => import('@/pages/auth').then((m) => ({ default: m.AuthCallbackPage })))
|
||||||
|
const CartPage = lazy(() => import('@/pages/cart').then((m) => ({ default: m.CartPage })))
|
||||||
|
const CheckoutPage = lazy(() => import('@/pages/checkout').then((m) => ({ default: m.CheckoutPage })))
|
||||||
|
const AboutPage = lazy(() => import('@/pages/about').then((m) => ({ default: m.AboutPage })))
|
||||||
|
const InfoPage = lazy(() => import('@/pages/info').then((m) => ({ default: m.InfoPage })))
|
||||||
|
const PrivacyPolicyPage = lazy(() => import('@/pages/privacy-policy').then((m) => ({ default: m.PrivacyPolicyPage })))
|
||||||
|
const TermsPage = lazy(() => import('@/pages/terms').then((m) => ({ default: m.TermsPage })))
|
||||||
|
const ProductPage = lazy(() => import('@/pages/product').then((m) => ({ default: m.ProductPage })))
|
||||||
|
const NotFoundPage = lazy(() => import('@/pages/not-found').then((m) => ({ default: m.NotFoundPage })))
|
||||||
|
|
||||||
|
export function AppRoutes() {
|
||||||
|
usePageTitleReset()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MainLayout>
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<Suspense fallback={<SkeletonPage />}>
|
||||||
|
<HomePage />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/*"
|
||||||
|
element={
|
||||||
|
<Suspense fallback={<SkeletonPage />}>
|
||||||
|
<AdminLayoutPage />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/auth"
|
||||||
|
element={
|
||||||
|
<Suspense fallback={<SkeletonPage />}>
|
||||||
|
<AuthPage />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/auth/callback"
|
||||||
|
element={
|
||||||
|
<Suspense fallback={<SkeletonPage />}>
|
||||||
|
<AuthCallbackPage />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/cart"
|
||||||
|
element={
|
||||||
|
<Suspense fallback={<SkeletonPage />}>
|
||||||
|
<CartPage />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/checkout"
|
||||||
|
element={
|
||||||
|
<Suspense fallback={<SkeletonPage />}>
|
||||||
|
<CheckoutPage />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/about"
|
||||||
|
element={
|
||||||
|
<Suspense fallback={<SkeletonPage />}>
|
||||||
|
<AboutPage />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/info"
|
||||||
|
element={
|
||||||
|
<Suspense fallback={<SkeletonPage />}>
|
||||||
|
<InfoPage />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/privacy"
|
||||||
|
element={
|
||||||
|
<Suspense fallback={<SkeletonPage />}>
|
||||||
|
<PrivacyPolicyPage />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/terms"
|
||||||
|
element={
|
||||||
|
<Suspense fallback={<SkeletonPage />}>
|
||||||
|
<TermsPage />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/me/*"
|
||||||
|
element={
|
||||||
|
<Suspense fallback={<SkeletonPage />}>
|
||||||
|
<MeLayoutPage />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/products/:id"
|
||||||
|
element={
|
||||||
|
<Suspense fallback={<SkeletonPage />}>
|
||||||
|
<ProductPage />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="*"
|
||||||
|
element={
|
||||||
|
<Suspense fallback={<SkeletonPage />}>
|
||||||
|
<NotFoundPage />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</MainLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: 'Outfit';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url('/fonts/Outfit-Regular.woff2') format('woff2');
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Outfit';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
src: url('/fonts/Outfit-Medium.woff2') format('woff2');
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Outfit';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
src: url('/fonts/Outfit-SemiBold.woff2') format('woff2');
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Outfit';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
src: url('/fonts/Outfit-Bold.woff2') format('woff2');
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
import type { CartItem } from '@/entities/cart/model/types'
|
||||||
|
import { apiClient } from '@/shared/api/client'
|
||||||
|
|
||||||
|
export type CartResponse = { items: CartItem[] }
|
||||||
|
|
||||||
|
export async function fetchMyCart(): Promise<CartResponse> {
|
||||||
|
const { data } = await apiClient.get<CartResponse>('me/cart')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addToCart(body: { productId: string; qty?: number }): Promise<void> {
|
||||||
|
await apiClient.post('me/cart/items', body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setCartQty(id: string, qty: number): Promise<void> {
|
||||||
|
await apiClient.patch(`me/cart/items/${id}`, { qty })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeCartItem(id: string): Promise<void> {
|
||||||
|
await apiClient.delete(`me/cart/items/${id}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export type { CartItem } from './model/types'
|
||||||
|
export { fetchMyCart, addToCart, setCartQty, removeCartItem } from './api/cart-api'
|
||||||
|
export type { CartResponse } from './api/cart-api'
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useUnit } from 'effector-react'
|
||||||
|
import { fetchMyCart } from '../api/cart-api'
|
||||||
|
import { $user } from '@/shared/model/auth'
|
||||||
|
|
||||||
|
export function useCartQuery() {
|
||||||
|
const user = useUnit($user)
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['me', 'cart'],
|
||||||
|
queryFn: fetchMyCart,
|
||||||
|
enabled: Boolean(user),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { Product } from '@/entities/product/model/types'
|
||||||
|
|
||||||
|
export type CartItem = {
|
||||||
|
id: string
|
||||||
|
qty: number
|
||||||
|
product: Product
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { apiClient } from '@/shared/api/client'
|
||||||
|
import type { CatalogSliderSlide, AdminCatalogSliderSlide } from '../model/types'
|
||||||
|
|
||||||
|
export async function fetchCatalogSlider(): Promise<{ slides: CatalogSliderSlide[] }> {
|
||||||
|
const { data } = await apiClient.get<{ slides: CatalogSliderSlide[] }>('catalog-slider')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminCatalogSlider(): Promise<{ slides: AdminCatalogSliderSlide[] }> {
|
||||||
|
const { data } = await apiClient.get<{ slides: AdminCatalogSliderSlide[] }>('admin/catalog-slider')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putAdminCatalogSlider(body: {
|
||||||
|
slides: Array<{ galleryImageId: string; caption: string; textColor?: string }>
|
||||||
|
}): Promise<{ slides: AdminCatalogSliderSlide[] }> {
|
||||||
|
const { data } = await apiClient.put<{ slides: AdminCatalogSliderSlide[] }>('admin/catalog-slider', body)
|
||||||
|
return data
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { fetchCatalogSlider, fetchAdminCatalogSlider, putAdminCatalogSlider } from './api/catalog-slider-api'
|
||||||
|
export type { CatalogSliderSlide, AdminCatalogSliderSlide } from './model/types'
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export type CatalogSliderSlide = {
|
||||||
|
id: string
|
||||||
|
url: string
|
||||||
|
caption: string
|
||||||
|
textColor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminCatalogSliderSlide = CatalogSliderSlide & {
|
||||||
|
galleryImageId: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { GalleryImageItem } from '@/entities/gallery/model/types'
|
||||||
|
import { apiClient } from '@/shared/api/client'
|
||||||
|
import { apiBaseURL } from '@/shared/config'
|
||||||
|
import { ADMIN_UPLOAD_IMAGE_MAX_BYTES, formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
|
||||||
|
|
||||||
|
export async function fetchAdminGallery(): Promise<{ items: GalleryImageItem[] }> {
|
||||||
|
const { data } = await apiClient.get<{ items: GalleryImageItem[] }>('admin/gallery')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteGalleryImage(id: string): Promise<void> {
|
||||||
|
await apiClient.delete(`admin/gallery/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadGalleryImages(files: File[]): Promise<string[]> {
|
||||||
|
for (const f of files) {
|
||||||
|
if (f.size > ADMIN_UPLOAD_IMAGE_MAX_BYTES) {
|
||||||
|
throw new Error(
|
||||||
|
`Файл «${f.name}» слишком большой (максимум ${formatAdminImageMaxSizeHint()} на одно изображение).`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fd = new FormData()
|
||||||
|
for (const f of files) {
|
||||||
|
fd.append('files', f, f.name)
|
||||||
|
}
|
||||||
|
const token = localStorage.getItem('craftshop_auth_token')
|
||||||
|
const base = apiBaseURL.replace(/\/$/, '')
|
||||||
|
const res = await fetch(`${base}/admin/gallery/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
body: fd,
|
||||||
|
})
|
||||||
|
const payload = (await res.json().catch(() => ({}))) as { urls?: string[]; error?: string }
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 413) {
|
||||||
|
throw new Error(
|
||||||
|
'Сервер отклонил файл как слишком большой (413). На проде часто лимит nginx: добавьте client_max_body_size для /api/ (см. docs/nginx-upload-limit.md). Проверьте также MAX_UPLOAD_BODY_BYTES в .env на сервере.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
throw new Error(typeof payload.error === 'string' ? payload.error : `Ошибка загрузки (${res.status})`)
|
||||||
|
}
|
||||||
|
if (!Array.isArray(payload.urls)) {
|
||||||
|
throw new Error('Некорректный ответ сервера')
|
||||||
|
}
|
||||||
|
return payload.urls
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resizeGalleryImage(id: string): Promise<{ url: string }> {
|
||||||
|
const { data } = await apiClient.post<{ url: string }>(`admin/gallery/${id}/resize`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { fetchAdminGallery, deleteGalleryImage, uploadGalleryImages, resizeGalleryImage } from './api/gallery-api'
|
||||||
|
export type { GalleryImageItem } from './model/types'
|
||||||
|
export { GalleryGrid } from './ui/GalleryGrid'
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export type GalleryImageItem = {
|
||||||
|
id: string
|
||||||
|
url: string
|
||||||
|
isResized: boolean
|
||||||
|
createdAt: string
|
||||||
|
inUse?: boolean
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import AutoFixHighOutlinedIcon from '@mui/icons-material/AutoFixHighOutlined'
|
||||||
|
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'
|
||||||
|
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'
|
||||||
|
import Box from '@mui/material/Box'
|
||||||
|
import Chip from '@mui/material/Chip'
|
||||||
|
import IconButton from '@mui/material/IconButton'
|
||||||
|
import Tooltip from '@mui/material/Tooltip'
|
||||||
|
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
|
||||||
|
import type { GalleryImageItem } from '../model/types'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
items: GalleryImageItem[]
|
||||||
|
deleting?: boolean
|
||||||
|
resizing?: string | null
|
||||||
|
onDelete: (id: string) => void
|
||||||
|
onResize: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GalleryGrid({ items, deleting, resizing, onDelete, onResize }: Props) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((item) => (
|
||||||
|
<Box
|
||||||
|
key={item.id}
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
aspectRatio: '1',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<OptimizedImage
|
||||||
|
src={item.url}
|
||||||
|
alt=""
|
||||||
|
sizes="140px"
|
||||||
|
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||||
|
/>
|
||||||
|
<Box sx={{ position: 'absolute', top: 4, left: 4 }}>
|
||||||
|
{item.isResized ? (
|
||||||
|
<Chip
|
||||||
|
label="Готово"
|
||||||
|
size="small"
|
||||||
|
color="success"
|
||||||
|
icon={<CheckCircleOutlineOutlinedIcon fontSize="small" />}
|
||||||
|
sx={{ height: 24, '& .MuiChip-label': { px: 0.75 }, '& .MuiChip-icon': { fontSize: 14, ml: 0.5 } }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Chip
|
||||||
|
label="Не обработано"
|
||||||
|
size="small"
|
||||||
|
color="warning"
|
||||||
|
sx={{ height: 24, '& .MuiChip-label': { px: 0.75 } }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ position: 'absolute', top: 4, right: 4, display: 'flex', gap: 0.5 }}>
|
||||||
|
{!item.isResized && (
|
||||||
|
<Tooltip title="Обработать (resize)">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
sx={{
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
'&:hover': { bgcolor: 'primary.light', color: 'primary.contrastText' },
|
||||||
|
}}
|
||||||
|
disabled={resizing === item.id}
|
||||||
|
onClick={() => onResize(item.id)}
|
||||||
|
>
|
||||||
|
<AutoFixHighOutlinedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip title="Удалить из галереи">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
sx={{
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
'&:hover': { bgcolor: 'error.light', color: 'error.contrastText' },
|
||||||
|
}}
|
||||||
|
disabled={deleting}
|
||||||
|
onClick={() => onDelete(item.id)}
|
||||||
|
>
|
||||||
|
<DeleteOutlineOutlinedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { apiClient } from '@/shared/api/client'
|
||||||
|
|
||||||
|
export interface UserNotificationSettings {
|
||||||
|
id: string
|
||||||
|
userId: string
|
||||||
|
globalEnabled: boolean
|
||||||
|
orderCreated: boolean
|
||||||
|
orderStatusChanged: boolean
|
||||||
|
orderMessageReceived: boolean
|
||||||
|
paymentStatusChanged: boolean
|
||||||
|
deliveryFeeAdjusted: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminNotificationSettings {
|
||||||
|
id: string
|
||||||
|
emailEnabled: boolean
|
||||||
|
telegramEnabled: boolean
|
||||||
|
telegramChatId: string | null
|
||||||
|
newOrder: boolean
|
||||||
|
newOrderMessage: boolean
|
||||||
|
newReview: boolean
|
||||||
|
authCodeDuplicate: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUserNotificationSettings(): Promise<{ settings: UserNotificationSettings }> {
|
||||||
|
const { data } = await apiClient.get<{ settings: UserNotificationSettings }>('me/notifications/settings')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserNotificationSettings(
|
||||||
|
settings: Partial<UserNotificationSettings>,
|
||||||
|
): Promise<{ settings: UserNotificationSettings }> {
|
||||||
|
const { data } = await apiClient.put<{ settings: UserNotificationSettings }>('me/notifications/settings', settings)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminNotificationSettings(): Promise<{ settings: AdminNotificationSettings }> {
|
||||||
|
const { data } = await apiClient.get<{ settings: AdminNotificationSettings }>('admin/notifications/settings')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAdminNotificationSettings(
|
||||||
|
settings: Partial<AdminNotificationSettings>,
|
||||||
|
): Promise<{ settings: AdminNotificationSettings }> {
|
||||||
|
const { data } = await apiClient.put<{ settings: AdminNotificationSettings }>(
|
||||||
|
'admin/notifications/settings',
|
||||||
|
settings,
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export {
|
||||||
|
fetchUserNotificationSettings,
|
||||||
|
updateUserNotificationSettings,
|
||||||
|
fetchAdminNotificationSettings,
|
||||||
|
updateAdminNotificationSettings,
|
||||||
|
} from './api/notifications-api'
|
||||||
|
export type { UserNotificationSettings, AdminNotificationSettings } from './api/notifications-api'
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { apiClient } from '@/shared/api/client'
|
||||||
|
|
||||||
|
export type AdminOrderListItem = {
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
deliveryType: 'delivery' | 'pickup'
|
||||||
|
deliveryFeeLocked: boolean
|
||||||
|
deliveryCarrier?: string | null
|
||||||
|
paymentMethod?: 'online' | 'on_pickup'
|
||||||
|
totalCents: number
|
||||||
|
currency: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
user: { id: string; email: string }
|
||||||
|
itemsCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminOrdersListResponse = {
|
||||||
|
items: AdminOrderListItem[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminOrderDetailResponse = {
|
||||||
|
item: {
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
deliveryType: 'delivery' | 'pickup'
|
||||||
|
deliveryCarrier?: string | null
|
||||||
|
paymentMethod?: 'online' | 'on_pickup'
|
||||||
|
itemsSubtotalCents: number
|
||||||
|
deliveryFeeCents: number
|
||||||
|
deliveryFeeLocked: boolean
|
||||||
|
totalCents: number
|
||||||
|
currency: string
|
||||||
|
addressSnapshotJson: string | null
|
||||||
|
comment: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
user: {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
displayName: string | null
|
||||||
|
avatar?: string | null
|
||||||
|
avatarStyle?: string | null
|
||||||
|
}
|
||||||
|
items: Array<{
|
||||||
|
id: string
|
||||||
|
productId: string
|
||||||
|
qty: number
|
||||||
|
titleSnapshot: string
|
||||||
|
priceCentsSnapshot: number
|
||||||
|
}>
|
||||||
|
messages: Array<{
|
||||||
|
id: string
|
||||||
|
authorType: string
|
||||||
|
text: string
|
||||||
|
attachmentUrl?: string | null
|
||||||
|
createdAt: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminOrdersSummary(): Promise<{ attentionCount: number }> {
|
||||||
|
const { data } = await apiClient.get<{ attentionCount: number }>('admin/orders/summary')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminOrders(params?: {
|
||||||
|
status?: string
|
||||||
|
deliveryType?: 'delivery' | 'pickup'
|
||||||
|
q?: string
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}): Promise<AdminOrdersListResponse> {
|
||||||
|
const { data } = await apiClient.get<AdminOrdersListResponse>('admin/orders', { params })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminOrder(id: string): Promise<AdminOrderDetailResponse> {
|
||||||
|
const { data } = await apiClient.get<AdminOrderDetailResponse>(`admin/orders/${id}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setAdminOrderStatus(id: string, status: string): Promise<void> {
|
||||||
|
await apiClient.patch(`admin/orders/${id}/status`, { status })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchAdminOrderDeliveryFee(id: string, deliveryFeeCents: number): Promise<void> {
|
||||||
|
await apiClient.patch(`admin/orders/${id}/delivery-fee`, { deliveryFeeCents })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postAdminOrderMessage(id: string, text: string): Promise<void> {
|
||||||
|
await apiClient.post(`admin/orders/${id}/messages`, { text })
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { apiClient } from '@/shared/api/client'
|
||||||
|
import type { DeliveryCarrierCode } from '@/shared/constants/delivery-carrier'
|
||||||
|
|
||||||
|
export type OrderListItem = {
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
totalCents: number
|
||||||
|
currency: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
itemsCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OrderListResponse = { items: OrderListItem[] }
|
||||||
|
|
||||||
|
export type OrderPaymentMethod = 'online' | 'on_pickup'
|
||||||
|
|
||||||
|
export type OrderDetailResponse = {
|
||||||
|
item: {
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
deliveryType: 'delivery' | 'pickup'
|
||||||
|
deliveryCarrier?: DeliveryCarrierCode | null
|
||||||
|
paymentMethod?: OrderPaymentMethod
|
||||||
|
itemsSubtotalCents: number
|
||||||
|
deliveryFeeCents: number
|
||||||
|
deliveryFeeLocked: boolean
|
||||||
|
totalCents: number
|
||||||
|
currency: string
|
||||||
|
addressSnapshotJson: string | null
|
||||||
|
comment: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
items: Array<{
|
||||||
|
id: string
|
||||||
|
productId: string
|
||||||
|
qty: number
|
||||||
|
titleSnapshot: string
|
||||||
|
priceCentsSnapshot: number
|
||||||
|
}>
|
||||||
|
messages: Array<{
|
||||||
|
id: string
|
||||||
|
authorType: 'user' | 'admin'
|
||||||
|
text: string
|
||||||
|
attachmentUrl?: string | null
|
||||||
|
createdAt: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createOrder(body: {
|
||||||
|
deliveryType: 'delivery' | 'pickup'
|
||||||
|
deliveryCarrier?: DeliveryCarrierCode | null
|
||||||
|
paymentMethod?: OrderPaymentMethod
|
||||||
|
addressId?: string | null
|
||||||
|
comment?: string | null
|
||||||
|
}): Promise<{ orderId: string }> {
|
||||||
|
const { data } = await apiClient.post<{ orderId: string }>('me/orders', body)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMyOrders(): Promise<OrderListResponse> {
|
||||||
|
const { data } = await apiClient.get<OrderListResponse>('me/orders')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMyOrder(id: string): Promise<OrderDetailResponse> {
|
||||||
|
const { data } = await apiClient.get<OrderDetailResponse>(`me/orders/${id}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Создать платёж в ЮKassa и получить URL для редиректа на форму оплаты. */
|
||||||
|
export async function createOrderPayment(orderId: string): Promise<{ confirmationUrl: string }> {
|
||||||
|
const { data } = await apiClient.post<{ confirmationUrl: string }>(`me/orders/${orderId}/pay`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Получить статус платежа для заказа. */
|
||||||
|
export async function getOrderPaymentStatus(orderId: string): Promise<{ status: string | null; paid: boolean }> {
|
||||||
|
const { data } = await apiClient.get<{ status: string | null; paid: boolean }>(`me/orders/${orderId}/payment`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postOrderMessage(id: string, text: string): Promise<void> {
|
||||||
|
await apiClient.post(`me/orders/${id}/messages`, { text })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmOrderReceived(id: string): Promise<{ ok: boolean; status: string }> {
|
||||||
|
const { data } = await apiClient.post<{ ok: boolean; status: string }>(`me/orders/${id}/confirm-received`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReviewEligibilityItem = { productId: string; title: string; hasReview: boolean }
|
||||||
|
|
||||||
|
export async function fetchOrderReviewEligibility(orderId: string): Promise<{
|
||||||
|
canReview: boolean
|
||||||
|
items: ReviewEligibilityItem[]
|
||||||
|
}> {
|
||||||
|
const { data } = await apiClient.get<{ canReview: boolean; items: ReviewEligibilityItem[] }>(
|
||||||
|
`me/orders/${orderId}/review-eligibility`,
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export {
|
||||||
|
fetchMyOrders,
|
||||||
|
createOrder,
|
||||||
|
confirmOrderReceived,
|
||||||
|
fetchMyOrder,
|
||||||
|
fetchOrderReviewEligibility,
|
||||||
|
} from './api/order-api'
|
||||||
|
export { createOrderPayment, getOrderPaymentStatus, postOrderMessage } from './api/order-api'
|
||||||
|
export type { OrderListResponse, OrderDetailResponse } from './api/order-api'
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import type { Category, Product } from '@/entities/product/model/types'
|
||||||
|
import { apiClient } from '@/shared/api/client'
|
||||||
|
import { apiBaseURL } from '@/shared/config'
|
||||||
|
import { ADMIN_UPLOAD_IMAGE_MAX_BYTES, formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
|
||||||
|
|
||||||
|
export async function fetchAdminProducts(): Promise<Product[]> {
|
||||||
|
const { data } = await apiClient.get<Product[]>('admin/products')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createProduct(body: {
|
||||||
|
title: string
|
||||||
|
slug?: string
|
||||||
|
shortDescription?: string | null
|
||||||
|
description?: string | null
|
||||||
|
quantity: number
|
||||||
|
materials?: string[]
|
||||||
|
priceCents: number
|
||||||
|
imageUrl?: string | null
|
||||||
|
imageUrls?: string[]
|
||||||
|
published: boolean
|
||||||
|
categoryId: string
|
||||||
|
}): Promise<Product> {
|
||||||
|
const { data } = await apiClient.post<Product>('admin/products', body)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProduct(
|
||||||
|
id: string,
|
||||||
|
body: Partial<{
|
||||||
|
title: string
|
||||||
|
slug: string
|
||||||
|
shortDescription: string | null
|
||||||
|
description: string | null
|
||||||
|
quantity: number
|
||||||
|
materials: string[]
|
||||||
|
priceCents: number
|
||||||
|
imageUrl: string | null
|
||||||
|
imageUrls: string[]
|
||||||
|
published: boolean
|
||||||
|
categoryId: string
|
||||||
|
}>,
|
||||||
|
): Promise<Product> {
|
||||||
|
const { data } = await apiClient.patch<Product>(`admin/products/${id}`, body)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProduct(id: string): Promise<void> {
|
||||||
|
await apiClient.delete(`admin/products/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCategory(body: { name: string; slug?: string; sort?: number }): Promise<Category> {
|
||||||
|
const { data } = await apiClient.post<Category>('admin/categories', body)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminCategories(): Promise<Category[]> {
|
||||||
|
const { data } = await apiClient.get<{ items: Category[] }>('admin/categories')
|
||||||
|
return data.items
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAdminCategory(
|
||||||
|
id: string,
|
||||||
|
body: Partial<{ name: string; slug: string; sort: number }>,
|
||||||
|
): Promise<Category> {
|
||||||
|
const { data } = await apiClient.patch<Category>(`admin/categories/${id}`, body)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAdminCategory(id: string): Promise<void> {
|
||||||
|
await apiClient.delete(`admin/categories/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FormData: не задавать Content-Type вручную (boundary задаёт браузер). */
|
||||||
|
export async function uploadAdminProductImages(files: FileList | readonly File[]): Promise<string[]> {
|
||||||
|
const list = Array.from(files)
|
||||||
|
for (const f of list) {
|
||||||
|
if (f.size > ADMIN_UPLOAD_IMAGE_MAX_BYTES) {
|
||||||
|
throw new Error(
|
||||||
|
`Файл «${f.name}» слишком большой (максимум ${formatAdminImageMaxSizeHint()} на одно изображение).`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fd = new FormData()
|
||||||
|
for (const f of list) {
|
||||||
|
fd.append('files', f, f.name)
|
||||||
|
}
|
||||||
|
const token = localStorage.getItem('craftshop_auth_token')
|
||||||
|
const base = apiBaseURL.replace(/\/$/, '')
|
||||||
|
const res = await fetch(`${base}/admin/uploads`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
body: fd,
|
||||||
|
})
|
||||||
|
const payload = (await res.json().catch(() => ({}))) as { urls?: string[]; error?: string }
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 413) {
|
||||||
|
throw new Error(
|
||||||
|
'Сервер отклонил файл как слишком большой (413). На проде часто лимит nginx: добавьте client_max_body_size для /api/ (см. docs/nginx-upload-limit.md). Проверьте также MAX_UPLOAD_BODY_BYTES в .env на сервере.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
throw new Error(typeof payload.error === 'string' ? payload.error : `Ошибка загрузки (${res.status})`)
|
||||||
|
}
|
||||||
|
if (!Array.isArray(payload.urls)) {
|
||||||
|
throw new Error('Некорректный ответ сервера')
|
||||||
|
}
|
||||||
|
return payload.urls
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Category, Product } from '@/entities/product/model/types'
|
||||||
|
import { apiClient } from '@/shared/api/client'
|
||||||
|
|
||||||
|
export type PublicProductsResponse = {
|
||||||
|
items: Product[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPublicProducts(params?: {
|
||||||
|
categorySlug?: string
|
||||||
|
q?: string
|
||||||
|
sort?: 'price_asc' | 'price_desc' | ''
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
priceMinCents?: number
|
||||||
|
priceMaxCents?: number
|
||||||
|
}): Promise<PublicProductsResponse> {
|
||||||
|
const { data } = await apiClient.get<PublicProductsResponse>('products', {
|
||||||
|
params: {
|
||||||
|
categorySlug: params?.categorySlug || undefined,
|
||||||
|
q: params?.q || undefined,
|
||||||
|
sort: params?.sort || undefined,
|
||||||
|
page: params?.page || undefined,
|
||||||
|
pageSize: params?.pageSize || undefined,
|
||||||
|
priceMin: params?.priceMinCents ?? undefined,
|
||||||
|
priceMax: params?.priceMaxCents ?? undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPublicProduct(id: string): Promise<Product> {
|
||||||
|
const { data } = await apiClient.get<Product>(`products/${id}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCategories(): Promise<Category[]> {
|
||||||
|
const { data } = await apiClient.get<Category[]>('categories')
|
||||||
|
return data
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { fetchPublicProducts, fetchPublicProduct, fetchCategories } from './api/product-api'
|
||||||
|
export type { PublicProductsResponse } from './api/product-api'
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
export type Category = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
sort: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProductReviewsSummary = {
|
||||||
|
approvedReviewCount: number
|
||||||
|
avgRating: number | null
|
||||||
|
latestApprovedText: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Product = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
slug: string
|
||||||
|
shortDescription: string | null
|
||||||
|
description: string | null
|
||||||
|
quantity: number
|
||||||
|
materials?: string[]
|
||||||
|
priceCents: number
|
||||||
|
imageUrl: string | null
|
||||||
|
imageUrls?: string[] // legacy-friendly (used only in admin payloads)
|
||||||
|
published: boolean
|
||||||
|
categoryId: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
category?: Category
|
||||||
|
images?: { id: string; url: string; sort: number }[]
|
||||||
|
/** Для опубликованных товаров с публичного API. */
|
||||||
|
reviewsSummary?: ProductReviewsSummary | null
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import * as React from 'react'
|
||||||
|
import { useCallback, useMemo, useRef } from 'react'
|
||||||
|
import { useMediaQuery } from '@mui/material'
|
||||||
|
import Box from '@mui/material/Box'
|
||||||
|
import Card from '@mui/material/Card'
|
||||||
|
import CardMedia from '@mui/material/CardMedia'
|
||||||
|
import Chip from '@mui/material/Chip'
|
||||||
|
import Stack from '@mui/material/Stack'
|
||||||
|
import Typography from '@mui/material/Typography'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Swiper, SwiperSlide } from 'swiper/react'
|
||||||
|
import 'swiper/css'
|
||||||
|
import type { Product } from '@/entities/product/model/types'
|
||||||
|
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||||
|
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
|
||||||
|
import type { Swiper as SwiperType } from 'swiper/types'
|
||||||
|
|
||||||
|
type Props = { product: Product; mediaHeight?: number; actions?: ReactNode }
|
||||||
|
|
||||||
|
const ProductCardInner = ({ product, mediaHeight = 390, actions }: Props) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const isMobile = useMediaQuery('(max-width:600px)')
|
||||||
|
const swiperRef = useRef<SwiperType | null>(null)
|
||||||
|
const imageUrls = useMemo(() => {
|
||||||
|
const fromImages = (product.images ?? [])
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.sort - b.sort)
|
||||||
|
.map((x) => x.url)
|
||||||
|
const urls = fromImages.length ? fromImages : product.imageUrl ? [product.imageUrl] : []
|
||||||
|
return urls
|
||||||
|
}, [product.images, product.imageUrl])
|
||||||
|
|
||||||
|
const materials = (product.materials ?? []).slice(0, 3)
|
||||||
|
const moreMaterials = Math.max(0, (product.materials?.length ?? 0) - materials.length)
|
||||||
|
|
||||||
|
const onMouseMove = (e: React.MouseEvent<HTMLElement>) => {
|
||||||
|
if (!swiperRef.current) return
|
||||||
|
if (imageUrls.length <= 1) return
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||||
|
const rel = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width))
|
||||||
|
const idx = Math.min(imageUrls.length - 1, Math.floor(rel * imageUrls.length))
|
||||||
|
swiperRef.current.slideTo(idx, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToProduct = useCallback(() => {
|
||||||
|
navigate(`/products/${product.id}`)
|
||||||
|
}, [navigate, product.id])
|
||||||
|
|
||||||
|
const stockLabel = product.quantity > 0 ? null : { label: 'Нет в наличии', color: 'default' as const }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
onClick={goToProduct}
|
||||||
|
sx={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: '16px 16px 12px 12px',
|
||||||
|
border: 'none',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
|
||||||
|
transition: 'transform 250ms ease, box-shadow 300ms ease',
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'translateY(-6px)',
|
||||||
|
boxShadow: '0 12px 40px rgba(0,0,0,0.12)',
|
||||||
|
},
|
||||||
|
'&:hover .product-card__media': { transform: 'scale(1.06)' },
|
||||||
|
'&:hover .product-card__title': { color: 'primary.main' },
|
||||||
|
'@media (prefers-reduced-motion: reduce)': {
|
||||||
|
transition: 'none',
|
||||||
|
'&:hover': { transform: 'none' },
|
||||||
|
'&:hover .product-card__media': { transform: 'none' },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ position: 'relative' }}>
|
||||||
|
{imageUrls.length ? (
|
||||||
|
<Box
|
||||||
|
onMouseMove={!isMobile ? onMouseMove : undefined}
|
||||||
|
sx={{ width: '100%', aspectRatio: '3/4', maxHeight: mediaHeight, overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<Swiper
|
||||||
|
slidesPerView={1}
|
||||||
|
spaceBetween={16}
|
||||||
|
allowTouchMove={!isMobile}
|
||||||
|
onSwiper={(s) => {
|
||||||
|
swiperRef.current = s
|
||||||
|
}}
|
||||||
|
style={{ width: '100%', height: '100%', overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
{imageUrls.map((url) => (
|
||||||
|
<SwiperSlide key={url}>
|
||||||
|
<Box
|
||||||
|
className="product-card__media"
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
transition: 'transform 320ms ease',
|
||||||
|
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
|
||||||
|
userSelect: 'none',
|
||||||
|
bgcolor: 'grey.50',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<OptimizedImage
|
||||||
|
src={url}
|
||||||
|
alt={product.title}
|
||||||
|
sizes="(max-width: 600px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||||
|
sx={{
|
||||||
|
width: '101%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</SwiperSlide>
|
||||||
|
))}
|
||||||
|
</Swiper>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<CardMedia
|
||||||
|
component="div"
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: '3/4',
|
||||||
|
maxHeight: mediaHeight,
|
||||||
|
bgcolor: 'grey.50',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography color="text.disabled" variant="body2">
|
||||||
|
Нет фото
|
||||||
|
</Typography>
|
||||||
|
</CardMedia>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stockLabel && (
|
||||||
|
<Chip
|
||||||
|
label={stockLabel.label}
|
||||||
|
size="small"
|
||||||
|
color={stockLabel.color}
|
||||||
|
variant="filled"
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 8,
|
||||||
|
left: 8,
|
||||||
|
zIndex: 2,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
bgcolor: 'rgba(0,0,0,0.55)',
|
||||||
|
color: 'common.white',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', p: 2, pb: 2 }}>
|
||||||
|
<Stack spacing={1.25} sx={{ flexGrow: 1 }}>
|
||||||
|
{product.category && (
|
||||||
|
<Chip
|
||||||
|
label={product.category.name}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
sx={{
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.65rem',
|
||||||
|
height: 22,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
variant="subtitle1"
|
||||||
|
component="h2"
|
||||||
|
className="product-card__title"
|
||||||
|
sx={{
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: 'text.primary',
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: 1.3,
|
||||||
|
transition: 'color 150ms ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{product.title}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{(product.materials?.length ?? 0) > 0 && (
|
||||||
|
<Stack direction="row" spacing={0.5} useFlexGap sx={{ flexWrap: 'wrap' }}>
|
||||||
|
{materials.map((m) => (
|
||||||
|
<Chip
|
||||||
|
key={m}
|
||||||
|
label={m}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
bgcolor: 'chip.default',
|
||||||
|
color: 'text.secondary',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
height: 22,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{moreMaterials > 0 && (
|
||||||
|
<Chip
|
||||||
|
label={`+${moreMaterials}`}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
bgcolor: 'chip.default',
|
||||||
|
color: 'text.secondary',
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
height: 22,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
lineHeight: 1.45,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{product.shortDescription ?? 'Описание появится позже.'}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pt: 1.5 }}>
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
component="p"
|
||||||
|
color="primary"
|
||||||
|
sx={{ fontWeight: 700, fontSize: '1.1rem', fontVariantNumeric: 'tabular-nums' }}
|
||||||
|
>
|
||||||
|
{formatPriceRub(product.priceCents)}
|
||||||
|
</Typography>
|
||||||
|
{actions}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProductCard = React.memo(ProductCardInner, (prev, next) => {
|
||||||
|
return prev.product.id === next.product.id && prev.mediaHeight === next.mediaHeight && prev.actions === next.actions
|
||||||
|
})
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { apiClient } from '@/shared/api/client'
|
||||||
|
|
||||||
|
export type AdminReview = {
|
||||||
|
id: string
|
||||||
|
rating: number
|
||||||
|
text: string | null
|
||||||
|
status: string
|
||||||
|
createdAt: string
|
||||||
|
moderatedAt: string | null
|
||||||
|
user: { id: string; email: string; displayName: string | null }
|
||||||
|
product: { id: string; title: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminReviewsListResponse = {
|
||||||
|
items: AdminReview[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminReviews(params?: {
|
||||||
|
status?: string
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}): Promise<AdminReviewsListResponse> {
|
||||||
|
const { data } = await apiClient.get<AdminReviewsListResponse>('admin/reviews', { params })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moderateReview(id: string, action: 'approve' | 'reject'): Promise<void> {
|
||||||
|
await apiClient.patch(`admin/reviews/${id}`, { action })
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { apiClient } from '@/shared/api/client'
|
||||||
|
import { OTHER_UPLOAD_MAX_FILE_BYTES, formatOtherUploadMaxSizeHint } from '@/shared/constants/upload-limits'
|
||||||
|
|
||||||
|
export async function postProductReview(
|
||||||
|
productId: string,
|
||||||
|
body: { rating: number; text?: string | null; imageUrl?: string | null },
|
||||||
|
): Promise<void> {
|
||||||
|
await apiClient.post(`products/${productId}/reviews`, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadReviewImage(file: File): Promise<{ url: string }> {
|
||||||
|
if (file.size > OTHER_UPLOAD_MAX_FILE_BYTES) {
|
||||||
|
throw new Error(`Файл «${file.name}» слишком большой (максимум ${formatOtherUploadMaxSizeHint()}).`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file, file.name)
|
||||||
|
const { data } = await apiClient.post<{ url: string }>('reviews/upload-image', fd)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PublicReviewFeedItem = {
|
||||||
|
id: string
|
||||||
|
rating: number
|
||||||
|
text: string | null
|
||||||
|
imageUrl: string | null
|
||||||
|
createdAt: string
|
||||||
|
authorId: string
|
||||||
|
authorDisplay: string
|
||||||
|
authorAvatar?: string | null
|
||||||
|
authorAvatarStyle?: string | null
|
||||||
|
product: {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
published: boolean
|
||||||
|
slug: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PublicReviewsLatestResponse = {
|
||||||
|
items: PublicReviewFeedItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLatestApprovedReviews(limit = 5): Promise<PublicReviewsLatestResponse> {
|
||||||
|
const { data } = await apiClient.get<PublicReviewsLatestResponse>('reviews/latest', {
|
||||||
|
params: { limit },
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PublicProductReviewItem = {
|
||||||
|
id: string
|
||||||
|
rating: number
|
||||||
|
text: string | null
|
||||||
|
imageUrl: string | null
|
||||||
|
createdAt: string
|
||||||
|
authorId: string
|
||||||
|
authorDisplay: string
|
||||||
|
authorAvatar?: string | null
|
||||||
|
authorAvatarStyle?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PublicProductReviewsResponse = {
|
||||||
|
items: PublicProductReviewItem[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPublicProductReviews(
|
||||||
|
productId: string,
|
||||||
|
params?: { page?: number; pageSize?: number },
|
||||||
|
): Promise<PublicProductReviewsResponse> {
|
||||||
|
const { data } = await apiClient.get<PublicProductReviewsResponse>(`products/${productId}/reviews`, {
|
||||||
|
params: { page: params?.page, pageSize: params?.pageSize },
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export {
|
||||||
|
postProductReview,
|
||||||
|
uploadReviewImage,
|
||||||
|
fetchLatestApprovedReviews,
|
||||||
|
fetchPublicProductReviews,
|
||||||
|
} from './api/reviews-api'
|
||||||
|
export type {
|
||||||
|
PublicReviewFeedItem,
|
||||||
|
PublicReviewsLatestResponse,
|
||||||
|
PublicProductReviewItem,
|
||||||
|
PublicProductReviewsResponse,
|
||||||
|
} from './api/reviews-api'
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { apiClient } from '@/shared/api/client'
|
||||||
|
|
||||||
|
export type ChecklistResultDto = {
|
||||||
|
passed: boolean
|
||||||
|
comment: string | null
|
||||||
|
checkedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TestChecklistResponse = {
|
||||||
|
results: Record<string, ChecklistResultDto>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateChecklistItemResponse = {
|
||||||
|
itemKey: string
|
||||||
|
passed: boolean
|
||||||
|
comment: string | null
|
||||||
|
checkedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTestChecklistResults(): Promise<TestChecklistResponse> {
|
||||||
|
const { data } = await apiClient.get<TestChecklistResponse>('admin/test-checklist')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTestChecklistItem(
|
||||||
|
itemKey: string,
|
||||||
|
passed: boolean,
|
||||||
|
comment?: string | null,
|
||||||
|
): Promise<UpdateChecklistItemResponse> {
|
||||||
|
const { data } = await apiClient.patch<{ result: UpdateChecklistItemResponse }>('admin/test-checklist', {
|
||||||
|
itemKey,
|
||||||
|
passed,
|
||||||
|
comment: passed ? null : (comment ?? null),
|
||||||
|
})
|
||||||
|
return data.result
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetTestChecklist(): Promise<void> {
|
||||||
|
await apiClient.post('admin/test-checklist/reset')
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { ShippingAddress } from '@/entities/user/model/types'
|
||||||
|
import { apiClient } from '@/shared/api/client'
|
||||||
|
|
||||||
|
export type AddressesListResponse = { items: ShippingAddress[] }
|
||||||
|
|
||||||
|
export async function fetchMyAddresses(): Promise<AddressesListResponse> {
|
||||||
|
const { data } = await apiClient.get<AddressesListResponse>('me/addresses')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMyAddress(body: {
|
||||||
|
label?: string | null
|
||||||
|
recipientName: string
|
||||||
|
recipientPhone: string
|
||||||
|
addressLine: string
|
||||||
|
comment?: string | null
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
isDefault?: boolean
|
||||||
|
}): Promise<{ item: ShippingAddress }> {
|
||||||
|
const { data } = await apiClient.post<{ item: ShippingAddress }>('me/addresses', body)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMyAddress(
|
||||||
|
id: string,
|
||||||
|
body: Partial<{
|
||||||
|
label: string | null
|
||||||
|
recipientName: string
|
||||||
|
recipientPhone: string
|
||||||
|
addressLine: string
|
||||||
|
comment: string | null
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
isDefault: boolean
|
||||||
|
}>,
|
||||||
|
): Promise<{ item: ShippingAddress }> {
|
||||||
|
const { data } = await apiClient.patch<{ item: ShippingAddress }>(`me/addresses/${id}`, body)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMyAddress(id: string): Promise<void> {
|
||||||
|
await apiClient.delete(`me/addresses/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setMyAddressDefault(id: string): Promise<{ item: ShippingAddress }> {
|
||||||
|
const { data } = await apiClient.post<{ item: ShippingAddress }>(`me/addresses/${id}/default`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { apiClient } from '@/shared/api/client'
|
||||||
|
|
||||||
|
export async function fetchUnreadMessageCount(): Promise<{ count: number }> {
|
||||||
|
const { data } = await apiClient.get<{ count: number }>('me/messages/unread-count')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markOrderMessagesRead(orderId: string): Promise<void> {
|
||||||
|
await apiClient.post(`me/orders/${orderId}/messages/read`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConversationSummary = {
|
||||||
|
orderId: string
|
||||||
|
status: string
|
||||||
|
deliveryType: 'delivery' | 'pickup'
|
||||||
|
lastMessageAt: string
|
||||||
|
preview: string
|
||||||
|
unreadCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMyConversations(): Promise<{ items: ConversationSummary[] }> {
|
||||||
|
const { data } = await apiClient.get<{ items: ConversationSummary[] }>('me/conversations')
|
||||||
|
return data
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { AdminUser } from '@/entities/user/model/types'
|
||||||
|
import { apiClient } from '@/shared/api/client'
|
||||||
|
|
||||||
|
export type AdminUsersListResponse = {
|
||||||
|
items: AdminUser[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminUsers(params?: {
|
||||||
|
q?: string
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}): Promise<AdminUsersListResponse> {
|
||||||
|
const { data } = await apiClient.get<AdminUsersListResponse>('admin/users', { params })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAdminUser(body: { email: string; displayName?: string | null }): Promise<AdminUser> {
|
||||||
|
const { data } = await apiClient.post<AdminUser>('admin/users', body)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAdminUser(
|
||||||
|
id: string,
|
||||||
|
body: Partial<{ email: string; displayName: string | null }>,
|
||||||
|
): Promise<AdminUser> {
|
||||||
|
const { data } = await apiClient.patch<AdminUser>(`admin/users/${id}`, body)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminAvatarResponse = {
|
||||||
|
avatar: string | null
|
||||||
|
avatarStyle: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminAvatar(): Promise<AdminAvatarResponse> {
|
||||||
|
const { data } = await apiClient.get<AdminAvatarResponse>('admin/avatar')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAdminUser(id: string): Promise<void> {
|
||||||
|
await apiClient.delete(`admin/users/${id}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export type { AdminUser, ShippingAddress } from './model/types'
|
||||||
|
export { fetchAdminUsers, createAdminUser, updateAdminUser, deleteAdminUser } from './api/user-api'
|
||||||
|
export type { AdminUsersListResponse } from './api/user-api'
|
||||||
|
export {
|
||||||
|
fetchMyAddresses,
|
||||||
|
createMyAddress,
|
||||||
|
updateMyAddress,
|
||||||
|
deleteMyAddress,
|
||||||
|
setMyAddressDefault,
|
||||||
|
} from './api/address-api'
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export type AdminUser = {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
displayName: string | null
|
||||||
|
avatar?: string | null
|
||||||
|
avatarStyle?: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ShippingAddress = {
|
||||||
|
id: string
|
||||||
|
label: string | null
|
||||||
|
recipientName: string
|
||||||
|
recipientPhone: string
|
||||||
|
addressLine: string
|
||||||
|
comment: string | null
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
isDefault: boolean
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { AddressFormDialog } from './ui/AddressFormDialog'
|
||||||
|
export type { AddressFormValues } from './ui/AddressFormDialog'
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import Button from '@mui/material/Button'
|
||||||
|
import Dialog from '@mui/material/Dialog'
|
||||||
|
import DialogActions from '@mui/material/DialogActions'
|
||||||
|
import DialogContent from '@mui/material/DialogContent'
|
||||||
|
import DialogTitle from '@mui/material/DialogTitle'
|
||||||
|
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||||
|
import Stack from '@mui/material/Stack'
|
||||||
|
import Switch from '@mui/material/Switch'
|
||||||
|
import TextField from '@mui/material/TextField'
|
||||||
|
import { Controller, type UseFormReturn } from 'react-hook-form'
|
||||||
|
import { AddressMapPicker } from '@/features/address-map-picker'
|
||||||
|
|
||||||
|
export type AddressFormValues = {
|
||||||
|
label: string
|
||||||
|
recipientName: string
|
||||||
|
recipientPhone: string
|
||||||
|
addressLine: string
|
||||||
|
comment: string
|
||||||
|
lat: number | null
|
||||||
|
lng: number | null
|
||||||
|
isDefault: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddressFormDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
editing,
|
||||||
|
form,
|
||||||
|
onSubmit,
|
||||||
|
isPending,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
editing: boolean
|
||||||
|
form: UseFormReturn<AddressFormValues>
|
||||||
|
onSubmit: () => void
|
||||||
|
isPending: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
|
||||||
|
<DialogTitle>{editing ? 'Редактировать адрес' : 'Новый адрес'}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="label"
|
||||||
|
render={({ field }) => <TextField label="Метка (например: Дом/Работа)" fullWidth {...field} />}
|
||||||
|
/>
|
||||||
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="recipientName"
|
||||||
|
render={({ field }) => <TextField label="ФИО получателя" fullWidth required {...field} />}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="recipientPhone"
|
||||||
|
render={({ field }) => <TextField label="Телефон получателя" fullWidth required {...field} />}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="addressLine"
|
||||||
|
render={({ field }) => <TextField label="Адрес" fullWidth required {...field} />}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="comment"
|
||||||
|
render={({ field }) => <TextField label="Комментарий (необязательно)" fullWidth {...field} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="lat"
|
||||||
|
render={({ field: latField }) => (
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="lng"
|
||||||
|
render={({ field: lngField }) => (
|
||||||
|
<AddressMapPicker
|
||||||
|
value={
|
||||||
|
latField.value !== null && lngField.value !== null
|
||||||
|
? { lat: latField.value, lng: lngField.value }
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onChange={(v) => {
|
||||||
|
latField.onChange(v.lat)
|
||||||
|
lngField.onChange(v.lng)
|
||||||
|
if (v.addressLine) form.setValue('addressLine', v.addressLine, { shouldDirty: true })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="isDefault"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Switch checked={Boolean(field.value)} onChange={(_, v) => field.onChange(v)} />}
|
||||||
|
label="Адрес по умолчанию"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>Отмена</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={
|
||||||
|
isPending ||
|
||||||
|
!form.watch('recipientName').trim() ||
|
||||||
|
!form.watch('recipientPhone').trim() ||
|
||||||
|
!form.watch('addressLine').trim()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { LatLng, NominatimItem } from '../model/types'
|
||||||
|
|
||||||
|
export async function reverseGeocode(pos: LatLng): Promise<string | null> {
|
||||||
|
const url = new URL('https://nominatim.openstreetmap.org/reverse')
|
||||||
|
url.searchParams.set('format', 'jsonv2')
|
||||||
|
url.searchParams.set('lat', String(pos.lat))
|
||||||
|
url.searchParams.set('lon', String(pos.lng))
|
||||||
|
url.searchParams.set('accept-language', 'ru')
|
||||||
|
const res = await fetch(url.toString(), { headers: { 'User-Agent': 'craftshop-demo' } })
|
||||||
|
if (!res.ok) return null
|
||||||
|
const data = (await res.json()) as { display_name?: string }
|
||||||
|
return data.display_name ? String(data.display_name) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchPlaces(q: string, signal?: AbortSignal): Promise<NominatimItem[]> {
|
||||||
|
const url = new URL('https://nominatim.openstreetmap.org/search')
|
||||||
|
url.searchParams.set('format', 'jsonv2')
|
||||||
|
url.searchParams.set('q', q)
|
||||||
|
url.searchParams.set('accept-language', 'ru')
|
||||||
|
url.searchParams.set('limit', '5')
|
||||||
|
const res = await fetch(url.toString(), {
|
||||||
|
headers: { 'User-Agent': 'craftshop-demo' },
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = (await res.json()) as NominatimItem[]
|
||||||
|
return Array.isArray(data) ? data : []
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { AddressMapPicker } from './ui/AddressMapPicker'
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export type NominatimItem = { display_name: string; lat: string; lon: string }
|
||||||
|
|
||||||
|
export type LatLng = { lat: number; lng: number }
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import Box from '@mui/material/Box'
|
||||||
|
import Button from '@mui/material/Button'
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress'
|
||||||
|
import List from '@mui/material/List'
|
||||||
|
import ListItemButton from '@mui/material/ListItemButton'
|
||||||
|
import ListItemText from '@mui/material/ListItemText'
|
||||||
|
import Stack from '@mui/material/Stack'
|
||||||
|
import TextField from '@mui/material/TextField'
|
||||||
|
import Typography from '@mui/material/Typography'
|
||||||
|
import { reverseGeocode, searchPlaces } from '../api/map-geocoding'
|
||||||
|
import { MapPickerMap } from './MapPickerMap'
|
||||||
|
import type { LatLng, NominatimItem } from '../model/types'
|
||||||
|
|
||||||
|
export function AddressMapPicker(props: {
|
||||||
|
value: { lat: number; lng: number } | null
|
||||||
|
onChange: (v: { lat: number; lng: number; addressLine?: string | null }) => void
|
||||||
|
}) {
|
||||||
|
const { value, onChange } = props
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
const [searching, setSearching] = useState(false)
|
||||||
|
const [results, setResults] = useState<NominatimItem[]>([])
|
||||||
|
const [hint, setHint] = useState<string | null>(null)
|
||||||
|
const abortRef = useRef<AbortController | null>(null)
|
||||||
|
const lastQueryRef = useRef<string>('')
|
||||||
|
const lastRequestAtRef = useRef<number>(0)
|
||||||
|
|
||||||
|
const qTrimmed = q.trim()
|
||||||
|
const visibleResults = qTrimmed.length >= 3 ? results : []
|
||||||
|
|
||||||
|
const center = useMemo(() => {
|
||||||
|
if (value) return { lat: value.lat, lng: value.lng }
|
||||||
|
return { lat: 55.751244, lng: 37.618423 }
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
const pick = async (pos: LatLng) => {
|
||||||
|
setHint(null)
|
||||||
|
onChange({ lat: pos.lat, lng: pos.lng })
|
||||||
|
try {
|
||||||
|
const addr = await reverseGeocode(pos)
|
||||||
|
if (addr) {
|
||||||
|
setHint(addr)
|
||||||
|
onChange({ lat: pos.lat, lng: pos.lng, addressLine: addr })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[address-map-picker] Failed to reverse geocode', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const s = qTrimmed
|
||||||
|
if (s.length < 3) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = window.setTimeout(async () => {
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - lastRequestAtRef.current < 900) return
|
||||||
|
if (s === lastQueryRef.current) return
|
||||||
|
|
||||||
|
lastQueryRef.current = s
|
||||||
|
lastRequestAtRef.current = now
|
||||||
|
|
||||||
|
abortRef.current?.abort()
|
||||||
|
const ac = new AbortController()
|
||||||
|
abortRef.current = ac
|
||||||
|
|
||||||
|
setSearching(true)
|
||||||
|
try {
|
||||||
|
setResults(await searchPlaces(s, ac.signal))
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as { name?: string })?.name !== 'AbortError') {
|
||||||
|
setResults([])
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSearching(false)
|
||||||
|
}
|
||||||
|
}, 450)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(t)
|
||||||
|
}
|
||||||
|
}, [qTrimmed])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
<Typography variant="subtitle2">Выбор на карте</Typography>
|
||||||
|
|
||||||
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}>
|
||||||
|
<TextField size="small" label="Найти адрес" value={q} onChange={(e) => setQ(e.target.value)} fullWidth />
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={async () => {
|
||||||
|
const s = q.trim()
|
||||||
|
if (!s) return
|
||||||
|
abortRef.current?.abort()
|
||||||
|
const ac = new AbortController()
|
||||||
|
abortRef.current = ac
|
||||||
|
setSearching(true)
|
||||||
|
try {
|
||||||
|
lastQueryRef.current = s
|
||||||
|
lastRequestAtRef.current = Date.now()
|
||||||
|
setResults(await searchPlaces(s, ac.signal))
|
||||||
|
} finally {
|
||||||
|
setSearching(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={searching || !q.trim()}
|
||||||
|
sx={{ minWidth: 160 }}
|
||||||
|
>
|
||||||
|
{searching ? <CircularProgress size={18} /> : 'Найти'}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{visibleResults.length > 0 && (
|
||||||
|
<List dense sx={{ border: 1, borderColor: 'divider', borderRadius: 2 }}>
|
||||||
|
{visibleResults.map((r) => (
|
||||||
|
<ListItemButton
|
||||||
|
key={`${r.lat}:${r.lon}:${r.display_name}`}
|
||||||
|
onClick={() => {
|
||||||
|
const lat = Number(r.lat)
|
||||||
|
const lng = Number(r.lon)
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return
|
||||||
|
void pick({ lat, lng })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemText primary={r.display_name} />
|
||||||
|
</ListItemButton>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MapPickerMap value={value} onChange={onChange} center={center} />
|
||||||
|
|
||||||
|
<Box sx={{ minHeight: 32, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{hint && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Подсказка адреса: {hint}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||