base commit
This commit is contained in:
@@ -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** на сервере.
|
||||
|
||||
|
||||
Generated
+640
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ export type AdminUser = {
|
||||
id: string
|
||||
email: string
|
||||
name: string | null
|
||||
hasPassword: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
@@ -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())}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { InfoPage } from './ui/InfoPage'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
DATABASE_URL="file:./dev.db"
|
||||
PORT=3333
|
||||
ADMIN_API_TOKEN=замените-на-секрет
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
JWT_SECRET=замените-на-секрет-jwt
|
||||
|
||||
# Разрешённый Origin фронта (через запятую при нескольких)
|
||||
|
||||
Generated
-10
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
+20
@@ -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");
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Vendored
+16
@@ -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 },
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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: 'Недостаточно прав' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,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,
|
||||
}
|
||||
|
||||
@@ -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: 'Блок не найден' })
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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) }
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user