From fe10f25b8c32b2c605ec8a7f4106c7aadb575b84 Mon Sep 17 00:00:00 2001 From: "@kirill.komarov" Date: Sun, 3 May 2026 19:57:12 +0500 Subject: [PATCH] base commit --- README.md | 8 +- client/package-lock.json | 640 ++++++++++++++++++ client/package.json | 3 + client/src/app/App.tsx | 2 + client/src/app/layout/AppHeader.tsx | 82 ++- client/src/entities/info/api/info-page-api.ts | 41 ++ .../src/entities/order/api/admin-order-api.ts | 48 +- .../src/entities/product/api/product-api.ts | 63 +- .../src/entities/product/api/reviews-api.ts | 13 +- .../entities/review/api/admin-review-api.ts | 24 +- client/src/entities/user/api/user-api.ts | 36 +- client/src/entities/user/model/types.ts | 1 - client/src/pages/admin-info/index.ts | 1 + .../src/pages/admin-info/ui/AdminInfoPage.tsx | 216 ++++++ .../pages/admin-layout/ui/AdminLayoutPage.tsx | 22 +- .../pages/admin-orders/ui/AdminOrdersPage.tsx | 234 +++---- .../admin-reviews/ui/AdminReviewsPage.tsx | 160 ++--- .../pages/admin-users/ui/AdminUsersPage.tsx | 263 +++---- client/src/pages/admin/ui/AdminPage.tsx | 197 ++---- client/src/pages/auth/ui/AuthPage.tsx | 54 +- client/src/pages/info/index.ts | 1 + client/src/pages/info/ui/InfoPage.tsx | 42 ++ client/src/pages/me/ui/MePage.tsx | 43 -- .../src/pages/me/ui/sections/MessagesPage.tsx | 13 +- .../pages/me/ui/sections/OrderDetailPage.tsx | 104 ++- .../src/pages/me/ui/sections/OrdersPage.tsx | 67 +- .../src/pages/me/ui/sections/SettingsPage.tsx | 43 -- client/src/pages/product/ui/ProductPage.tsx | 15 + client/src/shared/lib/admin-token.ts | 26 - client/src/shared/lib/get-error-message.ts | 4 + .../src/shared/lib/group-orders-by-status.ts | 24 + .../src/shared/lib/invalidate-query-keys.ts | 5 + .../src/shared/lib/use-edit-dialog-state.ts | 28 + client/src/shared/model/auth.ts | 13 +- client/src/shared/ui/EntityRowActions.tsx | 43 ++ .../src/shared/ui/RichTextMessageEditor.tsx | 101 +++ .../widgets/reviews-block/ui/ReviewsBlock.tsx | 16 + server/.env.example | 2 +- server/package-lock.json | 10 - server/package.json | 1 - .../migration.sql | 20 + server/prisma/schema.prisma | 14 + server/src/index.js | 2 + server/src/lib/auth.js | 8 - server/src/lib/bootstrap-admin.js | 16 + server/src/lib/upload-images.js | 44 ++ server/src/plugins/auth.js | 27 +- server/src/routes/api.js | 4 +- server/src/routes/api/admin-products.js | 41 +- server/src/routes/api/admin-users.js | 25 +- server/src/routes/api/info-page.js | 118 ++++ server/src/routes/api/public-reviews.js | 29 + server/src/routes/auth.js | 78 +-- 53 files changed, 2064 insertions(+), 1071 deletions(-) create mode 100644 client/src/entities/info/api/info-page-api.ts create mode 100644 client/src/pages/admin-info/index.ts create mode 100644 client/src/pages/admin-info/ui/AdminInfoPage.tsx create mode 100644 client/src/pages/info/index.ts create mode 100644 client/src/pages/info/ui/InfoPage.tsx delete mode 100644 client/src/shared/lib/admin-token.ts create mode 100644 client/src/shared/lib/get-error-message.ts create mode 100644 client/src/shared/lib/group-orders-by-status.ts create mode 100644 client/src/shared/lib/invalidate-query-keys.ts create mode 100644 client/src/shared/lib/use-edit-dialog-state.ts create mode 100644 client/src/shared/ui/EntityRowActions.tsx create mode 100644 client/src/shared/ui/RichTextMessageEditor.tsx create mode 100644 server/prisma/migrations/20260503144425_add_admin_email_info_blocks_review_image/migration.sql create mode 100644 server/src/lib/bootstrap-admin.js create mode 100644 server/src/lib/upload-images.js create mode 100644 server/src/routes/api/info-page.js diff --git a/README.md b/README.md index 365af45..15cfe42 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,7 @@ ### Данные и админка - Данные загружаются/редактируются через **админку на фронте**. -- Админ‑роуты бэкенда защищены простым токеном: - - фронт отправляет `Authorization: Bearer ` - - токен задаётся в `server/.env` как `ADMIN_API_TOKEN` +- Админ‑роуты бэкенда доступны только авторизованному пользователю с email из `ADMIN_EMAIL` в `server/.env`. ### Форматирование и линтинг (client) @@ -54,7 +52,7 @@ ```bash cd server -cp .env.example .env # при необходимости поправьте ADMIN_API_TOKEN +cp .env.example .env # укажите ADMIN_EMAIL npm install npx prisma migrate dev # если база ещё не создана npx prisma db seed # опционально: тестовые категории и товары @@ -77,7 +75,7 @@ npm run dev ## Админка -Раздел **«Админка»** в шапке. Введите тот же секрет, что в `ADMIN_API_TOKEN` (заголовок `Authorization: Bearer …` уже подставляет фронт). Там можно создавать категории и товары, включать показ на витрине. +Раздел админки доступен только по прямой ссылке `/admin` и только для пользователя с email из `ADMIN_EMAIL`. Если такого пользователя нет в БД, сервер создаёт его автоматически при старте. Для боевого размещения фронта и API на разных доменах задайте `VITE_API_URL` (например `https://api.example.com/api`) и **CORS_ORIGIN** на сервере. diff --git a/client/package-lock.json b/client/package-lock.json index 9f90035..d52ddd3 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -13,6 +13,9 @@ "@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", @@ -618,6 +621,34 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT", + "optional": true + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1417,6 +1448,447 @@ "react": "^18 || ^19" } }, + "node_modules/@tiptap/core": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.5.tgz", + "integrity": "sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "3.22.5" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.22.5.tgz", + "integrity": "sha512-ajyP5W8fG5Hrru47T/eF3xMKOpNvWofgNJqBTeNuGl02sYxsy9a4EunyFxudsaZP9WW3VOD4SaIWr5+MqpbnOQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.22.5.tgz", + "integrity": "sha512-l/uDtpJISiFFyfctvnODNWBN/XPZI1jVZRacTRDDnSn8+x6KQ7G2qgFYueU7KvVJGDFVT39Iio56mcFRG/Pozg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.22.5.tgz", + "integrity": "sha512-yrNlFQQJY5MmhBpmD8tnmaSmyUQrEvgyPKa3bzVeWEhDSG1CW4A0ZSMx3hrA9yFO0HWfw3IJmvSCycEZQBalpQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5", + "@tiptap/pm": "3.22.5" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.22.5.tgz", + "integrity": "sha512-cf54fG9AybU8NgPMv1TOcoqAkELeRc/VpnSCt/rIJZphWQx9nsFmrtkrlCatrIcCaGtNZYwlHlMnC5LVVMu0uA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.22.5" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.22.5.tgz", + "integrity": "sha512-mwDNOJC9rYbDu/JcqrN4dbUQRklJU8Fuk2raxD/IvFw9qUIcPCmxQ2XT9UTKmZz/Ju7Kdy72fss6XpgWv6gLAQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.22.5.tgz", + "integrity": "sha512-d123kCfLdJTi4fue1m0+TNFztDkmIRSZGZmGu6H9KqwG5Q7IzjT9o8lzRsz+pXxYqHvqgYmXoEpM6srbzXx/Ag==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5", + "@tiptap/pm": "3.22.5" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.22.5.tgz", + "integrity": "sha512-8NJERd+pCtvSuEP4C4WMGYmRRCV12ePZL7bC+QUdFlbdXg+kNZS0zZ7hh879tYA0Kidbi8rWWD1Tx+H2ezkmMw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.22.5.tgz", + "integrity": "sha512-Mp40DaFrY3sEUVtFqmxrR0BmU4G3k8GCYYNGqNa9OqWv7BrcFDC03V2n3okESDKt4MKkzhQQmypq+ouLy8dLfA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.22.5" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.22.5.tgz", + "integrity": "sha512-dhem4sTPhyQgQ+pFp2Oud4k4FSQz9PVMgeQAC9288SmGwxBkJNveDAw6sKTMrumqDvwkJrtslXIupq9TZYQnzg==", + "license": "MIT", + "optional": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "3.22.5", + "@tiptap/pm": "3.22.5" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.22.5.tgz", + "integrity": "sha512-4WkMu7qqjbsm8hCQS+8X+la1wjriN0SKoRdvpfKH33qM50MB34tYJuGLAO+y7TTh4MMMco3AZCKPBL5JVMqNIg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.22.5" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.22.5.tgz", + "integrity": "sha512-n0R2mUVYZU2AVbJhg/WcY9+zx690wVwvsItHJf0DrYbf1tCYHx+PRHUt/AoXk6u8BSmnkb8/FDziS8m3mjfpSg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.22.5.tgz", + "integrity": "sha512-hjyEG4947PAhMBfP1G6B0QAh6+y9mp2C5BQmNjprA05/lQzDAT7KFZzNh8ZVp3ol6aICKq/N1gFOW9Dc/9FUOw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.22.5.tgz", + "integrity": "sha512-vUV0/ugIbXOc8SJib0h8UMhgcqZXWu/dkEhlswZN4VVven1o5enkfxEiDw+OyIJHi5rUkrdhsQ/KTxG/Xb7X8A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5", + "@tiptap/pm": "3.22.5" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.22.5.tgz", + "integrity": "sha512-4T8baSiLkeIymTgEwirxDFt5YgYofkP3m1+MGYdGy2HKcOK+1vpvlPhEO1X5qtZngtJW5S4+njKjinRg52A4PA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.22.5.tgz", + "integrity": "sha512-d671MvF3GPKoS2OVxjIlQ7hIE7MS3hREdR+d4cvnnoiLLD+ZJ6KgDnxmWqF0a1s4qxLWK2KxKRSOIfYGE31QWQ==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5", + "@tiptap/pm": "3.22.5" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.22.5.tgz", + "integrity": "sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5", + "@tiptap/pm": "3.22.5" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.22.5.tgz", + "integrity": "sha512-W7uTmyKLhlsvuTPLv+8WwnsY+mlikBFIoLSvVcBaFt4MwpsZ+DeB6KQg02Y7tbtaAnG7rXu9Fvw2QORh2P728A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.22.5" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.22.5.tgz", + "integrity": "sha512-cGUnxJ0y515e1bVHNjUmbx7oWHoEon59w6BA5N2KwV9iW2mZZchlTX4yxJSOX+ixeVRChsa7YwC3Z1jUZ6AMEg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.22.5" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.22.5.tgz", + "integrity": "sha512-OXdh4k4CNrukwiSdWdEQ49uvgnqvR0Z9aNSP4HI5/kZQ/Te1NtRtYCpUrzWyO/7CtjcCisXHti0o9C/TV8YMbQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.22.5" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.22.5.tgz", + "integrity": "sha512-52KCto4+XKpnBWpIufspWLyq4UWxAWC72ANPdGuIhbi72NRTabiTbTVN40uwGSPkyakeESG0/vKdWJCVvB4f0g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-placeholder": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.22.5.tgz", + "integrity": "sha512-MZAohQ3FCS763BkhGXgaWRya6WruZjwRwEAkXP8vkxbERzl2OJRjniS4uXCWzAlRb3ttE103SnY7LMdM8FvsXw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.22.5" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.22.5.tgz", + "integrity": "sha512-42WrrFK5gOom/0znH85x12Mw5IQ/6O6DWdyUWoRIrNA/qJpuHtU8oVU+bIgU2tuomMGHruRjIzgBQv5sBjEtww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.22.5.tgz", + "integrity": "sha512-bzpDOdAEo1JeoVZDIyV0oY0jGXkEG+AzF70SzHoRSjOvFDtKWunyXf9eO1OnOr2/fmMcckT2qwUBNBMQplWBzw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.22.5.tgz", + "integrity": "sha512-9ut09rJD0iEbS6sk7yd2j6IwuFDLTNmDEGTDLodvqAfi+bq7ddsTDv0YviXoZaA9sdHAdTEVr2ITy2m6WK5jpA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.22.5.tgz", + "integrity": "sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5", + "@tiptap/pm": "3.22.5" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.5.tgz", + "integrity": "sha512-Cr9Mv4igxvI2tKMiahw48sZxva3PfDzypErH8IB82N+9qa9n9ygVMt0BOaDg53hLKxEEVeYr2S/wCcJIVFgBTw==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.3.0", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-keymap": "^1.2.2", + "prosemirror-model": "^1.24.1", + "prosemirror-schema-list": "^1.5.0", + "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.6.4", + "prosemirror-transform": "^1.10.2", + "prosemirror-view": "^1.38.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.22.5.tgz", + "integrity": "sha512-36WHEs+vPmB//V1ff7Ujcnpz7Ey5g8lhpI/0+hoanSbdiPMTQ7qZVWwMovIkMKDlqWVp2fxBgeYM1861jyFzTw==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "fast-equals": "^5.3.3", + "use-sync-external-store": "^1.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "optionalDependencies": { + "@tiptap/extension-bubble-menu": "^3.22.5", + "@tiptap/extension-floating-menu": "^3.22.5" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5", + "@tiptap/pm": "3.22.5", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.22.5.tgz", + "integrity": "sha512-LZ/LYbwH6rnDi5DnRyagkuNsYAVyhM+yJvvz+ZuYA0JkPiTXJV86J5PWSKew8M0gVfMHcNVtKjfQCvViFCeIgw==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^3.22.5", + "@tiptap/extension-blockquote": "^3.22.5", + "@tiptap/extension-bold": "^3.22.5", + "@tiptap/extension-bullet-list": "^3.22.5", + "@tiptap/extension-code": "^3.22.5", + "@tiptap/extension-code-block": "^3.22.5", + "@tiptap/extension-document": "^3.22.5", + "@tiptap/extension-dropcursor": "^3.22.5", + "@tiptap/extension-gapcursor": "^3.22.5", + "@tiptap/extension-hard-break": "^3.22.5", + "@tiptap/extension-heading": "^3.22.5", + "@tiptap/extension-horizontal-rule": "^3.22.5", + "@tiptap/extension-italic": "^3.22.5", + "@tiptap/extension-link": "^3.22.5", + "@tiptap/extension-list": "^3.22.5", + "@tiptap/extension-list-item": "^3.22.5", + "@tiptap/extension-list-keymap": "^3.22.5", + "@tiptap/extension-ordered-list": "^3.22.5", + "@tiptap/extension-paragraph": "^3.22.5", + "@tiptap/extension-strike": "^3.22.5", + "@tiptap/extension-text": "^3.22.5", + "@tiptap/extension-underline": "^3.22.5", + "@tiptap/extensions": "^3.22.5", + "@tiptap/pm": "^3.22.5" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -1515,6 +1987,12 @@ "@types/geojson": "*" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", @@ -3786,6 +4264,15 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -5238,6 +5725,12 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/linkifyjs": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz", + "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -5604,6 +6097,12 @@ "node": ">= 0.8.0" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -5851,6 +6350,135 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/prosemirror-changeset": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", + "integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", + "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.4", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", + "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", + "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.41.8", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz", + "integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.20.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/protocol-buffers-schema": { "version": "3.6.1", "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", @@ -6137,6 +6765,12 @@ "dev": true, "license": "MIT" }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, "node_modules/rw": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", @@ -7105,6 +7739,12 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/client/package.json b/client/package.json index 6a1ebd1..68d9e96 100644 --- a/client/package.json +++ b/client/package.json @@ -18,6 +18,9 @@ "@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", diff --git a/client/src/app/App.tsx b/client/src/app/App.tsx index d684547..cc98017 100644 --- a/client/src/app/App.tsx +++ b/client/src/app/App.tsx @@ -6,6 +6,7 @@ import { AuthCallbackPage, AuthPage } from '@/pages/auth' import { CartPage } from '@/pages/cart' import { CheckoutPage } from '@/pages/checkout' import { HomePage } from '@/pages/home' +import { InfoPage } from '@/pages/info' import { MeLayoutPage } from '@/pages/me' import { ProductPage } from '@/pages/product' @@ -21,6 +22,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/client/src/app/layout/AppHeader.tsx b/client/src/app/layout/AppHeader.tsx index 637d6a3..de9ffc8 100644 --- a/client/src/app/layout/AppHeader.tsx +++ b/client/src/app/layout/AppHeader.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import AccountCircleOutlinedIcon from '@mui/icons-material/AccountCircleOutlined' import DarkModeOutlinedIcon from '@mui/icons-material/DarkModeOutlined' import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined' +import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined' import MenuOutlinedIcon from '@mui/icons-material/MenuOutlined' import ShoppingCartOutlinedIcon from '@mui/icons-material/ShoppingCartOutlined' import AppBar from '@mui/material/AppBar' @@ -29,6 +30,7 @@ import { Link as RouterLink, useNavigate } from 'react-router-dom' import type { ColorScheme } from '@/app/providers/theme-controller' import { useThemeController } from '@/app/providers/theme-controller' import { fetchMyCart } from '@/entities/cart/api/cart-api' +import { fetchMyOrders } from '@/entities/order/api/order-api' import { STORE_NAME } from '@/shared/config' import { $user, logout, tokenSet } from '@/shared/model/auth' import { BearLogo } from '@/shared/ui/BearLogo' @@ -37,7 +39,7 @@ type NavItem = { label: string; to: string } const navItems: NavItem[] = [ { label: 'Каталог', to: '/' }, - { label: 'Админка', to: '/admin' }, + { label: 'О покупке', to: '/info' }, ] function ThemeControlsDesktop(props: { @@ -168,15 +170,26 @@ export function AppHeader() { const { mode, resolvedMode, scheme, setMode, setScheme, cycleMode } = useThemeController() const user = useUnit($user) const navigate = useNavigate() + const isAdmin = Boolean(user?.isAdmin) const cartQuery = useQuery({ queryKey: ['me', 'cart'], queryFn: fetchMyCart, - enabled: Boolean(user), + 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 [userAnchorEl, setUserAnchorEl] = useState(null) const userMenuOpen = Boolean(userAnchorEl) @@ -249,24 +262,35 @@ export function AppHeader() { ))} - - - { - if (!user) navigate('/auth') - else navigate('/cart') - }} - aria-label="Корзина" - > - - - - - - + {!isAdmin && ( + <> + {user && ( + + navigate('/me/orders')} aria-label="Заказы"> + + + + + + )} + + + { + if (!user) navigate('/auth') + else navigate('/cart') + }} + aria-label="Корзина" + > + + + + + + + )} ))} - + {!isAdmin && ( + + )} + {user && !isAdmin && ( + + )} diff --git a/client/src/entities/info/api/info-page-api.ts b/client/src/entities/info/api/info-page-api.ts new file mode 100644 index 0000000..343fd2a --- /dev/null +++ b/client/src/entities/info/api/info-page-api.ts @@ -0,0 +1,41 @@ +import { apiClient } from '@/shared/api/client' + +export type InfoPageBlock = { + id: string + key: string + title: string + body: string + sort: number + published: boolean + createdAt: string + updatedAt: string +} + +export async function fetchPublicInfoBlocks(): Promise<{ items: InfoPageBlock[] }> { + const { data } = await apiClient.get<{ items: InfoPageBlock[] }>('info-page/blocks') + return data +} + +export async function fetchAdminInfoBlocks(): Promise<{ items: InfoPageBlock[] }> { + const { data } = await apiClient.get<{ items: InfoPageBlock[] }>('admin/info-page/blocks') + return data +} + +export async function createInfoBlock( + body: Pick, +): Promise<{ item: InfoPageBlock }> { + const { data } = await apiClient.post<{ item: InfoPageBlock }>('admin/info-page/blocks', body) + return data +} + +export async function updateInfoBlock( + id: string, + body: Partial>, +): Promise<{ item: InfoPageBlock }> { + const { data } = await apiClient.patch<{ item: InfoPageBlock }>(`admin/info-page/blocks/${id}`, body) + return data +} + +export async function deleteInfoBlock(id: string): Promise { + await apiClient.delete(`admin/info-page/blocks/${id}`) +} diff --git a/client/src/entities/order/api/admin-order-api.ts b/client/src/entities/order/api/admin-order-api.ts index fe61423..e65cfdb 100644 --- a/client/src/entities/order/api/admin-order-api.ts +++ b/client/src/entities/order/api/admin-order-api.ts @@ -49,47 +49,31 @@ export type AdminOrderDetailResponse = { } } -export async function fetchAdminOrdersSummary(token: string): Promise<{ attentionCount: number }> { - const { data } = await apiClient.get<{ attentionCount: number }>('admin/orders/summary', { - headers: { Authorization: `Bearer ${token}` }, - }) +export async function fetchAdminOrdersSummary(): Promise<{ attentionCount: number }> { + const { data } = await apiClient.get<{ attentionCount: number }>('admin/orders/summary') return data } -export async function fetchAdminOrders( - token: string, - params?: { status?: string; deliveryType?: 'delivery' | 'pickup'; q?: string; page?: number; pageSize?: number }, -): Promise { - const { data } = await apiClient.get('admin/orders', { - params, - headers: { Authorization: `Bearer ${token}` }, - }) +export async function fetchAdminOrders(params?: { + status?: string + deliveryType?: 'delivery' | 'pickup' + q?: string + page?: number + pageSize?: number +}): Promise { + const { data } = await apiClient.get('admin/orders', { params }) return data } -export async function fetchAdminOrder(token: string, id: string): Promise { - const { data } = await apiClient.get(`admin/orders/${id}`, { - headers: { Authorization: `Bearer ${token}` }, - }) +export async function fetchAdminOrder(id: string): Promise { + const { data } = await apiClient.get(`admin/orders/${id}`) return data } -export async function setAdminOrderStatus(token: string, id: string, status: string): Promise { - await apiClient.patch( - `admin/orders/${id}/status`, - { status }, - { - headers: { Authorization: `Bearer ${token}` }, - }, - ) +export async function setAdminOrderStatus(id: string, status: string): Promise { + await apiClient.patch(`admin/orders/${id}/status`, { status }) } -export async function postAdminOrderMessage(token: string, id: string, text: string): Promise { - await apiClient.post( - `admin/orders/${id}/messages`, - { text }, - { - headers: { Authorization: `Bearer ${token}` }, - }, - ) +export async function postAdminOrderMessage(id: string, text: string): Promise { + await apiClient.post(`admin/orders/${id}/messages`, { text }) } diff --git a/client/src/entities/product/api/product-api.ts b/client/src/entities/product/api/product-api.ts index 03671cf..43f62f2 100644 --- a/client/src/entities/product/api/product-api.ts +++ b/client/src/entities/product/api/product-api.ts @@ -43,39 +43,31 @@ export async function fetchCategories(): Promise { return data } -export async function fetchAdminProducts(token: string): Promise { - const { data } = await apiClient.get('admin/products', { - headers: { Authorization: `Bearer ${token}` }, - }) +export async function fetchAdminProducts(): Promise { + const { data } = await apiClient.get('admin/products') return data } -export async function createProduct( - token: string, - body: { - title: string - slug?: string - shortDescription?: string | null - description?: string | null - quantity?: number | null - materials?: string[] - priceCents: number - imageUrl?: string | null - imageUrls?: string[] - published: boolean - inStock?: boolean - leadTimeDays?: number | null - categoryId: string - }, -): Promise { - const { data } = await apiClient.post('admin/products', body, { - headers: { Authorization: `Bearer ${token}` }, - }) +export async function createProduct(body: { + title: string + slug?: string + shortDescription?: string | null + description?: string | null + quantity?: number | null + materials?: string[] + priceCents: number + imageUrl?: string | null + imageUrls?: string[] + published: boolean + inStock?: boolean + leadTimeDays?: number | null + categoryId: string +}): Promise { + const { data } = await apiClient.post('admin/products', body) return data } export async function updateProduct( - token: string, id: string, body: Partial<{ title: string @@ -93,24 +85,15 @@ export async function updateProduct( categoryId: string }>, ): Promise { - const { data } = await apiClient.patch(`admin/products/${id}`, body, { - headers: { Authorization: `Bearer ${token}` }, - }) + const { data } = await apiClient.patch(`admin/products/${id}`, body) return data } -export async function deleteProduct(token: string, id: string): Promise { - await apiClient.delete(`admin/products/${id}`, { - headers: { Authorization: `Bearer ${token}` }, - }) +export async function deleteProduct(id: string): Promise { + await apiClient.delete(`admin/products/${id}`) } -export async function createCategory( - token: string, - body: { name: string; slug?: string; sort?: number }, -): Promise { - const { data } = await apiClient.post('admin/categories', body, { - headers: { Authorization: `Bearer ${token}` }, - }) +export async function createCategory(body: { name: string; slug?: string; sort?: number }): Promise { + const { data } = await apiClient.post('admin/categories', body) return data } diff --git a/client/src/entities/product/api/reviews-api.ts b/client/src/entities/product/api/reviews-api.ts index 70864dd..2c9dcbd 100644 --- a/client/src/entities/product/api/reviews-api.ts +++ b/client/src/entities/product/api/reviews-api.ts @@ -2,15 +2,25 @@ import { apiClient } from '@/shared/api/client' export async function postProductReview( productId: string, - body: { rating: number; text?: string | null }, + body: { rating: number; text?: string | null; imageUrl?: string | null }, ): Promise { await apiClient.post(`products/${productId}/reviews`, body) } +export async function uploadReviewImage(file: File): Promise<{ url: string }> { + const fd = new FormData() + fd.append('file', file) + const { data } = await apiClient.post<{ url: string }>('reviews/upload-image', fd, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return data +} + export type PublicReviewFeedItem = { id: string rating: number text: string | null + imageUrl: string | null createdAt: string authorDisplay: string productId: string @@ -32,6 +42,7 @@ export type PublicProductReviewItem = { id: string rating: number text: string | null + imageUrl: string | null createdAt: string authorDisplay: string } diff --git a/client/src/entities/review/api/admin-review-api.ts b/client/src/entities/review/api/admin-review-api.ts index 07587a8..fdad3b6 100644 --- a/client/src/entities/review/api/admin-review-api.ts +++ b/client/src/entities/review/api/admin-review-api.ts @@ -18,23 +18,15 @@ export type AdminReviewsListResponse = { pageSize: number } -export async function fetchAdminReviews( - token: string, - params?: { status?: string; page?: number; pageSize?: number }, -): Promise { - const { data } = await apiClient.get('admin/reviews', { - params, - headers: { Authorization: `Bearer ${token}` }, - }) +export async function fetchAdminReviews(params?: { + status?: string + page?: number + pageSize?: number +}): Promise { + const { data } = await apiClient.get('admin/reviews', { params }) return data } -export async function moderateReview(token: string, id: string, action: 'approve' | 'reject'): Promise { - await apiClient.patch( - `admin/reviews/${id}`, - { action }, - { - headers: { Authorization: `Bearer ${token}` }, - }, - ) +export async function moderateReview(id: string, action: 'approve' | 'reject'): Promise { + await apiClient.patch(`admin/reviews/${id}`, { action }) } diff --git a/client/src/entities/user/api/user-api.ts b/client/src/entities/user/api/user-api.ts index b5aeb71..814b6cc 100644 --- a/client/src/entities/user/api/user-api.ts +++ b/client/src/entities/user/api/user-api.ts @@ -8,40 +8,28 @@ export type AdminUsersListResponse = { pageSize: number } -export async function fetchAdminUsers( - token: string, - params?: { q?: string; page?: number; pageSize?: number }, -): Promise { - const { data } = await apiClient.get('admin/users', { - params, - headers: { Authorization: `Bearer ${token}` }, - }) +export async function fetchAdminUsers(params?: { + q?: string + page?: number + pageSize?: number +}): Promise { + const { data } = await apiClient.get('admin/users', { params }) return data } -export async function createAdminUser( - token: string, - body: { email: string; name?: string | null; password?: string }, -): Promise { - const { data } = await apiClient.post('admin/users', body, { - headers: { Authorization: `Bearer ${token}` }, - }) +export async function createAdminUser(body: { email: string; name?: string | null }): Promise { + const { data } = await apiClient.post('admin/users', body) return data } export async function updateAdminUser( - token: string, id: string, - body: Partial<{ email: string; name: string | null; password: string }>, + body: Partial<{ email: string; name: string | null }>, ): Promise { - const { data } = await apiClient.patch(`admin/users/${id}`, body, { - headers: { Authorization: `Bearer ${token}` }, - }) + const { data } = await apiClient.patch(`admin/users/${id}`, body) return data } -export async function deleteAdminUser(token: string, id: string): Promise { - await apiClient.delete(`admin/users/${id}`, { - headers: { Authorization: `Bearer ${token}` }, - }) +export async function deleteAdminUser(id: string): Promise { + await apiClient.delete(`admin/users/${id}`) } diff --git a/client/src/entities/user/model/types.ts b/client/src/entities/user/model/types.ts index a90437f..15dc992 100644 --- a/client/src/entities/user/model/types.ts +++ b/client/src/entities/user/model/types.ts @@ -2,7 +2,6 @@ export type AdminUser = { id: string email: string name: string | null - hasPassword: boolean createdAt: string updatedAt: string } diff --git a/client/src/pages/admin-info/index.ts b/client/src/pages/admin-info/index.ts new file mode 100644 index 0000000..aeb45fa --- /dev/null +++ b/client/src/pages/admin-info/index.ts @@ -0,0 +1 @@ +export { AdminInfoPage } from './ui/AdminInfoPage' diff --git a/client/src/pages/admin-info/ui/AdminInfoPage.tsx b/client/src/pages/admin-info/ui/AdminInfoPage.tsx new file mode 100644 index 0000000..f08cd28 --- /dev/null +++ b/client/src/pages/admin-info/ui/AdminInfoPage.tsx @@ -0,0 +1,216 @@ +import Alert from '@mui/material/Alert' +import Box from '@mui/material/Box' +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 Table from '@mui/material/Table' +import TableBody from '@mui/material/TableBody' +import TableCell from '@mui/material/TableCell' +import TableHead from '@mui/material/TableHead' +import TableRow from '@mui/material/TableRow' +import TextField from '@mui/material/TextField' +import Typography from '@mui/material/Typography' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Controller, useForm } from 'react-hook-form' +import { + createInfoBlock, + deleteInfoBlock, + fetchAdminInfoBlocks, + type InfoPageBlock, + updateInfoBlock, +} from '@/entities/info/api/info-page-api' +import { getErrorMessage } from '@/shared/lib/get-error-message' +import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys' +import { useEditDialogState } from '@/shared/lib/use-edit-dialog-state' +import { EntityRowActions } from '@/shared/ui/EntityRowActions' + +type FormState = { + key: string + title: string + body: string + sort: string + published: boolean +} + +const emptyForm = (): FormState => ({ key: '', title: '', body: '', sort: '0', published: true }) + +export function AdminInfoPage() { + const qc = useQueryClient() + const { dialogOpen, editing, openCreateDialog, openEditDialog, closeDialog } = useEditDialogState() + const form = useForm({ defaultValues: emptyForm(), mode: 'onChange' }) + + const blocksQuery = useQuery({ + queryKey: ['admin', 'info-page', 'blocks'], + queryFn: fetchAdminInfoBlocks, + }) + + const saveMut = useMutation({ + mutationFn: async () => { + const values = form.getValues() + const payload = { + key: values.key.trim(), + title: values.title.trim(), + body: values.body.trim(), + sort: Number(values.sort || 0), + published: values.published, + } + if (editing) return updateInfoBlock(editing.id, payload) + return createInfoBlock(payload) + }, + onSuccess: async () => { + closeDialog() + form.reset(emptyForm()) + await invalidateQueryKeys(qc, [ + ['admin', 'info-page', 'blocks'], + ['info-page', 'public', 'blocks'], + ]) + }, + }) + + const deleteMut = useMutation({ + mutationFn: (id: string) => deleteInfoBlock(id), + onSuccess: async () => { + await invalidateQueryKeys(qc, [ + ['admin', 'info-page', 'blocks'], + ['info-page', 'public', 'blocks'], + ]) + }, + }) + + const openCreate = () => { + form.reset(emptyForm()) + openCreateDialog() + } + + const openEdit = (item: InfoPageBlock) => { + openEditDialog(item) + form.reset({ + key: item.key, + title: item.title, + body: item.body, + sort: String(item.sort), + published: item.published, + }) + } + + const items = blocksQuery.data?.items ?? [] + const err = saveMut.error ?? deleteMut.error + + return ( + + + Информационная страница + + + + + Управление блоками страницы с процессом покупки, оплаты и доставки. + + + {blocksQuery.isError && Не удалось загрузить блоки.} + {err && ( + + {getErrorMessage(err)} + + )} + + + + + Key + Заголовок + Порядок + Опубликован + Действия + + + + {items.map((item) => ( + + {item.key} + {item.title} + {item.sort} + {item.published ? 'Да' : 'Нет'} + + openEdit(item)} + onDelete={() => deleteMut.mutate(item.id)} + deleteDisabled={deleteMut.isPending} + /> + + + ))} + {items.length === 0 && !blocksQuery.isLoading && ( + + + Блоков пока нет. + + + )} + +
+ + + {editing ? 'Редактировать блок' : 'Новый блок'} + + + } + /> + } + /> + ( + + )} + /> + } + /> + ( + field.onChange(v)} />} + label="Показывать на публичной странице" + /> + )} + /> + + + + + + + +
+ ) +} diff --git a/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx b/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx index 111dfe2..040b934 100644 --- a/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx +++ b/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react' -import { useMemo, useState, useSyncExternalStore } from 'react' +import { useMemo, useState } from 'react' import AssignmentOutlinedIcon from '@mui/icons-material/AssignmentOutlined' +import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined' import MenuOutlinedIcon from '@mui/icons-material/MenuOutlined' import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined' import RateReviewOutlinedIcon from '@mui/icons-material/RateReviewOutlined' @@ -19,13 +20,15 @@ import { useTheme } from '@mui/material/styles' import Typography from '@mui/material/Typography' import useMediaQuery from '@mui/material/useMediaQuery' import { useQuery } from '@tanstack/react-query' +import { useUnit } from 'effector-react' import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom' import { fetchAdminOrdersSummary } from '@/entities/order/api/admin-order-api' import { AdminPage } from '@/pages/admin' +import { AdminInfoPage } from '@/pages/admin-info' import { AdminOrdersPage } from '@/pages/admin-orders' import { AdminReviewsPage } from '@/pages/admin-reviews' import { AdminUsersPage } from '@/pages/admin-users' -import { getAdminToken, subscribeAdminTokenChange } from '@/shared/lib/admin-token' +import { $user } from '@/shared/model/auth' type NavItem = { to: string @@ -39,12 +42,13 @@ export function AdminLayoutPage() { const theme = useTheme() const isMobile = useMediaQuery(theme.breakpoints.down('md')) const [mobileOpen, setMobileOpen] = useState(false) - const adminToken = useSyncExternalStore(subscribeAdminTokenChange, getAdminToken, () => null) + const user = useUnit($user) + const isAdmin = Boolean(user?.isAdmin) const ordersSummaryQuery = useQuery({ - queryKey: ['admin', 'orders', 'summary', adminToken], - queryFn: () => fetchAdminOrdersSummary(adminToken!), - enabled: Boolean(adminToken), + queryKey: ['admin', 'orders', 'summary'], + queryFn: fetchAdminOrdersSummary, + enabled: isAdmin, refetchInterval: 45_000, refetchOnWindowFocus: true, }) @@ -57,10 +61,15 @@ export function AdminLayoutPage() { { to: '/admin/orders', label: 'Заказы', icon: }, { to: '/admin/reviews', label: 'Отзывы', icon: }, { to: '/admin/users', label: 'Пользователи', icon: }, + { to: '/admin/info', label: 'Инфо-страница', icon: }, ], [], ) + if (!isAdmin) { + return + } + const activeTo = navItems.find((x) => location.pathname === x.to)?.to ?? navItems.find((x) => location.pathname.startsWith(`${x.to}/`))?.to ?? @@ -142,6 +151,7 @@ export function AdminLayoutPage() { } /> } /> } /> + } /> } /> diff --git a/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx b/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx index e80f468..e2af7f9 100644 --- a/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx +++ b/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { Fragment, useMemo, useState } from 'react' import Alert from '@mui/material/Alert' import Box from '@mui/material/Box' import Button from '@mui/material/Button' @@ -19,7 +19,6 @@ import TableRow from '@mui/material/TableRow' import TextField from '@mui/material/TextField' import Typography from '@mui/material/Typography' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { Controller, useForm } from 'react-hook-form' import { fetchAdminOrder, fetchAdminOrders, @@ -27,15 +26,14 @@ import { setAdminOrderStatus, } from '@/entities/order/api/admin-order-api' import { ORDER_STATUSES, getAdminNextOrderStatuses } from '@/shared/constants/order' -import { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token' import { formatPriceRub } from '@/shared/lib/format-price' +import { groupOrdersByStatus } from '@/shared/lib/group-orders-by-status' +import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys' import { orderStatusLabelRu } from '@/shared/lib/order-status-labels' - -type TokenFormState = { token: string } +import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor' export function AdminOrdersPage() { const qc = useQueryClient() - const [token, setTokenState] = useState(() => getAdminToken()) const [q, setQ] = useState('') const [status, setStatus] = useState('') const [deliveryType, setDeliveryType] = useState<'delivery' | 'pickup' | ''>('') @@ -43,54 +41,38 @@ export function AdminOrdersPage() { const [selectedId, setSelectedId] = useState(null) const [msg, setMsg] = useState('') - const tokenForm = useForm({ defaultValues: { token: '' }, mode: 'onChange' }) - - useEffect(() => { - tokenForm.reset({ token: '' }) - }, [token, tokenForm]) - - const saveToken = () => { - const t = tokenForm.getValues('token').trim() - if (!t) { - clearAdminToken() - setTokenState(null) - return - } - setAdminToken(t) - setTokenState(t) - } - const ordersQuery = useQuery({ - queryKey: ['admin', 'orders', token, { q, status, deliveryType }], + queryKey: ['admin', 'orders', { q, status, deliveryType }], queryFn: () => - fetchAdminOrders(token!, { + fetchAdminOrders({ q: q.trim() || undefined, status: status || undefined, deliveryType: deliveryType || undefined, }), - enabled: Boolean(token), }) const orderDetailQuery = useQuery({ - queryKey: ['admin', 'orders', 'detail', token, selectedId], - queryFn: () => fetchAdminOrder(token!, selectedId!), - enabled: Boolean(token && selectedId), + queryKey: ['admin', 'orders', 'detail', selectedId], + queryFn: () => fetchAdminOrder(selectedId!), + enabled: Boolean(selectedId), }) const statusMut = useMutation({ - mutationFn: (next: string) => setAdminOrderStatus(token!, selectedId!, next), + mutationFn: (next: string) => setAdminOrderStatus(selectedId!, next), onSuccess: async () => { - await qc.invalidateQueries({ queryKey: ['admin', 'orders'] }) - await qc.invalidateQueries({ queryKey: ['admin', 'orders', 'detail'] }) - await qc.invalidateQueries({ queryKey: ['admin', 'orders', 'summary'] }) + await invalidateQueryKeys(qc, [ + ['admin', 'orders'], + ['admin', 'orders', 'detail'], + ['admin', 'orders', 'summary'], + ]) }, }) const msgMut = useMutation({ - mutationFn: () => postAdminOrderMessage(token!, selectedId!, msg.trim()), + mutationFn: () => postAdminOrderMessage(selectedId!, msg.trim()), onSuccess: async () => { setMsg('') - await qc.invalidateQueries({ queryKey: ['admin', 'orders', 'detail'] }) + await invalidateQueryKeys(qc, [['admin', 'orders', 'detail']]) }, }) @@ -99,7 +81,15 @@ export function AdminOrdersPage() { setDialogOpen(true) } - const items = ordersQuery.data?.items ?? [] + const items = useMemo(() => ordersQuery.data?.items ?? [], [ordersQuery.data?.items]) + const groupedItems = useMemo( + () => + groupOrdersByStatus(items, ORDER_STATUSES).map((group) => ({ + statusCode: group.status, + items: group.items, + })), + [items], + ) const detail = orderDetailQuery.data?.item @@ -115,97 +105,74 @@ export function AdminOrdersPage() { - Введите API-токен из ADMIN_API_TOKEN (сохраняется в sessionStorage). + Управление заказами доступно пользователю с правами администратора. - - ( - - )} - /> - + setQ(e.target.value)} fullWidth /> + + Статус + + + + Способ получения + + - {!token && После сохранения токена появится список заказов.} + {ordersQuery.isError && Не удалось загрузить заказы.} - {token && ( - <> - - setQ(e.target.value)} - fullWidth - /> - - Статус - - - - Способ получения - - - - - {ordersQuery.isError && Не удалось загрузить заказы.} - - - +
+ + + ID + Покупатель + Создан + Сумма + Позиций + Действия + + + + {groupedItems.map((group) => ( + - ID - Покупатель - Статус - Сумма - Позиций - Действия + + {orderStatusLabelRu(group.statusCode)} ({group.items.length}) + - - - {items.map((o) => ( + {group.items.map((o) => ( {o.id.slice(-8)} {o.user.email} - {orderStatusLabelRu(o.status)} + {new Date(o.createdAt).toLocaleString('ru-RU')} {formatPriceRub(o.totalCents)} {o.itemsCount} @@ -215,17 +182,17 @@ export function AdminOrdersPage() { ))} - {ordersQuery.isSuccess && items.length === 0 && ( - - - Заказов пока нет. - - - )} - -
- - )} + + ))} + {ordersQuery.isSuccess && items.length === 0 && ( + + + Заказов пока нет. + + + )} + + setDialogOpen(false)} fullWidth maxWidth="md"> Заказ @@ -282,14 +249,9 @@ export function AdminOrdersPage() { - setMsg(e.target.value)} - fullWidth - multiline - minRows={2} - /> + + + + - {!token && После сохранения токена появится список отзывов на модерации.} + {reviewsQuery.isError && Не удалось загрузить отзывы.} + {error && {getErrorMessage(error)}} - {token && ( - <> - {reviewsQuery.isError && Не удалось загрузить отзывы.} - {error && {(error as Error).message}} - - - - - Товар - Пользователь - Оценка - Текст - Действия - - - - {items.map((r) => ( - - {r.product.title} - {r.user.email} - - - - {r.text ?? '—'} - - - - - - ))} - {reviewsQuery.isSuccess && items.length === 0 && ( - - - Нет отзывов на модерации. - - - )} - -
- - )} + + + + Товар + Пользователь + Оценка + Текст + Действия + + + + {items.map((r) => ( + + {r.product.title} + {r.user.email} + + + + {r.text ?? '—'} + + + + + + ))} + {reviewsQuery.isSuccess && items.length === 0 && ( + + + Нет отзывов на модерации. + + + )} + +
) } diff --git a/client/src/pages/admin-users/ui/AdminUsersPage.tsx b/client/src/pages/admin-users/ui/AdminUsersPage.tsx index faf98e4..d4cff67 100644 --- a/client/src/pages/admin-users/ui/AdminUsersPage.tsx +++ b/client/src/pages/admin-users/ui/AdminUsersPage.tsx @@ -20,17 +20,17 @@ import { Controller, useForm } from 'react-hook-form' import { Link as RouterLink } from 'react-router-dom' import { createAdminUser, deleteAdminUser, fetchAdminUsers, updateAdminUser } from '@/entities/user/api/user-api' import type { AdminUser } from '@/entities/user/model/types' -import { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token' - -type TokenFormState = { token: string } +import { getErrorMessage } from '@/shared/lib/get-error-message' +import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys' +import { useEditDialogState } from '@/shared/lib/use-edit-dialog-state' +import { EntityRowActions } from '@/shared/ui/EntityRowActions' type UserFormState = { email: string name: string - password: string } -const emptyUserForm = (): UserFormState => ({ email: '', name: '', password: '' }) +const emptyUserForm = (): UserFormState => ({ email: '', name: '' }) function formatDt(v: string) { try { @@ -44,28 +44,17 @@ function formatDt(v: string) { export function AdminUsersPage() { const queryClient = useQueryClient() - const [token, setTokenState] = useState(() => getAdminToken()) - const [dialogOpen, setDialogOpen] = useState(false) - const [editing, setEditing] = useState(null) + const { dialogOpen, editing, openCreateDialog, openEditDialog, closeDialog } = useEditDialogState() const [qInput, setQInput] = useState('') const [q, setQ] = useState('') const [page, setPage] = useState(0) const [rowsPerPage, setRowsPerPage] = useState(20) - const tokenForm = useForm({ - defaultValues: { token: '' }, - mode: 'onChange', - }) - const userForm = useForm({ defaultValues: emptyUserForm(), mode: 'onChange', }) - useEffect(() => { - tokenForm.reset({ token: '' }) - }, [token, tokenForm]) - useEffect(() => { const t = window.setTimeout(() => { setQ(qInput.trim()) @@ -74,81 +63,64 @@ export function AdminUsersPage() { return () => window.clearTimeout(t) }, [qInput]) - const saveToken = () => { - const t = tokenForm.getValues('token').trim() - if (!t) { - clearAdminToken() - setTokenState(null) - return - } - setAdminToken(t) - setTokenState(t) - } - const usersQuery = useQuery({ - queryKey: ['admin', 'users', token, { q, page, rowsPerPage }], + queryKey: ['admin', 'users', { q, page, rowsPerPage }], queryFn: () => - fetchAdminUsers(token!, { + fetchAdminUsers({ q: q || undefined, page: page + 1, pageSize: rowsPerPage, }), - enabled: Boolean(token), }) const createMut = useMutation({ mutationFn: async () => { const v = userForm.getValues() - await createAdminUser(token!, { + await createAdminUser({ email: v.email.trim(), name: v.name.trim() || null, - password: v.password.trim() || undefined, }) }, onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) - setDialogOpen(false) + void invalidateQueryKeys(queryClient, [['admin', 'users']]) + closeDialog() }, }) const updateMut = useMutation({ mutationFn: async () => { const v = userForm.getValues() - await updateAdminUser(token!, editing!.id, { + await updateAdminUser(editing!.id, { email: v.email.trim(), name: v.name.trim() || null, - ...(v.password.trim() ? { password: v.password.trim() } : {}), }) }, onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) - setDialogOpen(false) + void invalidateQueryKeys(queryClient, [['admin', 'users']]) + closeDialog() }, }) const deleteMut = useMutation({ - mutationFn: async (id: string) => deleteAdminUser(token!, id), + mutationFn: async (id: string) => deleteAdminUser(id), onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + void invalidateQueryKeys(queryClient, [['admin', 'users']]) }, }) const mutationError = createMut.error ?? updateMut.error ?? deleteMut.error const openCreate = () => { - setEditing(null) userForm.reset(emptyUserForm()) - setDialogOpen(true) + openCreateDialog() } const openEdit = (u: AdminUser) => { - setEditing(u) + openEditDialog(u) userForm.reset({ email: u.email, name: u.name ?? '', - password: '', }) - setDialogOpen(true) } const emailValue = userForm.watch('email') @@ -171,124 +143,84 @@ export function AdminUsersPage() { - Введите API-токен из{' '} - - .env - {' '} - сервера (ADMIN_API_TOKEN). Он сохраняется только в памяти браузера (sessionStorage). + Управление пользователями доступно пользователю с правами администратора. - - - ( - - )} - /> - + setQInput(e.target.value)} + fullWidth + /> - {!token && После сохранения токена здесь появится список пользователей.} - - {token && ( - <> - - - setQInput(e.target.value)} - fullWidth - /> - - - {usersQuery.isError && ( - - Ошибка загрузки. Проверьте токен и что сервер запущен. - - )} - - {mutationError && ( - - {(mutationError as Error).message} - - )} - - - - - Почта - Имя - Пароль - Создан - Обновлён - Действия - - - - {users.map((u) => ( - - {u.email} - {u.name ?? '—'} - {u.hasPassword ? 'задан' : 'нет'} - {formatDt(u.createdAt)} - {formatDt(u.updatedAt)} - - - - - - ))} - {users.length === 0 && !usersQuery.isLoading && ( - - - Пользователей пока нет. - - - )} - -
- - setPage(p)} - rowsPerPage={rowsPerPage} - onRowsPerPageChange={(e) => { - setRowsPerPage(Number(e.target.value)) - setPage(0) - }} - rowsPerPageOptions={[10, 20, 50, 100]} - /> - + {usersQuery.isError && ( + + Ошибка загрузки. Проверьте, что сервер запущен, и у вас есть права администратора. + )} - setDialogOpen(false)} fullWidth maxWidth="xs"> + {mutationError && ( + + {getErrorMessage(mutationError)} + + )} + + + + + Почта + Имя + Создан + Обновлён + Действия + + + + {users.map((u) => ( + + {u.email} + {u.name ?? '—'} + {formatDt(u.createdAt)} + {formatDt(u.updatedAt)} + + openEdit(u)} + onDelete={() => deleteMut.mutate(u.id)} + deleteDisabled={deleteMut.isPending} + confirmDeleteMessage={`Удалить пользователя ${u.email}?`} + /> + + + ))} + {users.length === 0 && !usersQuery.isLoading && ( + + + Пользователей пока нет. + + + )} + +
+ + setPage(p)} + rowsPerPage={rowsPerPage} + onRowsPerPageChange={(e) => { + setRowsPerPage(Number(e.target.value)) + setPage(0) + }} + rowsPerPageOptions={[10, 20, 50, 100]} + /> + + {editing ? 'Редактировать пользователя' : 'Новый пользователь'} @@ -302,23 +234,10 @@ export function AdminUsersPage() { name="name" render={({ field }) => } /> - ( - - )} - /> - + + - {!token && ( - После сохранения токена здесь появится список товаров и формы управления. + {productsQuery.isError && ( + + Ошибка загрузки. Проверьте, что сервер запущен, и у вас есть права администратора. + )} - {token && ( - <> - - - - - - {productsQuery.isError && ( - - Ошибка загрузки. Проверьте токен и что сервер запущен. - - )} - - {mutationError && ( - - {(mutationError as Error).message} - - )} - - - - - Название - Категория - Цена - Витрина - Действия - - - - {(productsQuery.data ?? []).map((p) => ( - - {p.title} - {p.category?.name ?? '—'} - {formatPriceRub(p.priceCents)} - {p.published ? 'да' : 'нет'} - - - - - - ))} - -
- + {mutationError && ( + + {getErrorMessage(mutationError)} + )} - setDialogOpen(false)} fullWidth maxWidth="sm"> + + + + Название + Категория + Цена + Витрина + Действия + + + + {(productsQuery.data ?? []).map((p) => ( + + {p.title} + {p.category?.name ?? '—'} + {formatPriceRub(p.priceCents)} + {p.published ? 'да' : 'нет'} + + openEdit(p)} onDelete={() => deleteMut.mutate(p.id)} /> + + + ))} + +
+ + {editing ? 'Редактировать товар' : 'Новый товар'} @@ -448,7 +383,7 @@ export function AdminPage() { flexDirection: { xs: 'column', sm: 'row' }, }} > - {uploadImagesMut.isPending && Загрузка…} - {uploadImagesMut.isError && ( - Не удалось загрузить фото (проверьте токен и сервер) - )} + {uploadImagesMut.isError && Не удалось загрузить фото} {productForm.watch('imageUrls').length > 0 && ( @@ -559,7 +492,7 @@ export function AdminPage() { - + - - - - Вариант 2: Email + пароль - - - - - ) diff --git a/client/src/pages/info/index.ts b/client/src/pages/info/index.ts new file mode 100644 index 0000000..a5938d4 --- /dev/null +++ b/client/src/pages/info/index.ts @@ -0,0 +1 @@ +export { InfoPage } from './ui/InfoPage' diff --git a/client/src/pages/info/ui/InfoPage.tsx b/client/src/pages/info/ui/InfoPage.tsx new file mode 100644 index 0000000..7d2dd77 --- /dev/null +++ b/client/src/pages/info/ui/InfoPage.tsx @@ -0,0 +1,42 @@ +import Alert from '@mui/material/Alert' +import Box from '@mui/material/Box' +import Paper from '@mui/material/Paper' +import Stack from '@mui/material/Stack' +import Typography from '@mui/material/Typography' +import { useQuery } from '@tanstack/react-query' +import { fetchPublicInfoBlocks } from '@/entities/info/api/info-page-api' + +export function InfoPage() { + const q = useQuery({ + queryKey: ['info-page', 'public', 'blocks'], + queryFn: fetchPublicInfoBlocks, + }) + + return ( + + + Информация для покупателей + + + Как оформить заказ, как проходит доставка, оплата и другие важные детали. + + + {q.isLoading && Загрузка…} + {q.isError && Не удалось загрузить информацию.} + {q.isSuccess && q.data.items.length === 0 && Раздел пока не заполнен.} + + {q.isSuccess && q.data.items.length > 0 && ( + + {q.data.items.map((block) => ( + + + {block.title} + + {block.body} + + ))} + + )} + + ) +} diff --git a/client/src/pages/me/ui/MePage.tsx b/client/src/pages/me/ui/MePage.tsx index a7ec7e8..3a40bb2 100644 --- a/client/src/pages/me/ui/MePage.tsx +++ b/client/src/pages/me/ui/MePage.tsx @@ -8,12 +8,10 @@ import Typography from '@mui/material/Typography' import { useUnit } from 'effector-react' import { useForm } from 'react-hook-form' import { - $changePasswordError, $requestEmailChangeCodeError, $updateProfileError, $user, $verifyEmailChangeError, - changePasswordFx, requestEmailChangeCodeFx, updateProfileFx, verifyEmailChangeFx, @@ -28,20 +26,13 @@ function getApiErrorMessage(error: unknown): string | null { export function MePage() { const user = useUnit($user) - const pendingPassword = useUnit(changePasswordFx.pending) const pendingEmailReq = useUnit(requestEmailChangeCodeFx.pending) const pendingEmailVerify = useUnit(verifyEmailChangeFx.pending) const pendingProfile = useUnit(updateProfileFx.pending) - const errorPassword = useUnit($changePasswordError) const errorEmailReq = useUnit($requestEmailChangeCodeError) const errorProfile = useUnit($updateProfileError) const errorEmailVerify = useUnit($verifyEmailChangeError) - const passwordForm = useForm<{ currentPassword: string; newPassword: string }>({ - defaultValues: { currentPassword: '', newPassword: '' }, - mode: 'onChange', - }) - const emailForm = useForm<{ newEmail: string; code: string }>({ defaultValues: { newEmail: '', code: '' }, mode: 'onChange', @@ -52,7 +43,6 @@ export function MePage() { mode: 'onChange', }) - const passwordErrorMsg = getApiErrorMessage(errorPassword) const emailErrorMsg = getApiErrorMessage(errorEmailReq) ?? getApiErrorMessage(errorEmailVerify) const profileErrorMsg = getApiErrorMessage(errorProfile) @@ -69,11 +59,6 @@ export function MePage() { Текущая почта: {user.email} - {passwordErrorMsg && ( - - {passwordErrorMsg} - - )} {emailErrorMsg && ( {emailErrorMsg} @@ -143,34 +128,6 @@ export function MePage() { - - - - - - Смена пароля - - - - - - - ) diff --git a/client/src/pages/me/ui/sections/MessagesPage.tsx b/client/src/pages/me/ui/sections/MessagesPage.tsx index a61fe5b..920d5b1 100644 --- a/client/src/pages/me/ui/sections/MessagesPage.tsx +++ b/client/src/pages/me/ui/sections/MessagesPage.tsx @@ -8,13 +8,13 @@ import ListItem from '@mui/material/ListItem' 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Link as RouterLink } from 'react-router-dom' import { fetchMyOrder, postOrderMessage } from '@/entities/order/api/order-api' import { fetchMyConversations, markOrderMessagesRead } from '@/entities/user/api/messages-api' import { orderStatusLabelRu } from '@/shared/lib/order-status-labels' +import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor' export function MessagesPage() { const qc = useQueryClient() @@ -177,14 +177,9 @@ export function MessagesPage() { {order.messages.length === 0 && Нет сообщений.} - setText(e.target.value)} - fullWidth - multiline - minRows={2} - /> + + + + {reviewImageUrl && ( + + )} + + {reviewImageUrl && ( + + )} + {uploadReviewImageMut.isError && ( + + Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp. + + )} {reviewMut.isError && ( {reviewSubmitErrorMessage(reviewMut.error)} @@ -379,7 +433,15 @@ export function OrderDetailPage() { )} - + + + + + ))} + ))} diff --git a/client/src/pages/me/ui/sections/SettingsPage.tsx b/client/src/pages/me/ui/sections/SettingsPage.tsx index 3ddf2c4..003f11c 100644 --- a/client/src/pages/me/ui/sections/SettingsPage.tsx +++ b/client/src/pages/me/ui/sections/SettingsPage.tsx @@ -8,12 +8,10 @@ import Typography from '@mui/material/Typography' import { useUnit } from 'effector-react' import { useForm } from 'react-hook-form' import { - $changePasswordError, $requestEmailChangeCodeError, $updateProfileError, $user, $verifyEmailChangeError, - changePasswordFx, requestEmailChangeCodeFx, updateProfileFx, verifyEmailChangeFx, @@ -28,20 +26,13 @@ function getApiErrorMessage(error: unknown): string | null { export function SettingsPage() { const user = useUnit($user) - const pendingPassword = useUnit(changePasswordFx.pending) const pendingEmailReq = useUnit(requestEmailChangeCodeFx.pending) const pendingEmailVerify = useUnit(verifyEmailChangeFx.pending) const pendingProfile = useUnit(updateProfileFx.pending) - const errorPassword = useUnit($changePasswordError) const errorEmailReq = useUnit($requestEmailChangeCodeError) const errorProfile = useUnit($updateProfileError) const errorEmailVerify = useUnit($verifyEmailChangeError) - const passwordForm = useForm<{ currentPassword: string; newPassword: string }>({ - defaultValues: { currentPassword: '', newPassword: '' }, - mode: 'onChange', - }) - const emailForm = useForm<{ newEmail: string; code: string }>({ defaultValues: { newEmail: '', code: '' }, mode: 'onChange', @@ -52,7 +43,6 @@ export function SettingsPage() { mode: 'onChange', }) - const passwordErrorMsg = getApiErrorMessage(errorPassword) const emailErrorMsg = getApiErrorMessage(errorEmailReq) ?? getApiErrorMessage(errorEmailVerify) const profileErrorMsg = getApiErrorMessage(errorProfile) @@ -69,11 +59,6 @@ export function SettingsPage() { Текущая почта: {user.email} - {passwordErrorMsg && ( - - {passwordErrorMsg} - - )} {emailErrorMsg && ( {emailErrorMsg} @@ -150,34 +135,6 @@ export function SettingsPage() { - - - - - - Смена пароля - - - - - - - ) diff --git a/client/src/pages/product/ui/ProductPage.tsx b/client/src/pages/product/ui/ProductPage.tsx index e174d29..f2433a2 100644 --- a/client/src/pages/product/ui/ProductPage.tsx +++ b/client/src/pages/product/ui/ProductPage.tsx @@ -218,6 +218,21 @@ export function ProductPage() { Без текстового комментария. )} + {rv.imageUrl && ( + + )} ) diff --git a/client/src/shared/lib/admin-token.ts b/client/src/shared/lib/admin-token.ts deleted file mode 100644 index e0161b2..0000000 --- a/client/src/shared/lib/admin-token.ts +++ /dev/null @@ -1,26 +0,0 @@ -const KEY = 'craftshop_admin_token' -const TOKEN_EVENT = 'craftshop_admin_token_change' - -export function getAdminToken(): string | null { - return sessionStorage.getItem(KEY) -} - -function notifyTokenListeners(): void { - window.dispatchEvent(new Event(TOKEN_EVENT)) -} - -/** Подписаться на смену токена (в т. ч. после setAdminToken). */ -export function subscribeAdminTokenChange(cb: () => void): () => void { - window.addEventListener(TOKEN_EVENT, cb) - return () => window.removeEventListener(TOKEN_EVENT, cb) -} - -export function setAdminToken(token: string): void { - sessionStorage.setItem(KEY, token) - notifyTokenListeners() -} - -export function clearAdminToken(): void { - sessionStorage.removeItem(KEY) - notifyTokenListeners() -} diff --git a/client/src/shared/lib/get-error-message.ts b/client/src/shared/lib/get-error-message.ts new file mode 100644 index 0000000..a9e2f5c --- /dev/null +++ b/client/src/shared/lib/get-error-message.ts @@ -0,0 +1,4 @@ +export function getErrorMessage(error: unknown, fallback = 'Произошла ошибка'): string { + if (error instanceof Error && error.message) return error.message + return fallback +} diff --git a/client/src/shared/lib/group-orders-by-status.ts b/client/src/shared/lib/group-orders-by-status.ts new file mode 100644 index 0000000..74ce6ad --- /dev/null +++ b/client/src/shared/lib/group-orders-by-status.ts @@ -0,0 +1,24 @@ +type OrderLike = { + status: string + createdAt: string +} + +export function groupOrdersByStatus(items: T[], statuses: readonly string[]) { + const byStatus = new Map() + for (const status of statuses) byStatus.set(status, []) + + for (const item of items) { + const list = byStatus.get(item.status) ?? [] + list.push(item) + byStatus.set(item.status, list) + } + + return statuses + .map((status) => ({ + status, + items: (byStatus.get(status) ?? []).sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ), + })) + .filter((group) => group.items.length > 0) +} diff --git a/client/src/shared/lib/invalidate-query-keys.ts b/client/src/shared/lib/invalidate-query-keys.ts new file mode 100644 index 0000000..da281da --- /dev/null +++ b/client/src/shared/lib/invalidate-query-keys.ts @@ -0,0 +1,5 @@ +import type { QueryClient, QueryKey } from '@tanstack/react-query' + +export async function invalidateQueryKeys(queryClient: QueryClient, keys: QueryKey[]): Promise { + await Promise.all(keys.map((queryKey) => queryClient.invalidateQueries({ queryKey }))) +} diff --git a/client/src/shared/lib/use-edit-dialog-state.ts b/client/src/shared/lib/use-edit-dialog-state.ts new file mode 100644 index 0000000..494e06b --- /dev/null +++ b/client/src/shared/lib/use-edit-dialog-state.ts @@ -0,0 +1,28 @@ +import { useCallback, useState } from 'react' + +export function useEditDialogState() { + const [dialogOpen, setDialogOpen] = useState(false) + const [editing, setEditing] = useState(null) + + const openCreateDialog = useCallback(() => { + setEditing(null) + setDialogOpen(true) + }, []) + + const openEditDialog = useCallback((item: T) => { + setEditing(item) + setDialogOpen(true) + }, []) + + const closeDialog = useCallback(() => { + setDialogOpen(false) + }, []) + + return { + dialogOpen, + editing, + openCreateDialog, + openEditDialog, + closeDialog, + } +} diff --git a/client/src/shared/model/auth.ts b/client/src/shared/model/auth.ts index 6c2df82..f752a70 100644 --- a/client/src/shared/model/auth.ts +++ b/client/src/shared/model/auth.ts @@ -1,7 +1,7 @@ import { createEffect, createEvent, createStore, sample } from 'effector' import { apiClient } from '@/shared/api/client' -export type AuthUser = { id: string; email: string; name?: string | null; phone?: string | null } +export type AuthUser = { id: string; email: string; name?: string | null; phone?: string | null; isAdmin?: boolean } const TOKEN_KEY = 'craftshop_auth_token' @@ -14,11 +14,6 @@ export const $token = createStore(null) export const $user = createStore(null).reset(logout) -export const changePasswordFx = createEffect(async (params: { currentPassword?: string; newPassword: string }) => { - const { data } = await apiClient.patch<{ user: AuthUser }>('me/password', params) - return data.user -}) - export const requestEmailChangeCodeFx = createEffect(async (newEmail: string) => { await apiClient.post('me/change-email/request-code', { newEmail }) }) @@ -35,10 +30,6 @@ export const updateProfileFx = createEffect(async (params: UpdateProfileParams) return data.user }) -export const $changePasswordError = createStore(null) - .on(changePasswordFx.failData, (_, e) => e) - .reset(changePasswordFx, logout) - export const $requestEmailChangeCodeError = createStore(null) .on(requestEmailChangeCodeFx.failData, (_, e) => e) .reset(requestEmailChangeCodeFx, logout) @@ -70,7 +61,7 @@ sample({ }) sample({ - clock: [changePasswordFx.doneData, verifyEmailChangeFx.doneData, updateProfileFx.doneData], + clock: [verifyEmailChangeFx.doneData, updateProfileFx.doneData], target: $user, }) diff --git a/client/src/shared/ui/EntityRowActions.tsx b/client/src/shared/ui/EntityRowActions.tsx new file mode 100644 index 0000000..6283174 --- /dev/null +++ b/client/src/shared/ui/EntityRowActions.tsx @@ -0,0 +1,43 @@ +import Button from '@mui/material/Button' +import Stack from '@mui/material/Stack' + +type EntityRowActionsProps = { + onEdit?: () => void + onDelete?: () => void + deleteDisabled?: boolean + confirmDeleteMessage?: string + editLabel?: string + deleteLabel?: string +} + +export function EntityRowActions({ + onEdit, + onDelete, + deleteDisabled = false, + confirmDeleteMessage, + editLabel = 'Изменить', + deleteLabel = 'Удалить', +}: EntityRowActionsProps) { + return ( + + {onEdit && ( + + )} + {onDelete && ( + + )} + + ) +} diff --git a/client/src/shared/ui/RichTextMessageEditor.tsx b/client/src/shared/ui/RichTextMessageEditor.tsx new file mode 100644 index 0000000..8232d39 --- /dev/null +++ b/client/src/shared/ui/RichTextMessageEditor.tsx @@ -0,0 +1,101 @@ +import { useEffect } from 'react' +import FormatBoldOutlinedIcon from '@mui/icons-material/FormatBoldOutlined' +import FormatItalicOutlinedIcon from '@mui/icons-material/FormatItalicOutlined' +import FormatListBulletedOutlinedIcon from '@mui/icons-material/FormatListBulletedOutlined' +import Box from '@mui/material/Box' +import IconButton from '@mui/material/IconButton' +import Stack from '@mui/material/Stack' +import Placeholder from '@tiptap/extension-placeholder' +import { EditorContent, useEditor } from '@tiptap/react' +import TiptapStarterKit from '@tiptap/starter-kit' + +type RichTextMessageEditorProps = { + value: string + onChange: (next: string) => void + placeholder?: string + disabled?: boolean +} + +export function RichTextMessageEditor({ + value, + onChange, + placeholder = 'Введите сообщение', + disabled = false, +}: RichTextMessageEditorProps) { + const editor = useEditor({ + extensions: [ + TiptapStarterKit.configure({ heading: false, codeBlock: false, blockquote: false, horizontalRule: false }), + Placeholder.configure({ placeholder }), + ], + content: value, + editable: !disabled, + onUpdate: ({ editor: tiptap }) => onChange(tiptap.getText()), + }) + + useEffect(() => { + if (!editor) return + editor.setEditable(!disabled) + }, [disabled, editor]) + + useEffect(() => { + if (!editor) return + if (editor.getText() === value) return + editor.commands.setContent(value, false) + }, [editor, value]) + + return ( + + + editor?.chain().focus().toggleBold().run()} + color={editor?.isActive('bold') ? 'primary' : 'default'} + disabled={disabled} + aria-label="Жирный" + > + + + editor?.chain().focus().toggleItalic().run()} + color={editor?.isActive('italic') ? 'primary' : 'default'} + disabled={disabled} + aria-label="Курсив" + > + + + editor?.chain().focus().toggleBulletList().run()} + color={editor?.isActive('bulletList') ? 'primary' : 'default'} + disabled={disabled} + aria-label="Список" + > + + + + + + + + + ) +} diff --git a/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx b/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx index 0696294..0accad0 100644 --- a/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx +++ b/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx @@ -109,6 +109,22 @@ export function ReviewsBlock() { {text} + {r.imageUrl && ( + + )} ) })} diff --git a/server/.env.example b/server/.env.example index 03348c7..5f5ca09 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1,6 +1,6 @@ DATABASE_URL="file:./dev.db" PORT=3333 -ADMIN_API_TOKEN=замените-на-секрет +ADMIN_EMAIL=admin@example.com JWT_SECRET=замените-на-секрет-jwt # Разрешённый Origin фронта (через запятую при нескольких) diff --git a/server/package-lock.json b/server/package-lock.json index d16814f..52be30b 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -13,7 +13,6 @@ "@fastify/multipart": "^10.0.0", "@fastify/static": "^9.1.3", "@prisma/client": "5.22.0", - "bcryptjs": "^3.0.3", "dotenv": "^17.4.2", "fastify": "^5.8.5", "nodemailer": "^8.0.7" @@ -456,15 +455,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/bcryptjs": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", - "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", - "license": "BSD-3-Clause", - "bin": { - "bcrypt": "bin/bcrypt" - } - }, "node_modules/bn.js": { "version": "4.12.3", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", diff --git a/server/package.json b/server/package.json index 6c47678..a10b991 100644 --- a/server/package.json +++ b/server/package.json @@ -19,7 +19,6 @@ "@fastify/multipart": "^10.0.0", "@fastify/static": "^9.1.3", "@prisma/client": "5.22.0", - "bcryptjs": "^3.0.3", "dotenv": "^17.4.2", "fastify": "^5.8.5", "nodemailer": "^8.0.7" diff --git a/server/prisma/migrations/20260503144425_add_admin_email_info_blocks_review_image/migration.sql b/server/prisma/migrations/20260503144425_add_admin_email_info_blocks_review_image/migration.sql new file mode 100644 index 0000000..c33acfb --- /dev/null +++ b/server/prisma/migrations/20260503144425_add_admin_email_info_blocks_review_image/migration.sql @@ -0,0 +1,20 @@ +-- AlterTable +ALTER TABLE "Review" ADD COLUMN "imageUrl" TEXT; + +-- CreateTable +CREATE TABLE "InfoPageBlock" ( + "id" TEXT NOT NULL PRIMARY KEY, + "key" TEXT NOT NULL, + "title" TEXT NOT NULL, + "body" TEXT NOT NULL, + "sort" INTEGER NOT NULL DEFAULT 0, + "published" BOOLEAN NOT NULL DEFAULT true, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "InfoPageBlock_key_key" ON "InfoPageBlock"("key"); + +-- CreateIndex +CREATE INDEX "InfoPageBlock_published_sort_idx" ON "InfoPageBlock"("published", "sort"); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 0bb0e8b..fc0d904 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -161,6 +161,7 @@ model Review { id String @id @default(cuid()) rating Int text String? + imageUrl String? /// 'pending' | 'approved' | 'rejected' status String @default("pending") createdAt DateTime @default(now()) @@ -229,3 +230,16 @@ model AuthCode { @@index([email, purpose]) @@index([expiresAt]) } + +model InfoPageBlock { + id String @id @default(cuid()) + key String @unique + title String + body String + sort Int @default(0) + published Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([published, sort]) +} diff --git a/server/src/index.js b/server/src/index.js index 55b80fa..9b921aa 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -5,6 +5,7 @@ import jwt from '@fastify/jwt' import multipart from '@fastify/multipart' import fastifyStatic from '@fastify/static' import path from 'node:path' +import { ensureAdminUser } from './lib/bootstrap-admin.js' import { registerAuth } from './plugins/auth.js' import { registerApiRoutes } from './routes/api.js' import { registerAuthRoutes } from './routes/auth.js' @@ -52,6 +53,7 @@ registerAuth(fastify) await registerAuthRoutes(fastify) await registerOAuthSocialRoutes(fastify) await registerApiRoutes(fastify) +await ensureAdminUser() fastify.get('/health', async () => ({ ok: true })) diff --git a/server/src/lib/auth.js b/server/src/lib/auth.js index e49103d..510b5e3 100644 --- a/server/src/lib/auth.js +++ b/server/src/lib/auth.js @@ -1,5 +1,4 @@ import crypto from 'node:crypto' -import bcrypt from 'bcryptjs' import { prisma } from './prisma.js' import { sendLoginCodeEmail } from './email.js' @@ -54,11 +53,4 @@ export async function verifyEmailCode({ email, purpose, code, userId = null }) { return true } -export async function hashPassword(password) { - return bcrypt.hash(password, 10) -} - -export async function verifyPassword(password, passwordHash) { - return bcrypt.compare(password, passwordHash) -} diff --git a/server/src/lib/bootstrap-admin.js b/server/src/lib/bootstrap-admin.js new file mode 100644 index 0000000..9a9812b --- /dev/null +++ b/server/src/lib/bootstrap-admin.js @@ -0,0 +1,16 @@ +import { normalizeEmail } from './auth.js' +import { prisma } from './prisma.js' + +export async function ensureAdminUser() { + const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL) + if (!adminEmail) return + if (!adminEmail.includes('@')) { + throw new Error('ADMIN_EMAIL должен быть валидным email') + } + + await prisma.user.upsert({ + where: { email: adminEmail }, + update: {}, + create: { email: adminEmail }, + }) +} diff --git a/server/src/lib/upload-images.js b/server/src/lib/upload-images.js new file mode 100644 index 0000000..0c57793 --- /dev/null +++ b/server/src/lib/upload-images.js @@ -0,0 +1,44 @@ +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' + +export function safeImageExt(filename) { + const ext = path.extname(String(filename || '')).toLowerCase() + const allowed = new Set(['.png', '.jpg', '.jpeg', '.webp']) + return allowed.has(ext) ? ext : null +} + +export function uploadError(message, statusCode = 400) { + const err = new Error(message) + err.statusCode = statusCode + return err +} + +export async function persistMultipartImages(request, { maxFiles = 10 } = {}) { + if (!request.isMultipart()) { + throw uploadError('Ожидается multipart/form-data') + } + + const uploadsDir = path.join(process.cwd(), 'uploads') + await fs.promises.mkdir(uploadsDir, { recursive: true }) + + const urls = [] + const parts = request.parts() + for await (const part of parts) { + if (part.type !== 'file') continue + if (urls.length >= maxFiles) { + throw uploadError(`Можно загрузить не более ${maxFiles} файл(ов)`) + } + const ext = safeImageExt(part.filename) + if (!ext) { + throw uploadError('Разрешены только файлы: png, jpg, jpeg, webp') + } + + const fileName = `${crypto.randomUUID()}${ext}` + const fullPath = path.join(uploadsDir, fileName) + await fs.promises.writeFile(fullPath, await part.toBuffer()) + urls.push(`/uploads/${fileName}`) + } + + return urls +} diff --git a/server/src/plugins/auth.js b/server/src/plugins/auth.js index 66546bd..3ae1be8 100644 --- a/server/src/plugins/auth.js +++ b/server/src/plugins/auth.js @@ -1,16 +1,23 @@ -/** - * Простая защита админ-роутов: заголовок Authorization: Bearer - */ export function registerAuth(fastify) { + function normalizeEmail(email) { + return String(email || '').trim().toLowerCase() + } + fastify.decorate('verifyAdmin', async function verifyAdmin(request, reply) { - const token = process.env.ADMIN_API_TOKEN - if (!token) { - return reply.code(503).send({ error: 'ADMIN_API_TOKEN не задан в .env' }) + const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL) + if (!adminEmail || !adminEmail.includes('@')) { + return reply.code(503).send({ error: 'ADMIN_EMAIL не задан в .env' }) } - const auth = request.headers.authorization - const match = typeof auth === 'string' ? auth.match(/^Bearer\s+(.+)$/i) : null - if (!match?.[1] || match[1] !== token) { - return reply.code(401).send({ error: 'Неверный или отсутствующий токен' }) + + try { + await request.jwtVerify() + } catch { + return reply.code(401).send({ error: 'Не авторизован' }) + } + + const userEmail = normalizeEmail(request.user?.email) + if (userEmail !== adminEmail) { + return reply.code(403).send({ error: 'Недостаточно прав' }) } }) } diff --git a/server/src/routes/api.js b/server/src/routes/api.js index 24b828a..50aa7bc 100644 --- a/server/src/routes/api.js +++ b/server/src/routes/api.js @@ -1,7 +1,6 @@ import { mapProductForApi, parseMaterialsInput, - safeExtFromFilename, slugify, } from './api/_product-helpers.js' import { registerAdminCategoryRoutes } from './api/admin-categories.js' @@ -9,16 +8,17 @@ import { registerAdminOrderRoutes } from './api/admin-orders.js' import { registerAdminProductRoutes } from './api/admin-products.js' import { registerAdminReviewRoutes } from './api/admin-reviews.js' import { registerAdminUserRoutes } from './api/admin-users.js' +import { registerInfoPageRoutes } from './api/info-page.js' import { registerPublicCatalogRoutes } from './api/public-catalog.js' import { registerPublicReviewRoutes } from './api/public-reviews.js' export async function registerApiRoutes(fastify) { await registerPublicCatalogRoutes(fastify, { mapProductForApi }) await registerPublicReviewRoutes(fastify) + await registerInfoPageRoutes(fastify) await registerAdminProductRoutes(fastify, { slugify, - safeExtFromFilename, parseMaterialsInput, mapProductForApi, }) diff --git a/server/src/routes/api/admin-products.js b/server/src/routes/api/admin-products.js index e8352ae..8dc1c5b 100644 --- a/server/src/routes/api/admin-products.js +++ b/server/src/routes/api/admin-products.js @@ -1,11 +1,9 @@ -import crypto from 'node:crypto' -import fs from 'node:fs' -import path from 'node:path' import { prisma } from '../../lib/prisma.js' +import { persistMultipartImages } from '../../lib/upload-images.js' export async function registerAdminProductRoutes( fastify, - { slugify, safeExtFromFilename, parseMaterialsInput, mapProductForApi } = {}, + { slugify, parseMaterialsInput, mapProductForApi } = {}, ) { fastify.get( '/api/admin/products', @@ -23,32 +21,17 @@ export async function registerAdminProductRoutes( '/api/admin/uploads', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { - if (!request.isMultipart()) { - reply.code(400).send({ error: 'Ожидается multipart/form-data' }) - return + try { + const urls = await persistMultipartImages(request, { maxFiles: 10 }) + return { urls } + } catch (error) { + const message = error instanceof Error ? error.message : 'Не удалось загрузить файлы' + const statusCode = + error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode) + ? Number(error.statusCode) + : 400 + return reply.code(statusCode).send({ error: message }) } - - const uploadsDir = path.join(process.cwd(), 'uploads') - await fs.promises.mkdir(uploadsDir, { recursive: true }) - - const urls = [] - const parts = request.parts() - - for await (const part of parts) { - if (part.type !== 'file') continue - const ext = safeExtFromFilename(part.filename) - if (!ext) { - reply.code(400).send({ error: 'Разрешены только файлы: png, jpg, jpeg, webp' }) - return - } - const id = crypto.randomUUID() - const fileName = `${id}${ext}` - const fullPath = path.join(uploadsDir, fileName) - await fs.promises.writeFile(fullPath, await part.toBuffer()) - urls.push(`/uploads/${fileName}`) - } - - return { urls } }, ) diff --git a/server/src/routes/api/admin-users.js b/server/src/routes/api/admin-users.js index bbdae79..4c5a653 100644 --- a/server/src/routes/api/admin-users.js +++ b/server/src/routes/api/admin-users.js @@ -1,5 +1,5 @@ import { prisma } from '../../lib/prisma.js' -import { hashPassword, normalizeEmail } from '../../lib/auth.js' +import { normalizeEmail } from '../../lib/auth.js' export async function registerAdminUserRoutes(fastify) { fastify.get( @@ -36,7 +36,6 @@ export async function registerAdminUserRoutes(fastify) { id: true, email: true, name: true, - passwordHash: true, createdAt: true, updatedAt: true, }, @@ -48,7 +47,6 @@ export async function registerAdminUserRoutes(fastify) { id: u.id, email: u.email, name: u.name, - hasPassword: Boolean(u.passwordHash), createdAt: u.createdAt, updatedAt: u.updatedAt, })) @@ -76,24 +74,16 @@ export async function registerAdminUserRoutes(fastify) { return } - const password = body.password ? String(body.password) : '' - if (password && password.length < 8) { - reply.code(400).send({ error: 'Пароль минимум 8 символов' }) - return - } - const exists = await prisma.user.findUnique({ where: { email } }) if (exists) { reply.code(409).send({ error: 'Почта уже занята' }) return } - const passwordHash = password ? await hashPassword(password) : null const user = await prisma.user.create({ data: { email, name: name && name.length ? name : null, - passwordHash: passwordHash ?? undefined, }, }) @@ -101,7 +91,6 @@ export async function registerAdminUserRoutes(fastify) { id: user.id, email: user.email, name: user.name, - hasPassword: Boolean(user.passwordHash), createdAt: user.createdAt, updatedAt: user.updatedAt, }) @@ -149,23 +138,11 @@ export async function registerAdminUserRoutes(fastify) { data.name = name && name.length ? name : null } - if (body.password !== undefined) { - const password = body.password ? String(body.password) : '' - if (password) { - if (password.length < 8) { - reply.code(400).send({ error: 'Пароль минимум 8 символов' }) - return - } - data.passwordHash = await hashPassword(password) - } - } - const user = await prisma.user.update({ where: { id }, data }) return { id: user.id, email: user.email, name: user.name, - hasPassword: Boolean(user.passwordHash), createdAt: user.createdAt, updatedAt: user.updatedAt, } diff --git a/server/src/routes/api/info-page.js b/server/src/routes/api/info-page.js new file mode 100644 index 0000000..f14bff4 --- /dev/null +++ b/server/src/routes/api/info-page.js @@ -0,0 +1,118 @@ +import { prisma } from '../../lib/prisma.js' + +function validateBlockPayload(body, reply) { + const key = String(body?.key || '').trim() + const title = String(body?.title || '').trim() + const content = String(body?.body || '').trim() + const sort = Number(body?.sort ?? 0) + const published = body?.published === undefined ? true : Boolean(body.published) + + if (!key) return reply.code(400).send({ error: 'key обязателен' }) + if (!/^[a-z0-9_-]{2,60}$/i.test(key)) { + return reply.code(400).send({ error: 'key должен быть 2-60 символов: буквы, цифры, "_" или "-"' }) + } + if (!title) return reply.code(400).send({ error: 'title обязателен' }) + if (title.length > 120) return reply.code(400).send({ error: 'title максимум 120 символов' }) + if (!content) return reply.code(400).send({ error: 'body обязателен' }) + if (content.length > 5000) return reply.code(400).send({ error: 'body максимум 5000 символов' }) + if (!Number.isFinite(sort) || Math.abs(sort) > 10_000) return reply.code(400).send({ error: 'Некорректный sort' }) + + return { key, title, body: content, sort: Math.trunc(sort), published } +} + +export async function registerInfoPageRoutes(fastify) { + fastify.get('/api/info-page/blocks', async () => { + const items = await prisma.infoPageBlock.findMany({ + where: { published: true }, + orderBy: [{ sort: 'asc' }, { createdAt: 'asc' }], + }) + return { items } + }) + + fastify.get( + '/api/admin/info-page/blocks', + { preHandler: [fastify.verifyAdmin] }, + async () => { + const items = await prisma.infoPageBlock.findMany({ orderBy: [{ sort: 'asc' }, { createdAt: 'asc' }] }) + return { items } + }, + ) + + fastify.post( + '/api/admin/info-page/blocks', + { preHandler: [fastify.verifyAdmin] }, + async (request, reply) => { + const validated = validateBlockPayload(request.body, reply) + if (!validated) return + + try { + const item = await prisma.infoPageBlock.create({ data: validated }) + return reply.code(201).send({ item }) + } catch { + return reply.code(409).send({ error: 'Блок с таким key уже существует' }) + } + }, + ) + + fastify.patch( + '/api/admin/info-page/blocks/:id', + { preHandler: [fastify.verifyAdmin] }, + async (request, reply) => { + const { id } = request.params + const existing = await prisma.infoPageBlock.findUnique({ where: { id } }) + if (!existing) return reply.code(404).send({ error: 'Блок не найден' }) + + const body = request.body ?? {} + const data = {} + if (body.key !== undefined) { + const key = String(body.key || '').trim() + if (!key) return reply.code(400).send({ error: 'key обязателен' }) + if (!/^[a-z0-9_-]{2,60}$/i.test(key)) { + return reply.code(400).send({ error: 'key должен быть 2-60 символов: буквы, цифры, "_" или "-"' }) + } + data.key = key + } + if (body.title !== undefined) { + const title = String(body.title || '').trim() + if (!title) return reply.code(400).send({ error: 'title обязателен' }) + if (title.length > 120) return reply.code(400).send({ error: 'title максимум 120 символов' }) + data.title = title + } + if (body.body !== undefined) { + const content = String(body.body || '').trim() + if (!content) return reply.code(400).send({ error: 'body обязателен' }) + if (content.length > 5000) return reply.code(400).send({ error: 'body максимум 5000 символов' }) + data.body = content + } + if (body.sort !== undefined) { + const sort = Number(body.sort) + if (!Number.isFinite(sort) || Math.abs(sort) > 10_000) return reply.code(400).send({ error: 'Некорректный sort' }) + data.sort = Math.trunc(sort) + } + if (body.published !== undefined) { + data.published = Boolean(body.published) + } + + try { + const item = await prisma.infoPageBlock.update({ where: { id }, data }) + return { item } + } catch { + return reply.code(409).send({ error: 'Блок с таким key уже существует' }) + } + }, + ) + + fastify.delete( + '/api/admin/info-page/blocks/:id', + { preHandler: [fastify.verifyAdmin] }, + async (request, reply) => { + const { id } = request.params + try { + await prisma.infoPageBlock.delete({ where: { id } }) + return reply.code(204).send() + } catch { + return reply.code(404).send({ error: 'Блок не найден' }) + } + }, + ) +} diff --git a/server/src/routes/api/public-reviews.js b/server/src/routes/api/public-reviews.js index 0fa9948..959bbb1 100644 --- a/server/src/routes/api/public-reviews.js +++ b/server/src/routes/api/public-reviews.js @@ -1,7 +1,27 @@ import { publicReviewAuthorDisplay } from '../../lib/review-display.js' import { prisma } from '../../lib/prisma.js' +import { persistMultipartImages } from '../../lib/upload-images.js' export async function registerPublicReviewRoutes(fastify) { + fastify.post( + '/api/reviews/upload-image', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + try { + const urls = await persistMultipartImages(request, { maxFiles: 1 }) + if (urls.length !== 1) return reply.code(400).send({ error: 'Нужно прикрепить 1 изображение' }) + return { url: urls[0] } + } catch (error) { + const message = error instanceof Error ? error.message : 'Не удалось загрузить изображение' + const statusCode = + error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode) + ? Number(error.statusCode) + : 400 + return reply.code(statusCode).send({ error: message }) + } + }, + ) + fastify.get('/api/reviews/latest', async (request, reply) => { const limitRaw = request.query?.limit const limitParsed = typeof limitRaw === 'string' ? Number(limitRaw) : Number(limitRaw) @@ -22,6 +42,7 @@ export async function registerPublicReviewRoutes(fastify) { id: r.id, rating: r.rating, text: r.text, + imageUrl: r.imageUrl, createdAt: r.createdAt, authorDisplay: publicReviewAuthorDisplay(r.user), productId: r.productId, @@ -60,6 +81,7 @@ export async function registerPublicReviewRoutes(fastify) { id: r.id, rating: r.rating, text: r.text, + imageUrl: r.imageUrl, createdAt: r.createdAt, authorDisplay: publicReviewAuthorDisplay(r.user), })) @@ -84,6 +106,12 @@ export async function registerPublicReviewRoutes(fastify) { const textRaw = request.body?.text const text = textRaw === null || textRaw === undefined ? null : String(textRaw).trim() if (text !== null && text.length > 1000) return reply.code(400).send({ error: 'Отзыв слишком длинный' }) + const imageUrlRaw = request.body?.imageUrl + const imageUrl = imageUrlRaw === null || imageUrlRaw === undefined ? null : String(imageUrlRaw).trim() + if (imageUrl !== null && imageUrl.length > 300) return reply.code(400).send({ error: 'Ссылка на фото слишком длинная' }) + if (imageUrl !== null && imageUrl.length > 0 && !imageUrl.startsWith('/uploads/')) { + return reply.code(400).send({ error: 'Некорректная ссылка на изображение' }) + } try { const created = await prisma.review.create({ @@ -92,6 +120,7 @@ export async function registerPublicReviewRoutes(fastify) { userId, rating: Math.floor(rating), text: text && text.length ? text : null, + imageUrl: imageUrl && imageUrl.length ? imageUrl : null, status: 'pending', }, }) diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index db69fe9..c97fc3d 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -1,5 +1,17 @@ import { prisma } from '../lib/prisma.js' -import { hashPassword, issueEmailCode, normalizeEmail, verifyEmailCode, verifyPassword } from '../lib/auth.js' +import { issueEmailCode, normalizeEmail, verifyEmailCode } from '../lib/auth.js' + +function mapUserForClient(user) { + const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL) + const userEmail = normalizeEmail(user.email) + return { + id: user.id, + email: user.email, + name: user.name, + phone: user.phone, + isAdmin: Boolean(adminEmail) && userEmail === adminEmail, + } +} export async function registerAuthRoutes(fastify) { fastify.post('/api/auth/request-code', async (request, reply) => { @@ -27,38 +39,7 @@ export async function registerAuthRoutes(fastify) { }) const token = fastify.jwt.sign({ sub: user.id, email: user.email }) - return { token, user: { id: user.id, email: user.email, name: user.name, phone: user.phone } } - }) - - fastify.post('/api/auth/register', async (request, reply) => { - const email = normalizeEmail(request.body?.email) - const password = String(request.body?.password || '') - if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) - if (password.length < 8) return reply.code(400).send({ error: 'Пароль минимум 8 символов' }) - - const existing = await prisma.user.findUnique({ where: { email } }) - if (existing) return reply.code(409).send({ error: 'Пользователь уже существует' }) - - const passwordHash = await hashPassword(password) - const user = await prisma.user.create({ data: { email, passwordHash } }) - const token = fastify.jwt.sign({ sub: user.id, email: user.email }) - return reply.code(201).send({ token, user: { id: user.id, email: user.email, name: user.name, phone: user.phone } }) - }) - - fastify.post('/api/auth/login', async (request, reply) => { - const email = normalizeEmail(request.body?.email) - const password = String(request.body?.password || '') - if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) - if (!password) return reply.code(400).send({ error: 'Укажите пароль' }) - - const user = await prisma.user.findUnique({ where: { email } }) - if (!user?.passwordHash) return reply.code(401).send({ error: 'Неверные данные' }) - - const ok = await verifyPassword(password, user.passwordHash) - if (!ok) return reply.code(401).send({ error: 'Неверные данные' }) - - const token = fastify.jwt.sign({ sub: user.id, email: user.email }) - return { token, user: { id: user.id, email: user.email, name: user.name, phone: user.phone } } + return { token, user: mapUserForClient(user) } }) fastify.get( @@ -68,7 +49,7 @@ export async function registerAuthRoutes(fastify) { const userId = request.user.sub const user = await prisma.user.findUnique({ where: { id: userId } }) if (!user) return { user: null } - return { user: { id: user.id, email: user.email, name: user.name, phone: user.phone } } + return { user: mapUserForClient(user) } }, ) @@ -108,32 +89,7 @@ export async function registerAuthRoutes(fastify) { where: { id: userId }, data: { email: newEmail }, }) - return { user: { id: user.id, email: user.email, name: user.name, phone: user.phone } } - }, - ) - - fastify.patch( - '/api/me/password', - { preHandler: [fastify.authenticate] }, - async (request, reply) => { - const userId = request.user.sub - const currentPassword = request.body?.currentPassword ? String(request.body.currentPassword) : '' - const newPassword = String(request.body?.newPassword || '') - - if (newPassword.length < 8) return reply.code(400).send({ error: 'Новый пароль минимум 8 символов' }) - - const user = await prisma.user.findUnique({ where: { id: userId } }) - if (!user) return reply.code(404).send({ error: 'Пользователь не найден' }) - - if (user.passwordHash) { - if (!currentPassword) return reply.code(400).send({ error: 'Укажите текущий пароль' }) - const ok = await verifyPassword(currentPassword, user.passwordHash) - if (!ok) return reply.code(401).send({ error: 'Текущий пароль неверный' }) - } - - const passwordHash = await hashPassword(newPassword) - const updated = await prisma.user.update({ where: { id: userId }, data: { passwordHash } }) - return { user: { id: updated.id, email: updated.email, name: updated.name, phone: updated.phone } } + return { user: mapUserForClient(user) } }, ) @@ -160,7 +116,7 @@ export async function registerAuthRoutes(fastify) { where: { id: userId }, data: { name: name && name.length ? name : null, phone: phone && phone.length ? phone : null }, }) - return { user: { id: updated.id, email: updated.email, name: updated.name, phone: updated.phone } } + return { user: mapUserForClient(updated) } }, )