base commit

This commit is contained in:
@kirill.komarov
2026-05-03 19:57:12 +05:00
parent 9139a24093
commit fe10f25b8c
53 changed files with 2064 additions and 1071 deletions
+3 -5
View File
@@ -35,9 +35,7 @@
### Данные и админка
- Данные загружаются/редактируются через **админку на фронте**.
- Админ‑роуты бэкенда защищены простым токеном:
- фронт отправляет `Authorization: Bearer <token>`
- токен задаётся в `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** на сервере.
+640
View File
@@ -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",
+3
View File
@@ -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",
+2
View File
@@ -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() {
<Route path="/auth/callback" element={<AuthCallbackPage />} />
<Route path="/cart" element={<CartPage />} />
<Route path="/checkout" element={<CheckoutPage />} />
<Route path="/info" element={<InfoPage />} />
<Route path="/me/*" element={<MeLayoutPage />} />
<Route path="/products/:id" element={<ProductPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
+37 -11
View File
@@ -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 | HTMLElement>(null)
const userMenuOpen = Boolean(userAnchorEl)
@@ -249,12 +262,22 @@ export function AppHeader() {
</Button>
))}
{!isAdmin && (
<>
{user && (
<Tooltip title="Заказы">
<IconButton color="inherit" sx={{ ml: 1 }} onClick={() => navigate('/me/orders')} aria-label="Заказы">
<Badge color="secondary" badgeContent={activeOrdersCount} invisible={activeOrdersCount === 0}>
<LocalShippingOutlinedIcon />
</Badge>
</IconButton>
</Tooltip>
)}
<Tooltip title={user ? 'Корзина' : 'Авторизуйтесь для совершения покупок'}>
<span>
<IconButton
color="inherit"
sx={{ ml: 1 }}
disabled={!user}
onClick={() => {
if (!user) navigate('/auth')
else navigate('/cart')
@@ -265,8 +288,9 @@ export function AppHeader() {
<ShoppingCartOutlinedIcon />
</Badge>
</IconButton>
</span>
</Tooltip>
</>
)}
<IconButton color="inherit" onClick={openUserMenu} sx={{ ml: 1 }} aria-label="Пользователь">
<Badge
@@ -330,14 +354,16 @@ export function AppHeader() {
{i.label}
</Button>
))}
<Button
variant="text"
onClick={() => go(user ? '/cart' : '/auth')}
sx={{ justifyContent: 'flex-start' }}
disabled={!user}
>
{!isAdmin && (
<Button variant="text" onClick={() => go(user ? '/cart' : '/auth')} sx={{ justifyContent: 'flex-start' }}>
Корзина
</Button>
)}
{user && !isAdmin && (
<Button variant="text" onClick={() => go('/me/orders')} sx={{ justifyContent: 'flex-start' }}>
Заказы
</Button>
)}
<Button variant="text" onClick={() => go(user ? '/me' : '/auth')} sx={{ justifyContent: 'flex-start' }}>
{user ? 'Профиль' : 'Вход / регистрация'}
</Button>
@@ -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<InfoPageBlock, 'key' | 'title' | 'body' | 'sort' | 'published'>,
): 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<Pick<InfoPageBlock, 'key' | 'title' | 'body' | 'sort' | 'published'>>,
): 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<void> {
await apiClient.delete(`admin/info-page/blocks/${id}`)
}
@@ -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<AdminOrdersListResponse> {
const { data } = await apiClient.get<AdminOrdersListResponse>('admin/orders', {
params,
headers: { Authorization: `Bearer ${token}` },
})
export async function fetchAdminOrders(params?: {
status?: string
deliveryType?: 'delivery' | 'pickup'
q?: string
page?: number
pageSize?: number
}): Promise<AdminOrdersListResponse> {
const { data } = await apiClient.get<AdminOrdersListResponse>('admin/orders', { params })
return data
}
export async function fetchAdminOrder(token: string, id: string): Promise<AdminOrderDetailResponse> {
const { data } = await apiClient.get<AdminOrderDetailResponse>(`admin/orders/${id}`, {
headers: { Authorization: `Bearer ${token}` },
})
export async function fetchAdminOrder(id: string): Promise<AdminOrderDetailResponse> {
const { data } = await apiClient.get<AdminOrderDetailResponse>(`admin/orders/${id}`)
return data
}
export async function setAdminOrderStatus(token: string, id: string, status: string): Promise<void> {
await apiClient.patch(
`admin/orders/${id}/status`,
{ status },
{
headers: { Authorization: `Bearer ${token}` },
},
)
export async function setAdminOrderStatus(id: string, status: string): Promise<void> {
await apiClient.patch(`admin/orders/${id}/status`, { status })
}
export async function postAdminOrderMessage(token: string, id: string, text: string): Promise<void> {
await apiClient.post(
`admin/orders/${id}/messages`,
{ text },
{
headers: { Authorization: `Bearer ${token}` },
},
)
export async function postAdminOrderMessage(id: string, text: string): Promise<void> {
await apiClient.post(`admin/orders/${id}/messages`, { text })
}
+10 -27
View File
@@ -43,16 +43,12 @@ export async function fetchCategories(): Promise<Category[]> {
return data
}
export async function fetchAdminProducts(token: string): Promise<Product[]> {
const { data } = await apiClient.get<Product[]>('admin/products', {
headers: { Authorization: `Bearer ${token}` },
})
export async function fetchAdminProducts(): Promise<Product[]> {
const { data } = await apiClient.get<Product[]>('admin/products')
return data
}
export async function createProduct(
token: string,
body: {
export async function createProduct(body: {
title: string
slug?: string
shortDescription?: string | null
@@ -66,16 +62,12 @@ export async function createProduct(
inStock?: boolean
leadTimeDays?: number | null
categoryId: string
},
): Promise<Product> {
const { data } = await apiClient.post<Product>('admin/products', body, {
headers: { Authorization: `Bearer ${token}` },
})
}): Promise<Product> {
const { data } = await apiClient.post<Product>('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<Product> {
const { data } = await apiClient.patch<Product>(`admin/products/${id}`, body, {
headers: { Authorization: `Bearer ${token}` },
})
const { data } = await apiClient.patch<Product>(`admin/products/${id}`, body)
return data
}
export async function deleteProduct(token: string, id: string): Promise<void> {
await apiClient.delete(`admin/products/${id}`, {
headers: { Authorization: `Bearer ${token}` },
})
export async function deleteProduct(id: string): Promise<void> {
await apiClient.delete(`admin/products/${id}`)
}
export async function createCategory(
token: string,
body: { name: string; slug?: string; sort?: number },
): Promise<Category> {
const { data } = await apiClient.post<Category>('admin/categories', body, {
headers: { Authorization: `Bearer ${token}` },
})
export async function createCategory(body: { name: string; slug?: string; sort?: number }): Promise<Category> {
const { data } = await apiClient.post<Category>('admin/categories', body)
return data
}
+12 -1
View File
@@ -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<void> {
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
}
@@ -18,23 +18,15 @@ export type AdminReviewsListResponse = {
pageSize: number
}
export async function fetchAdminReviews(
token: string,
params?: { status?: string; page?: number; pageSize?: number },
): Promise<AdminReviewsListResponse> {
const { data } = await apiClient.get<AdminReviewsListResponse>('admin/reviews', {
params,
headers: { Authorization: `Bearer ${token}` },
})
export async function fetchAdminReviews(params?: {
status?: string
page?: number
pageSize?: number
}): Promise<AdminReviewsListResponse> {
const { data } = await apiClient.get<AdminReviewsListResponse>('admin/reviews', { params })
return data
}
export async function moderateReview(token: string, id: string, action: 'approve' | 'reject'): Promise<void> {
await apiClient.patch(
`admin/reviews/${id}`,
{ action },
{
headers: { Authorization: `Bearer ${token}` },
},
)
export async function moderateReview(id: string, action: 'approve' | 'reject'): Promise<void> {
await apiClient.patch(`admin/reviews/${id}`, { action })
}
+12 -24
View File
@@ -8,40 +8,28 @@ export type AdminUsersListResponse = {
pageSize: number
}
export async function fetchAdminUsers(
token: string,
params?: { q?: string; page?: number; pageSize?: number },
): Promise<AdminUsersListResponse> {
const { data } = await apiClient.get<AdminUsersListResponse>('admin/users', {
params,
headers: { Authorization: `Bearer ${token}` },
})
export async function fetchAdminUsers(params?: {
q?: string
page?: number
pageSize?: number
}): Promise<AdminUsersListResponse> {
const { data } = await apiClient.get<AdminUsersListResponse>('admin/users', { params })
return data
}
export async function createAdminUser(
token: string,
body: { email: string; name?: string | null; password?: string },
): Promise<AdminUser> {
const { data } = await apiClient.post<AdminUser>('admin/users', body, {
headers: { Authorization: `Bearer ${token}` },
})
export async function createAdminUser(body: { email: string; name?: string | null }): Promise<AdminUser> {
const { data } = await apiClient.post<AdminUser>('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<AdminUser> {
const { data } = await apiClient.patch<AdminUser>(`admin/users/${id}`, body, {
headers: { Authorization: `Bearer ${token}` },
})
const { data } = await apiClient.patch<AdminUser>(`admin/users/${id}`, body)
return data
}
export async function deleteAdminUser(token: string, id: string): Promise<void> {
await apiClient.delete(`admin/users/${id}`, {
headers: { Authorization: `Bearer ${token}` },
})
export async function deleteAdminUser(id: string): Promise<void> {
await apiClient.delete(`admin/users/${id}`)
}
-1
View File
@@ -2,7 +2,6 @@ export type AdminUser = {
id: string
email: string
name: string | null
hasPassword: boolean
createdAt: string
updatedAt: string
}
+1
View File
@@ -0,0 +1 @@
export { AdminInfoPage } from './ui/AdminInfoPage'
@@ -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<InfoPageBlock>()
const form = useForm<FormState>({ 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 (
<Box>
<Stack direction="row" sx={{ mb: 2, alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="h4">Информационная страница</Typography>
<Button variant="contained" onClick={openCreate}>
Новый блок
</Button>
</Stack>
<Typography color="text.secondary" sx={{ mb: 2 }}>
Управление блоками страницы с процессом покупки, оплаты и доставки.
</Typography>
{blocksQuery.isError && <Alert severity="error">Не удалось загрузить блоки.</Alert>}
{err && (
<Alert severity="error" sx={{ mb: 2 }}>
{getErrorMessage(err)}
</Alert>
)}
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Key</TableCell>
<TableCell>Заголовок</TableCell>
<TableCell>Порядок</TableCell>
<TableCell>Опубликован</TableCell>
<TableCell align="right">Действия</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((item) => (
<TableRow key={item.id} hover>
<TableCell>{item.key}</TableCell>
<TableCell>{item.title}</TableCell>
<TableCell>{item.sort}</TableCell>
<TableCell>{item.published ? 'Да' : 'Нет'}</TableCell>
<TableCell align="right">
<EntityRowActions
onEdit={() => openEdit(item)}
onDelete={() => deleteMut.mutate(item.id)}
deleteDisabled={deleteMut.isPending}
/>
</TableCell>
</TableRow>
))}
{items.length === 0 && !blocksQuery.isLoading && (
<TableRow>
<TableCell colSpan={5} sx={{ color: 'text.secondary' }}>
Блоков пока нет.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<Dialog open={dialogOpen} onClose={closeDialog} fullWidth maxWidth="md">
<DialogTitle>{editing ? 'Редактировать блок' : 'Новый блок'}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<Controller
control={form.control}
name="key"
render={({ field }) => <TextField label="Key (латиница, цифры, _-)" fullWidth required {...field} />}
/>
<Controller
control={form.control}
name="title"
render={({ field }) => <TextField label="Заголовок" fullWidth required {...field} />}
/>
<Controller
control={form.control}
name="body"
render={({ field }) => (
<TextField label="Содержимое" fullWidth multiline minRows={5} required {...field} />
)}
/>
<Controller
control={form.control}
name="sort"
render={({ field }) => <TextField label="Порядок сортировки" fullWidth {...field} />}
/>
<Controller
control={form.control}
name="published"
render={({ field }) => (
<FormControlLabel
control={<Switch checked={field.value} onChange={(_, v) => field.onChange(v)} />}
label="Показывать на публичной странице"
/>
)}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={closeDialog}>Отмена</Button>
<Button
variant="contained"
onClick={() => saveMut.mutate()}
disabled={
saveMut.isPending ||
!form.watch('key').trim() ||
!form.watch('title').trim() ||
!form.watch('body').trim()
}
>
{editing ? 'Сохранить' : 'Создать'}
</Button>
</DialogActions>
</Dialog>
</Box>
)
}
@@ -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: <AssignmentOutlinedIcon /> },
{ to: '/admin/reviews', label: 'Отзывы', icon: <RateReviewOutlinedIcon /> },
{ to: '/admin/users', label: 'Пользователи', icon: <PeopleOutlinedIcon /> },
{ to: '/admin/info', label: 'Инфо-страница', icon: <DescriptionOutlinedIcon /> },
],
[],
)
if (!isAdmin) {
return <Navigate to="/auth" replace />
}
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() {
<Route path="orders" element={<AdminOrdersPage />} />
<Route path="reviews" element={<AdminReviewsPage />} />
<Route path="users" element={<AdminUsersPage />} />
<Route path="info" element={<AdminInfoPage />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
</Routes>
</Box>
@@ -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<string | null>(() => 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<string | null>(null)
const [msg, setMsg] = useState('')
const tokenForm = useForm<TokenFormState>({ 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,40 +105,10 @@ export function AdminOrdersPage() {
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Введите API-токен из <code>ADMIN_API_TOKEN</code> (сохраняется в sessionStorage).
Управление заказами доступно пользователю с правами администратора.
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2 }}>
<Controller
control={tokenForm.control}
name="token"
render={({ field }) => (
<TextField
label="Токен (Bearer)"
type="password"
fullWidth
{...field}
placeholder={token ? '••••••••' : ''}
/>
)}
/>
<Button variant="contained" onClick={saveToken} sx={{ minWidth: 140 }}>
Сохранить
</Button>
</Stack>
{!token && <Alert severity="info">После сохранения токена появится список заказов.</Alert>}
{token && (
<>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2 }}>
<TextField
size="small"
label="Поиск (id/email)"
value={q}
onChange={(e) => setQ(e.target.value)}
fullWidth
/>
<TextField size="small" label="Поиск (id/email)" value={q} onChange={(e) => setQ(e.target.value)} fullWidth />
<FormControl size="small" sx={{ minWidth: 220 }}>
<InputLabel id="status-label">Статус</InputLabel>
<Select
@@ -194,18 +154,25 @@ export function AdminOrdersPage() {
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Покупатель</TableCell>
<TableCell>Статус</TableCell>
<TableCell>Создан</TableCell>
<TableCell>Сумма</TableCell>
<TableCell>Позиций</TableCell>
<TableCell align="right">Действия</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((o) => (
{groupedItems.map((group) => (
<Fragment key={`group:${group.statusCode}`}>
<TableRow>
<TableCell colSpan={6} sx={{ fontWeight: 700, bgcolor: 'action.hover' }}>
{orderStatusLabelRu(group.statusCode)} ({group.items.length})
</TableCell>
</TableRow>
{group.items.map((o) => (
<TableRow key={o.id} hover>
<TableCell>{o.id.slice(-8)}</TableCell>
<TableCell>{o.user.email}</TableCell>
<TableCell>{orderStatusLabelRu(o.status)}</TableCell>
<TableCell>{new Date(o.createdAt).toLocaleString('ru-RU')}</TableCell>
<TableCell>{formatPriceRub(o.totalCents)}</TableCell>
<TableCell>{o.itemsCount}</TableCell>
<TableCell align="right">
@@ -215,6 +182,8 @@ export function AdminOrdersPage() {
</TableCell>
</TableRow>
))}
</Fragment>
))}
{ordersQuery.isSuccess && items.length === 0 && (
<TableRow>
<TableCell colSpan={6} sx={{ color: 'text.secondary' }}>
@@ -224,8 +193,6 @@ export function AdminOrdersPage() {
)}
</TableBody>
</Table>
</>
)}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} fullWidth maxWidth="md">
<DialogTitle>Заказ</DialogTitle>
@@ -282,14 +249,9 @@ export function AdminOrdersPage() {
</Stack>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
<TextField
label="Ответ админа"
value={msg}
onChange={(e) => setMsg(e.target.value)}
fullWidth
multiline
minRows={2}
/>
<Box sx={{ flexGrow: 1, width: '100%' }}>
<RichTextMessageEditor value={msg} onChange={setMsg} placeholder="Ответ админа" />
</Box>
<Button
variant="contained"
onClick={() => msgMut.mutate()}
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useState } from 'react'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
@@ -12,42 +12,22 @@ 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 { fetchAdminReviews, moderateReview } from '@/entities/review/api/admin-review-api'
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'
export function AdminReviewsPage() {
const qc = useQueryClient()
const [token, setTokenState] = useState<string | null>(() => getAdminToken())
const tokenForm = useForm<TokenFormState>({ 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 [status] = useState('pending')
const reviewsQuery = useQuery({
queryKey: ['admin', 'reviews', token],
queryFn: () => fetchAdminReviews(token!, { status: 'pending', page: 1, pageSize: 50 }),
enabled: Boolean(token),
queryKey: ['admin', 'reviews', status],
queryFn: () => fetchAdminReviews({ status, page: 1, pageSize: 50 }),
})
const modMut = useMutation({
mutationFn: (params: { id: string; action: 'approve' | 'reject' }) =>
moderateReview(token!, params.id, params.action),
onSuccess: () => void qc.invalidateQueries({ queryKey: ['admin', 'reviews'] }),
mutationFn: (params: { id: string; action: 'approve' | 'reject' }) => moderateReview(params.id, params.action),
onSuccess: () => void invalidateQueryKeys(qc, [['admin', 'reviews']]),
})
const error = modMut.error
@@ -61,34 +41,14 @@ export function AdminReviewsPage() {
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Введите API-токен из <code>ADMIN_API_TOKEN</code>.
Модерация отзывов доступна пользователю с правами администратора.
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2 }}>
<Controller
control={tokenForm.control}
name="token"
render={({ field }) => (
<TextField
label="Токен (Bearer)"
type="password"
fullWidth
{...field}
placeholder={token ? '••••••••' : ''}
/>
)}
/>
<Button variant="contained" onClick={saveToken} sx={{ minWidth: 140 }}>
Сохранить
</Button>
<TextField size="small" label="Статус" value="pending" disabled />
</Stack>
{!token && <Alert severity="info">После сохранения токена появится список отзывов на модерации.</Alert>}
{token && (
<>
{reviewsQuery.isError && <Alert severity="error">Не удалось загрузить отзывы.</Alert>}
{error && <Alert severity="error">{(error as Error).message}</Alert>}
{error && <Alert severity="error">{getErrorMessage(error)}</Alert>}
<Table size="small">
<TableHead>
@@ -137,8 +97,6 @@ export function AdminReviewsPage() {
)}
</TableBody>
</Table>
</>
)}
</Box>
)
}
@@ -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<string | null>(() => getAdminToken())
const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState<AdminUser | null>(null)
const { dialogOpen, editing, openCreateDialog, openEditDialog, closeDialog } = useEditDialogState<AdminUser>()
const [qInput, setQInput] = useState('')
const [q, setQ] = useState('')
const [page, setPage] = useState(0)
const [rowsPerPage, setRowsPerPage] = useState(20)
const tokenForm = useForm<TokenFormState>({
defaultValues: { token: '' },
mode: 'onChange',
})
const userForm = useForm<UserFormState>({
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,36 +143,8 @@ export function AdminUsersPage() {
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Введите API-токен из{' '}
<Typography component="span" sx={{ fontFamily: 'monospace' }}>
.env
</Typography>{' '}
сервера (<code>ADMIN_API_TOKEN</code>). Он сохраняется только в памяти браузера (sessionStorage).
Управление пользователями доступно пользователю с правами администратора.
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 3 }}>
<Controller
control={tokenForm.control}
name="token"
render={({ field }) => (
<TextField
label="Токен (Bearer)"
type="password"
fullWidth
{...field}
placeholder={token ? '••••••••' : ''}
/>
)}
/>
<Button variant="contained" onClick={saveToken} sx={{ minWidth: 140 }}>
Сохранить
</Button>
</Stack>
{!token && <Alert severity="info">После сохранения токена здесь появится список пользователей.</Alert>}
{token && (
<>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}>
<Button variant="contained" onClick={openCreate}>
Новый пользователь
@@ -216,13 +160,13 @@ export function AdminUsersPage() {
{usersQuery.isError && (
<Alert severity="error" sx={{ mb: 2 }}>
Ошибка загрузки. Проверьте токен и что сервер запущен.
Ошибка загрузки. Проверьте, что сервер запущен, и у вас есть права администратора.
</Alert>
)}
{mutationError && (
<Alert severity="error" sx={{ mb: 2 }}>
{(mutationError as Error).message}
{getErrorMessage(mutationError)}
</Alert>
)}
@@ -231,7 +175,6 @@ export function AdminUsersPage() {
<TableRow>
<TableCell>Почта</TableCell>
<TableCell>Имя</TableCell>
<TableCell>Пароль</TableCell>
<TableCell>Создан</TableCell>
<TableCell>Обновлён</TableCell>
<TableCell align="right">Действия</TableCell>
@@ -242,30 +185,21 @@ export function AdminUsersPage() {
<TableRow key={u.id} hover>
<TableCell>{u.email}</TableCell>
<TableCell>{u.name ?? '—'}</TableCell>
<TableCell>{u.hasPassword ? 'задан' : 'нет'}</TableCell>
<TableCell>{formatDt(u.createdAt)}</TableCell>
<TableCell>{formatDt(u.updatedAt)}</TableCell>
<TableCell align="right">
<Button size="small" onClick={() => openEdit(u)}>
Изменить
</Button>
<Button
size="small"
color="error"
disabled={deleteMut.isPending}
onClick={() => {
if (!confirm(`Удалить пользователя ${u.email}?`)) return
deleteMut.mutate(u.id)
}}
>
Удалить
</Button>
<EntityRowActions
onEdit={() => openEdit(u)}
onDelete={() => deleteMut.mutate(u.id)}
deleteDisabled={deleteMut.isPending}
confirmDeleteMessage={`Удалить пользователя ${u.email}?`}
/>
</TableCell>
</TableRow>
))}
{users.length === 0 && !usersQuery.isLoading && (
<TableRow>
<TableCell colSpan={6} sx={{ color: 'text.secondary' }}>
<TableCell colSpan={5} sx={{ color: 'text.secondary' }}>
Пользователей пока нет.
</TableCell>
</TableRow>
@@ -285,10 +219,8 @@ export function AdminUsersPage() {
}}
rowsPerPageOptions={[10, 20, 50, 100]}
/>
</>
)}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} fullWidth maxWidth="xs">
<Dialog open={dialogOpen} onClose={closeDialog} fullWidth maxWidth="xs">
<DialogTitle>{editing ? 'Редактировать пользователя' : 'Новый пользователь'}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
@@ -302,23 +234,10 @@ export function AdminUsersPage() {
name="name"
render={({ field }) => <TextField label="Имя/ник" fullWidth {...field} />}
/>
<Controller
control={userForm.control}
name="password"
render={({ field }) => (
<TextField
label={editing ? 'Новый пароль (необязательно)' : 'Пароль (необязательно)'}
type="password"
fullWidth
helperText="Минимум 8 символов. Для редактирования можно оставить пустым."
{...field}
/>
)}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>Отмена</Button>
<Button onClick={closeDialog}>Отмена</Button>
<Button
variant="contained"
onClick={() => (editing ? updateMut.mutate() : createMut.mutate())}
+29 -96
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useState } from 'react'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
@@ -32,8 +32,11 @@ import {
} from '@/entities/product/api/product-api'
import type { Product } from '@/entities/product/model/types'
import { apiClient } from '@/shared/api/client'
import { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token'
import { formatPriceRub } from '@/shared/lib/format-price'
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 = {
title: string
@@ -67,16 +70,9 @@ const emptyForm = (): FormState => ({
export function AdminPage() {
const queryClient = useQueryClient()
const [token, setToken] = useState<string | null>(() => getAdminToken())
const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState<Product | null>(null)
const { dialogOpen, editing, openCreateDialog, openEditDialog, closeDialog } = useEditDialogState<Product>()
const [catOpen, setCatOpen] = useState(false)
const tokenForm = useForm<{ token: string }>({
defaultValues: { token: '' },
mode: 'onChange',
})
const productForm = useForm<FormState>({
defaultValues: emptyForm(),
mode: 'onChange',
@@ -91,40 +87,23 @@ export function AdminPage() {
const categoryIdValue = productForm.watch('categoryId')
const inStockValue = productForm.watch('inStock')
useEffect(() => {
tokenForm.reset({ token: '' })
}, [token, tokenForm])
const categoriesQuery = useQuery({
queryKey: ['categories'],
queryFn: () => fetchCategories(),
})
const productsQuery = useQuery({
queryKey: ['admin', 'products', token],
queryFn: () => fetchAdminProducts(token!),
enabled: Boolean(token),
queryKey: ['admin', 'products'],
queryFn: fetchAdminProducts,
})
const saveToken = () => {
const t = tokenForm.getValues('token').trim()
if (!t) {
clearAdminToken()
setToken(null)
return
}
setAdminToken(t)
setToken(t)
}
const openCreate = () => {
setEditing(null)
productForm.reset(emptyForm())
setDialogOpen(true)
openCreateDialog()
}
const openEdit = (p: Product) => {
setEditing(p)
openEditDialog(p)
const urls =
(p.images ?? [])
.slice()
@@ -144,7 +123,6 @@ export function AdminPage() {
leadTimeDays: p.leadTimeDays ? String(p.leadTimeDays) : '',
categoryId: p.categoryId,
})
setDialogOpen(true)
}
const createMut = useMutation({
@@ -164,7 +142,7 @@ export function AdminPage() {
.split(',')
.map((x) => x.trim())
.filter(Boolean)
await createProduct(token!, {
await createProduct({
title: form.title.trim(),
slug: form.slug.trim() || undefined,
shortDescription: form.shortDescription.trim() || null,
@@ -180,9 +158,8 @@ export function AdminPage() {
})
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'products'] })
void queryClient.invalidateQueries({ queryKey: ['products'] })
setDialogOpen(false)
void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']])
closeDialog()
},
})
@@ -203,7 +180,7 @@ export function AdminPage() {
.split(',')
.map((x) => x.trim())
.filter(Boolean)
await updateProduct(token!, editing!.id, {
await updateProduct(editing!.id, {
title: form.title.trim(),
slug: form.slug.trim(),
shortDescription: form.shortDescription.trim() || null,
@@ -219,30 +196,28 @@ export function AdminPage() {
})
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'products'] })
void queryClient.invalidateQueries({ queryKey: ['products'] })
setDialogOpen(false)
void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']])
closeDialog()
},
})
const deleteMut = useMutation({
mutationFn: (id: string) => deleteProduct(token!, id),
mutationFn: (id: string) => deleteProduct(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'products'] })
void queryClient.invalidateQueries({ queryKey: ['products'] })
void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']])
},
})
const createCategoryMut = useMutation({
mutationFn: () => {
const v = categoryForm.getValues()
return createCategory(token!, {
return createCategory({
name: v.name.trim(),
slug: v.slug.trim() || undefined,
})
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['categories'] })
void invalidateQueryKeys(queryClient, [['categories']])
setCatOpen(false)
categoryForm.reset({ name: '', slug: '' })
},
@@ -260,10 +235,7 @@ export function AdminPage() {
const fd = new FormData()
Array.from(files).forEach((f) => fd.append('files', f))
const { data } = await apiClient.post<{ urls: string[] }>('admin/uploads', fd, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'multipart/form-data',
},
headers: { 'Content-Type': 'multipart/form-data' },
})
return data.urls
},
@@ -288,38 +260,8 @@ export function AdminPage() {
Админка
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Введите API-токен из{' '}
<Typography component="span" sx={{ fontFamily: 'monospace' }}>
.env
</Typography>{' '}
сервера (<code>ADMIN_API_TOKEN</code>). Он сохраняется только в памяти браузера (sessionStorage).
Управление товарами и категориями. Доступно пользователю с правами администратора.
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 3 }}>
<Controller
control={tokenForm.control}
name="token"
render={({ field }) => (
<TextField
label="Токен (Bearer)"
type="password"
fullWidth
{...field}
placeholder={token ? '••••••••' : ''}
/>
)}
/>
<Button variant="contained" onClick={saveToken} sx={{ minWidth: 140 }}>
Сохранить
</Button>
</Stack>
{!token && (
<Alert severity="info">После сохранения токена здесь появится список товаров и формы управления.</Alert>
)}
{token && (
<>
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
<Button variant="contained" onClick={openCreate}>
Новый товар
@@ -331,13 +273,13 @@ export function AdminPage() {
{productsQuery.isError && (
<Alert severity="error" sx={{ mb: 2 }}>
Ошибка загрузки. Проверьте токен и что сервер запущен.
Ошибка загрузки. Проверьте, что сервер запущен, и у вас есть права администратора.
</Alert>
)}
{mutationError && (
<Alert severity="error" sx={{ mb: 2 }}>
{(mutationError as Error).message}
{getErrorMessage(mutationError)}
</Alert>
)}
@@ -359,21 +301,14 @@ export function AdminPage() {
<TableCell>{formatPriceRub(p.priceCents)}</TableCell>
<TableCell>{p.published ? 'да' : 'нет'}</TableCell>
<TableCell align="right">
<Button size="small" onClick={() => openEdit(p)}>
Изменить
</Button>
<Button size="small" color="error" onClick={() => deleteMut.mutate(p.id)}>
Удалить
</Button>
<EntityRowActions onEdit={() => openEdit(p)} onDelete={() => deleteMut.mutate(p.id)} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
)}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} fullWidth maxWidth="sm">
<Dialog open={dialogOpen} onClose={closeDialog} fullWidth maxWidth="sm">
<DialogTitle>{editing ? 'Редактировать товар' : 'Новый товар'}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
@@ -448,7 +383,7 @@ export function AdminPage() {
flexDirection: { xs: 'column', sm: 'row' },
}}
>
<Button component="label" variant="outlined" disabled={uploadImagesMut.isPending || !token}>
<Button component="label" variant="outlined" disabled={uploadImagesMut.isPending}>
Выбрать файлы
<input
hidden
@@ -464,9 +399,7 @@ export function AdminPage() {
/>
</Button>
{uploadImagesMut.isPending && <Typography color="text.secondary">Загрузка</Typography>}
{uploadImagesMut.isError && (
<Typography color="error">Не удалось загрузить фото (проверьте токен и сервер)</Typography>
)}
{uploadImagesMut.isError && <Typography color="error">Не удалось загрузить фото</Typography>}
</Box>
{productForm.watch('imageUrls').length > 0 && (
@@ -559,7 +492,7 @@ export function AdminPage() {
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>Отмена</Button>
<Button onClick={closeDialog}>Отмена</Button>
<Button
variant="contained"
onClick={handleSubmit}
+2 -52
View File
@@ -34,16 +34,13 @@ export function AuthPage() {
const { register, watch } = useForm<{
email: string
code: string
password: string
}>({
defaultValues: { email: '', code: '', password: '' },
defaultValues: { email: '', code: '' },
mode: 'onChange',
})
const email = watch('email')
const code = watch('code')
const password = watch('password')
useEffect(() => {
if (user) navigate('/', { replace: true })
}, [navigate, user])
@@ -71,27 +68,7 @@ export function AuthPage() {
},
})
const registerPassword = useMutation({
mutationFn: async () => {
const { data } = await apiClient.post<AuthResponse>('auth/register', { email, password })
tokenSet(data.token)
setMessage(`Регистрация выполнена: ${data.user.email}`)
navigate('/', { replace: true })
},
})
const loginPassword = useMutation({
mutationFn: async () => {
const { data } = await apiClient.post<AuthResponse>('auth/login', { email, password })
tokenSet(data.token)
setMessage(`Вход выполнен: ${data.user.email}`)
navigate('/', { replace: true })
},
})
const errMsg = getApiErrorMessage(
requestCode.error || verifyCode.error || registerPassword.error || loginPassword.error,
)
const errMsg = getApiErrorMessage(requestCode.error || verifyCode.error)
return (
<Box>
@@ -144,33 +121,6 @@ export function AuthPage() {
Войти
</Button>
</Stack>
<Divider />
<Typography variant="h6">Вариант 2: Email + пароль</Typography>
<TextField
label="Пароль"
type="password"
{...register('password')}
fullWidth
helperText="Минимум 8 символов для регистрации"
/>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<Button
variant="contained"
onClick={() => registerPassword.mutate()}
disabled={!email || password.length < 8 || registerPassword.isPending}
>
Зарегистрироваться
</Button>
<Button
variant="outlined"
onClick={() => loginPassword.mutate()}
disabled={!email || !password || loginPassword.isPending}
>
Войти
</Button>
</Stack>
</Stack>
</Box>
)
+1
View File
@@ -0,0 +1 @@
export { InfoPage } from './ui/InfoPage'
+42
View File
@@ -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 (
<Box>
<Typography variant="h4" gutterBottom>
Информация для покупателей
</Typography>
<Typography color="text.secondary" sx={{ mb: 2 }}>
Как оформить заказ, как проходит доставка, оплата и другие важные детали.
</Typography>
{q.isLoading && <Typography color="text.secondary">Загрузка</Typography>}
{q.isError && <Alert severity="error">Не удалось загрузить информацию.</Alert>}
{q.isSuccess && q.data.items.length === 0 && <Alert severity="info">Раздел пока не заполнен.</Alert>}
{q.isSuccess && q.data.items.length > 0 && (
<Stack spacing={2}>
{q.data.items.map((block) => (
<Paper key={block.id} variant="outlined" sx={{ p: 2, borderRadius: 2 }}>
<Typography variant="h6" sx={{ mb: 0.75 }}>
{block.title}
</Typography>
<Typography sx={{ whiteSpace: 'pre-wrap' }}>{block.body}</Typography>
</Paper>
))}
</Stack>
)}
</Box>
)
}
-43
View File
@@ -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() {
Текущая почта: <b>{user.email}</b>
</Typography>
{passwordErrorMsg && (
<Alert severity="error" sx={{ mb: 2 }}>
{passwordErrorMsg}
</Alert>
)}
{emailErrorMsg && (
<Alert severity="error" sx={{ mb: 2 }}>
{emailErrorMsg}
@@ -143,34 +128,6 @@ export function MePage() {
</Stack>
</Stack>
</Box>
<Divider />
<Box>
<Typography variant="h6" gutterBottom>
Смена пароля
</Typography>
<Stack spacing={2}>
<TextField
label="Текущий пароль (если установлен)"
type="password"
{...passwordForm.register('currentPassword')}
/>
<TextField label="Новый пароль" type="password" {...passwordForm.register('newPassword')} />
<Button
variant="contained"
disabled={passwordForm.watch('newPassword').length < 8 || pendingPassword}
onClick={() =>
changePasswordFx({
currentPassword: passwordForm.getValues('currentPassword') || undefined,
newPassword: passwordForm.getValues('newPassword'),
})
}
>
Сохранить пароль
</Button>
</Stack>
</Box>
</Stack>
</Box>
)
@@ -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 && <Typography color="text.secondary">Нет сообщений.</Typography>}
</Stack>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
<TextField
label="Сообщение"
value={text}
onChange={(e) => setText(e.target.value)}
fullWidth
multiline
minRows={2}
/>
<Box sx={{ flexGrow: 1, width: '100%' }}>
<RichTextMessageEditor value={text} onChange={setText} placeholder="Сообщение" />
</Box>
<Button
variant="contained"
sx={{ minWidth: 140 }}
@@ -9,7 +9,6 @@ import DialogTitle from '@mui/material/DialogTitle'
import Divider from '@mui/material/Divider'
import Rating from '@mui/material/Rating'
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 axios from 'axios'
@@ -21,10 +20,11 @@ import {
payOrderStub,
postOrderMessage,
} from '@/entities/order/api/order-api'
import { postProductReview } from '@/entities/product/api/reviews-api'
import { postProductReview, uploadReviewImage } from '@/entities/product/api/reviews-api'
import { markOrderMessagesRead } from '@/entities/user/api/messages-api'
import { formatPriceRub } from '@/shared/lib/format-price'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
function reviewSubmitErrorMessage(err: unknown): string {
if (axios.isAxiosError(err)) {
@@ -59,6 +59,7 @@ export function OrderDetailPage() {
const [reviewTarget, setReviewTarget] = useState<{ productId: string; title: string } | null>(null)
const [reviewRating, setReviewRating] = useState<number>(5)
const [reviewText, setReviewText] = useState('')
const [reviewImageUrl, setReviewImageUrl] = useState<string | null>(null)
const orderQuery = useQuery({
queryKey: ['me', 'orders', id],
@@ -108,16 +109,23 @@ export function OrderDetailPage() {
await postProductReview(reviewTarget.productId, {
rating: reviewRating,
text: t.length ? t : null,
imageUrl: reviewImageUrl,
})
},
onSuccess: async () => {
setReviewTarget(null)
setReviewRating(5)
setReviewText('')
setReviewImageUrl(null)
await qc.invalidateQueries({ queryKey: ['me', 'orders', id, 'review-eligibility'] })
},
})
const uploadReviewImageMut = useMutation({
mutationFn: (file: File) => uploadReviewImage(file),
onSuccess: ({ url }) => setReviewImageUrl(url),
})
useEffect(() => {
if (!id || orderQuery.status !== 'success' || !order) return
void (async () => {
@@ -326,14 +334,9 @@ export function OrderDetailPage() {
</Stack>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
<TextField
label="Сообщение"
value={text}
onChange={(e) => setText(e.target.value)}
fullWidth
multiline
minRows={2}
/>
<Box sx={{ flexGrow: 1, width: '100%' }}>
<RichTextMessageEditor value={text} onChange={setText} placeholder="Сообщение" />
</Box>
<Button
variant="contained"
onClick={() => msgMut.mutate()}
@@ -348,7 +351,13 @@ export function OrderDetailPage() {
<Dialog
open={Boolean(reviewTarget)}
onClose={() => !reviewMut.isPending && setReviewTarget(null)}
onClose={() => {
if (reviewMut.isPending) return
setReviewTarget(null)
setReviewRating(5)
setReviewText('')
setReviewImageUrl(null)
}}
fullWidth
maxWidth="sm"
>
@@ -363,15 +372,60 @@ export function OrderDetailPage() {
if (v !== null) setReviewRating(v)
}}
/>
<TextField
sx={{ mt: 2 }}
label="Комментарий (необязательно)"
<Box sx={{ mt: 2 }}>
<RichTextMessageEditor
value={reviewText}
onChange={(e) => setReviewText(e.target.value)}
fullWidth
multiline
minRows={3}
onChange={setReviewText}
placeholder="Комментарий (необязательно)"
/>
</Box>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5} sx={{ mt: 2, alignItems: { sm: 'center' } }}>
<Button component="label" variant="outlined" disabled={uploadReviewImageMut.isPending}>
{reviewImageUrl ? 'Заменить фото' : 'Прикрепить фото'}
<input
hidden
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(e) => {
const file = e.target.files?.[0]
if (!file) return
uploadReviewImageMut.mutate(file)
e.currentTarget.value = ''
}}
/>
</Button>
{reviewImageUrl && (
<Button
color="error"
variant="text"
onClick={() => setReviewImageUrl(null)}
disabled={reviewMut.isPending}
>
Удалить фото
</Button>
)}
</Stack>
{reviewImageUrl && (
<Box
component="img"
src={reviewImageUrl}
alt="Фото к отзыву"
sx={{
mt: 1,
width: 120,
height: 120,
objectFit: 'cover',
borderRadius: 1.5,
border: 1,
borderColor: 'divider',
}}
/>
)}
{uploadReviewImageMut.isError && (
<Alert severity="error" sx={{ mt: 2 }}>
Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.
</Alert>
)}
{reviewMut.isError && (
<Alert severity="error" sx={{ mt: 2 }}>
{reviewSubmitErrorMessage(reviewMut.error)}
@@ -379,7 +433,15 @@ export function OrderDetailPage() {
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setReviewTarget(null)} disabled={reviewMut.isPending}>
<Button
onClick={() => {
setReviewTarget(null)
setReviewRating(5)
setReviewText('')
setReviewImageUrl(null)
}}
disabled={reviewMut.isPending}
>
Отмена
</Button>
<Button variant="contained" disabled={reviewMut.isPending} onClick={() => reviewMut.mutate()}>
+18 -3
View File
@@ -1,12 +1,16 @@
import { useMemo } from 'react'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Divider from '@mui/material/Divider'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { useQuery } from '@tanstack/react-query'
import { Link as RouterLink } from 'react-router-dom'
import { fetchMyOrders } from '@/entities/order/api/order-api'
import { ORDER_STATUSES } from '@/shared/constants/order'
import { formatPriceRub } from '@/shared/lib/format-price'
import { groupOrdersByStatus } from '@/shared/lib/group-orders-by-status'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
export function OrdersPage() {
@@ -15,7 +19,8 @@ export function OrdersPage() {
queryFn: fetchMyOrders,
})
const items = ordersQuery.data?.items ?? []
const items = useMemo(() => ordersQuery.data?.items ?? [], [ordersQuery.data?.items])
const groups = useMemo(() => groupOrdersByStatus(items, ORDER_STATUSES), [items])
return (
<Box>
@@ -29,8 +34,14 @@ export function OrdersPage() {
<Alert severity="info">Заказов пока нет. Оформите заказ из корзины.</Alert>
)}
<Stack spacing={3}>
{groups.map((group) => (
<Box key={group.status}>
<Typography variant="h6" sx={{ mb: 1 }}>
{orderStatusLabelRu(group.status)} ({group.items.length})
</Typography>
<Stack spacing={2}>
{items.map((o) => (
{group.items.map((o) => (
<Box
key={o.id}
sx={{
@@ -45,7 +56,7 @@ export function OrdersPage() {
<Box sx={{ flexGrow: 1 }}>
<Typography sx={{ fontWeight: 700 }}>Заказ #{o.id.slice(-6)}</Typography>
<Typography color="text.secondary" variant="body2">
Статус: {orderStatusLabelRu(o.status)} · {o.itemsCount} поз.
{new Date(o.createdAt).toLocaleString('ru-RU')} · {o.itemsCount} поз.
</Typography>
</Box>
<Typography sx={{ fontWeight: 700 }}>{formatPriceRub(o.totalCents)}</Typography>
@@ -59,6 +70,10 @@ export function OrdersPage() {
</Box>
))}
</Stack>
<Divider sx={{ mt: 2 }} />
</Box>
))}
</Stack>
</Box>
)
}
@@ -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() {
Текущая почта: <b>{user.email}</b>
</Typography>
{passwordErrorMsg && (
<Alert severity="error" sx={{ mb: 2 }}>
{passwordErrorMsg}
</Alert>
)}
{emailErrorMsg && (
<Alert severity="error" sx={{ mb: 2 }}>
{emailErrorMsg}
@@ -150,34 +135,6 @@ export function SettingsPage() {
</Stack>
</Stack>
</Box>
<Divider />
<Box>
<Typography variant="h6" gutterBottom>
Смена пароля
</Typography>
<Stack spacing={2}>
<TextField
label="Текущий пароль (если установлен)"
type="password"
{...passwordForm.register('currentPassword')}
/>
<TextField label="Новый пароль" type="password" {...passwordForm.register('newPassword')} />
<Button
variant="contained"
disabled={passwordForm.watch('newPassword').length < 8 || pendingPassword}
onClick={() =>
changePasswordFx({
currentPassword: passwordForm.getValues('currentPassword') || undefined,
newPassword: passwordForm.getValues('newPassword'),
})
}
>
Сохранить пароль
</Button>
</Stack>
</Box>
</Stack>
</Box>
)
@@ -218,6 +218,21 @@ export function ProductPage() {
Без текстового комментария.
</Typography>
)}
{rv.imageUrl && (
<Box
component="img"
src={rv.imageUrl}
alt="Фото к отзыву"
sx={{
width: 140,
height: 140,
objectFit: 'cover',
borderRadius: 1.5,
border: 1,
borderColor: 'divider',
}}
/>
)}
</Stack>
</Paper>
)
-26
View File
@@ -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()
}
@@ -0,0 +1,4 @@
export function getErrorMessage(error: unknown, fallback = 'Произошла ошибка'): string {
if (error instanceof Error && error.message) return error.message
return fallback
}
@@ -0,0 +1,24 @@
type OrderLike = {
status: string
createdAt: string
}
export function groupOrdersByStatus<T extends OrderLike>(items: T[], statuses: readonly string[]) {
const byStatus = new Map<string, T[]>()
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)
}
@@ -0,0 +1,5 @@
import type { QueryClient, QueryKey } from '@tanstack/react-query'
export async function invalidateQueryKeys(queryClient: QueryClient, keys: QueryKey[]): Promise<void> {
await Promise.all(keys.map((queryKey) => queryClient.invalidateQueries({ queryKey })))
}
@@ -0,0 +1,28 @@
import { useCallback, useState } from 'react'
export function useEditDialogState<T>() {
const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState<T | null>(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,
}
}
+2 -11
View File
@@ -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<string | null>(null)
export const $user = createStore<AuthUser | null>(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<unknown | null>(null)
.on(changePasswordFx.failData, (_, e) => e)
.reset(changePasswordFx, logout)
export const $requestEmailChangeCodeError = createStore<unknown | null>(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,
})
+43
View File
@@ -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 (
<Stack direction="row" spacing={0.5} sx={{ justifyContent: 'flex-end' }}>
{onEdit && (
<Button size="small" onClick={onEdit}>
{editLabel}
</Button>
)}
{onDelete && (
<Button
size="small"
color="error"
disabled={deleteDisabled}
onClick={() => {
if (confirmDeleteMessage && !confirm(confirmDeleteMessage)) return
onDelete()
}}
>
{deleteLabel}
</Button>
)}
</Stack>
)
}
@@ -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 (
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, overflow: 'hidden', bgcolor: 'background.paper' }}>
<Stack direction="row" spacing={0.5} sx={{ p: 0.75, borderBottom: 1, borderColor: 'divider' }}>
<IconButton
size="small"
onClick={() => editor?.chain().focus().toggleBold().run()}
color={editor?.isActive('bold') ? 'primary' : 'default'}
disabled={disabled}
aria-label="Жирный"
>
<FormatBoldOutlinedIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => editor?.chain().focus().toggleItalic().run()}
color={editor?.isActive('italic') ? 'primary' : 'default'}
disabled={disabled}
aria-label="Курсив"
>
<FormatItalicOutlinedIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => editor?.chain().focus().toggleBulletList().run()}
color={editor?.isActive('bulletList') ? 'primary' : 'default'}
disabled={disabled}
aria-label="Список"
>
<FormatListBulletedOutlinedIcon fontSize="small" />
</IconButton>
</Stack>
<Box
sx={{
px: 1.5,
py: 1.25,
'& .ProseMirror': {
minHeight: 72,
outline: 'none',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
},
'& .ProseMirror p.is-editor-empty:first-of-type::before': {
content: `"${placeholder}"`,
color: 'text.disabled',
pointerEvents: 'none',
float: 'left',
height: 0,
},
}}
>
<EditorContent editor={editor} />
</Box>
</Box>
)
}
@@ -109,6 +109,22 @@ export function ReviewsBlock() {
{text}
</Typography>
</Stack>
{r.imageUrl && (
<Box
component="img"
src={r.imageUrl}
alt="Фото к отзыву"
sx={{
mt: 1.5,
width: 120,
height: 120,
objectFit: 'cover',
borderRadius: 1.5,
border: 1,
borderColor: 'divider',
}}
/>
)}
</Paper>
)
})}
+1 -1
View File
@@ -1,6 +1,6 @@
DATABASE_URL="file:./dev.db"
PORT=3333
ADMIN_API_TOKEN=замените-на-секрет
ADMIN_EMAIL=admin@example.com
JWT_SECRET=замените-на-секрет-jwt
# Разрешённый Origin фронта (через запятую при нескольких)
-10
View File
@@ -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",
-1
View File
@@ -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"
@@ -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");
+14
View File
@@ -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])
}
+2
View File
@@ -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 }))
-8
View File
@@ -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)
}
+16
View File
@@ -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 },
})
}
+44
View File
@@ -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
}
+18 -11
View File
@@ -1,16 +1,23 @@
/**
* Простая защита админ-роутов: заголовок Authorization: Bearer <ADMIN_API_TOKEN>
*/
export function registerAuth(fastify) {
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' })
function normalizeEmail(email) {
return String(email || '').trim().toLowerCase()
}
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: 'Неверный или отсутствующий токен' })
fastify.decorate('verifyAdmin', async function verifyAdmin(request, reply) {
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
if (!adminEmail || !adminEmail.includes('@')) {
return reply.code(503).send({ error: 'ADMIN_EMAIL не задан в .env' })
}
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: 'Недостаточно прав' })
}
})
}
+2 -2
View File
@@ -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,
})
+12 -29
View File
@@ -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
}
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}`)
}
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 })
}
},
)
+1 -24
View File
@@ -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,
}
+118
View File
@@ -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: 'Блок не найден' })
}
},
)
}
+29
View File
@@ -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',
},
})
+17 -61
View File
@@ -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) }
},
)