base commit

This commit is contained in:
@kirill.komarov
2026-05-03 19:57:12 +05:00
parent 9139a24093
commit fe10f25b8c
53 changed files with 2064 additions and 1071 deletions
+3 -5
View File
@@ -35,9 +35,7 @@
### Данные и админка ### Данные и админка
- Данные загружаются/редактируются через **админку на фронте**. - Данные загружаются/редактируются через **админку на фронте**.
- Админ‑роуты бэкенда защищены простым токеном: - Админ‑роуты бэкенда доступны только авторизованному пользователю с email из `ADMIN_EMAIL` в `server/.env`.
- фронт отправляет `Authorization: Bearer <token>`
- токен задаётся в `server/.env` как `ADMIN_API_TOKEN`
### Форматирование и линтинг (client) ### Форматирование и линтинг (client)
@@ -54,7 +52,7 @@
```bash ```bash
cd server cd server
cp .env.example .env # при необходимости поправьте ADMIN_API_TOKEN cp .env.example .env # укажите ADMIN_EMAIL
npm install npm install
npx prisma migrate dev # если база ещё не создана npx prisma migrate dev # если база ещё не создана
npx prisma db seed # опционально: тестовые категории и товары 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** на сервере. Для боевого размещения фронта и API на разных доменах задайте `VITE_API_URL` (например `https://api.example.com/api`) и **CORS_ORIGIN** на сервере.
+640
View File
@@ -13,6 +13,9 @@
"@mui/icons-material": "^9.0.0", "@mui/icons-material": "^9.0.0",
"@mui/material": "^9.0.0", "@mui/material": "^9.0.0",
"@tanstack/react-query": "^5.100.5", "@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", "axios": "^1.15.2",
"effector": "^23.4.4", "effector": "^23.4.4",
"effector-react": "^23.3.0", "effector-react": "^23.3.0",
@@ -618,6 +621,34 @@
"node": "^20.19.0 || ^22.13.0 || >=24" "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": { "node_modules/@humanfs/core": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -1417,6 +1448,447 @@
"react": "^18 || ^19" "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": { "node_modules/@tybys/wasm-util": {
"version": "0.10.1", "version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -1515,6 +1987,12 @@
"@types/geojson": "*" "@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": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.59.1", "version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz",
@@ -3786,6 +4264,15 @@
"dev": true, "dev": true,
"license": "Apache-2.0" "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": { "node_modules/fast-json-stable-stringify": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "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==", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT" "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": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -5604,6 +6097,12 @@
"node": ">= 0.8.0" "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": { "node_modules/own-keys": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
@@ -5851,6 +6350,135 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT" "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": { "node_modules/protocol-buffers-schema": {
"version": "3.6.1", "version": "3.6.1",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
@@ -6137,6 +6765,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/rw": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", "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": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+3
View File
@@ -18,6 +18,9 @@
"@mui/icons-material": "^9.0.0", "@mui/icons-material": "^9.0.0",
"@mui/material": "^9.0.0", "@mui/material": "^9.0.0",
"@tanstack/react-query": "^5.100.5", "@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", "axios": "^1.15.2",
"effector": "^23.4.4", "effector": "^23.4.4",
"effector-react": "^23.3.0", "effector-react": "^23.3.0",
+2
View File
@@ -6,6 +6,7 @@ import { AuthCallbackPage, AuthPage } from '@/pages/auth'
import { CartPage } from '@/pages/cart' import { CartPage } from '@/pages/cart'
import { CheckoutPage } from '@/pages/checkout' import { CheckoutPage } from '@/pages/checkout'
import { HomePage } from '@/pages/home' import { HomePage } from '@/pages/home'
import { InfoPage } from '@/pages/info'
import { MeLayoutPage } from '@/pages/me' import { MeLayoutPage } from '@/pages/me'
import { ProductPage } from '@/pages/product' import { ProductPage } from '@/pages/product'
@@ -21,6 +22,7 @@ export function App() {
<Route path="/auth/callback" element={<AuthCallbackPage />} /> <Route path="/auth/callback" element={<AuthCallbackPage />} />
<Route path="/cart" element={<CartPage />} /> <Route path="/cart" element={<CartPage />} />
<Route path="/checkout" element={<CheckoutPage />} /> <Route path="/checkout" element={<CheckoutPage />} />
<Route path="/info" element={<InfoPage />} />
<Route path="/me/*" element={<MeLayoutPage />} /> <Route path="/me/*" element={<MeLayoutPage />} />
<Route path="/products/:id" element={<ProductPage />} /> <Route path="/products/:id" element={<ProductPage />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
+54 -28
View File
@@ -2,6 +2,7 @@ import { useState } from 'react'
import AccountCircleOutlinedIcon from '@mui/icons-material/AccountCircleOutlined' import AccountCircleOutlinedIcon from '@mui/icons-material/AccountCircleOutlined'
import DarkModeOutlinedIcon from '@mui/icons-material/DarkModeOutlined' import DarkModeOutlinedIcon from '@mui/icons-material/DarkModeOutlined'
import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined' import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined'
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined'
import MenuOutlinedIcon from '@mui/icons-material/MenuOutlined' import MenuOutlinedIcon from '@mui/icons-material/MenuOutlined'
import ShoppingCartOutlinedIcon from '@mui/icons-material/ShoppingCartOutlined' import ShoppingCartOutlinedIcon from '@mui/icons-material/ShoppingCartOutlined'
import AppBar from '@mui/material/AppBar' 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 type { ColorScheme } from '@/app/providers/theme-controller'
import { useThemeController } from '@/app/providers/theme-controller' import { useThemeController } from '@/app/providers/theme-controller'
import { fetchMyCart } from '@/entities/cart/api/cart-api' import { fetchMyCart } from '@/entities/cart/api/cart-api'
import { fetchMyOrders } from '@/entities/order/api/order-api'
import { STORE_NAME } from '@/shared/config' import { STORE_NAME } from '@/shared/config'
import { $user, logout, tokenSet } from '@/shared/model/auth' import { $user, logout, tokenSet } from '@/shared/model/auth'
import { BearLogo } from '@/shared/ui/BearLogo' import { BearLogo } from '@/shared/ui/BearLogo'
@@ -37,7 +39,7 @@ type NavItem = { label: string; to: string }
const navItems: NavItem[] = [ const navItems: NavItem[] = [
{ label: 'Каталог', to: '/' }, { label: 'Каталог', to: '/' },
{ label: 'Админка', to: '/admin' }, { label: 'О покупке', to: '/info' },
] ]
function ThemeControlsDesktop(props: { function ThemeControlsDesktop(props: {
@@ -168,15 +170,26 @@ export function AppHeader() {
const { mode, resolvedMode, scheme, setMode, setScheme, cycleMode } = useThemeController() const { mode, resolvedMode, scheme, setMode, setScheme, cycleMode } = useThemeController()
const user = useUnit($user) const user = useUnit($user)
const navigate = useNavigate() const navigate = useNavigate()
const isAdmin = Boolean(user?.isAdmin)
const cartQuery = useQuery({ const cartQuery = useQuery({
queryKey: ['me', 'cart'], queryKey: ['me', 'cart'],
queryFn: fetchMyCart, queryFn: fetchMyCart,
enabled: Boolean(user), enabled: Boolean(user) && !isAdmin,
}) })
const cartCount = cartQuery.data?.items?.length ?? 0 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 [userAnchorEl, setUserAnchorEl] = useState<null | HTMLElement>(null)
const userMenuOpen = Boolean(userAnchorEl) const userMenuOpen = Boolean(userAnchorEl)
@@ -249,24 +262,35 @@ export function AppHeader() {
</Button> </Button>
))} ))}
<Tooltip title={user ? 'Корзина' : 'Авторизуйтесь для совершения покупок'}> {!isAdmin && (
<span> <>
<IconButton {user && (
color="inherit" <Tooltip title="Заказы">
sx={{ ml: 1 }} <IconButton color="inherit" sx={{ ml: 1 }} onClick={() => navigate('/me/orders')} aria-label="Заказы">
disabled={!user} <Badge color="secondary" badgeContent={activeOrdersCount} invisible={activeOrdersCount === 0}>
onClick={() => { <LocalShippingOutlinedIcon />
if (!user) navigate('/auth') </Badge>
else navigate('/cart') </IconButton>
}} </Tooltip>
aria-label="Корзина" )}
>
<Badge color="secondary" badgeContent={user ? cartCount : 0} invisible={!user || cartCount === 0}> <Tooltip title={user ? 'Корзина' : 'Авторизуйтесь для совершения покупок'}>
<ShoppingCartOutlinedIcon /> <IconButton
</Badge> color="inherit"
</IconButton> sx={{ ml: 1 }}
</span> onClick={() => {
</Tooltip> if (!user) navigate('/auth')
else navigate('/cart')
}}
aria-label="Корзина"
>
<Badge color="secondary" badgeContent={user ? cartCount : 0} invisible={!user || cartCount === 0}>
<ShoppingCartOutlinedIcon />
</Badge>
</IconButton>
</Tooltip>
</>
)}
<IconButton color="inherit" onClick={openUserMenu} sx={{ ml: 1 }} aria-label="Пользователь"> <IconButton color="inherit" onClick={openUserMenu} sx={{ ml: 1 }} aria-label="Пользователь">
<Badge <Badge
@@ -330,14 +354,16 @@ export function AppHeader() {
{i.label} {i.label}
</Button> </Button>
))} ))}
<Button {!isAdmin && (
variant="text" <Button variant="text" onClick={() => go(user ? '/cart' : '/auth')} sx={{ justifyContent: 'flex-start' }}>
onClick={() => go(user ? '/cart' : '/auth')} Корзина
sx={{ justifyContent: 'flex-start' }} </Button>
disabled={!user} )}
> {user && !isAdmin && (
Корзина <Button variant="text" onClick={() => go('/me/orders')} sx={{ justifyContent: 'flex-start' }}>
</Button> Заказы
</Button>
)}
<Button variant="text" onClick={() => go(user ? '/me' : '/auth')} sx={{ justifyContent: 'flex-start' }}> <Button variant="text" onClick={() => go(user ? '/me' : '/auth')} sx={{ justifyContent: 'flex-start' }}>
{user ? 'Профиль' : 'Вход / регистрация'} {user ? 'Профиль' : 'Вход / регистрация'}
</Button> </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 }> { export async function fetchAdminOrdersSummary(): Promise<{ attentionCount: number }> {
const { data } = await apiClient.get<{ attentionCount: number }>('admin/orders/summary', { const { data } = await apiClient.get<{ attentionCount: number }>('admin/orders/summary')
headers: { Authorization: `Bearer ${token}` },
})
return data return data
} }
export async function fetchAdminOrders( export async function fetchAdminOrders(params?: {
token: string, status?: string
params?: { status?: string; deliveryType?: 'delivery' | 'pickup'; q?: string; page?: number; pageSize?: number }, deliveryType?: 'delivery' | 'pickup'
): Promise<AdminOrdersListResponse> { q?: string
const { data } = await apiClient.get<AdminOrdersListResponse>('admin/orders', { page?: number
params, pageSize?: number
headers: { Authorization: `Bearer ${token}` }, }): Promise<AdminOrdersListResponse> {
}) const { data } = await apiClient.get<AdminOrdersListResponse>('admin/orders', { params })
return data return data
} }
export async function fetchAdminOrder(token: string, id: string): Promise<AdminOrderDetailResponse> { export async function fetchAdminOrder(id: string): Promise<AdminOrderDetailResponse> {
const { data } = await apiClient.get<AdminOrderDetailResponse>(`admin/orders/${id}`, { const { data } = await apiClient.get<AdminOrderDetailResponse>(`admin/orders/${id}`)
headers: { Authorization: `Bearer ${token}` },
})
return data return data
} }
export async function setAdminOrderStatus(token: string, id: string, status: string): Promise<void> { export async function setAdminOrderStatus(id: string, status: string): Promise<void> {
await apiClient.patch( await apiClient.patch(`admin/orders/${id}/status`, { status })
`admin/orders/${id}/status`,
{ status },
{
headers: { Authorization: `Bearer ${token}` },
},
)
} }
export async function postAdminOrderMessage(token: string, id: string, text: string): Promise<void> { export async function postAdminOrderMessage(id: string, text: string): Promise<void> {
await apiClient.post( await apiClient.post(`admin/orders/${id}/messages`, { text })
`admin/orders/${id}/messages`,
{ text },
{
headers: { Authorization: `Bearer ${token}` },
},
)
} }
+23 -40
View File
@@ -43,39 +43,31 @@ export async function fetchCategories(): Promise<Category[]> {
return data return data
} }
export async function fetchAdminProducts(token: string): Promise<Product[]> { export async function fetchAdminProducts(): Promise<Product[]> {
const { data } = await apiClient.get<Product[]>('admin/products', { const { data } = await apiClient.get<Product[]>('admin/products')
headers: { Authorization: `Bearer ${token}` },
})
return data return data
} }
export async function createProduct( export async function createProduct(body: {
token: string, title: string
body: { slug?: string
title: string shortDescription?: string | null
slug?: string description?: string | null
shortDescription?: string | null quantity?: number | null
description?: string | null materials?: string[]
quantity?: number | null priceCents: number
materials?: string[] imageUrl?: string | null
priceCents: number imageUrls?: string[]
imageUrl?: string | null published: boolean
imageUrls?: string[] inStock?: boolean
published: boolean leadTimeDays?: number | null
inStock?: boolean categoryId: string
leadTimeDays?: number | null }): Promise<Product> {
categoryId: string const { data } = await apiClient.post<Product>('admin/products', body)
},
): Promise<Product> {
const { data } = await apiClient.post<Product>('admin/products', body, {
headers: { Authorization: `Bearer ${token}` },
})
return data return data
} }
export async function updateProduct( export async function updateProduct(
token: string,
id: string, id: string,
body: Partial<{ body: Partial<{
title: string title: string
@@ -93,24 +85,15 @@ export async function updateProduct(
categoryId: string categoryId: string
}>, }>,
): Promise<Product> { ): Promise<Product> {
const { data } = await apiClient.patch<Product>(`admin/products/${id}`, body, { const { data } = await apiClient.patch<Product>(`admin/products/${id}`, body)
headers: { Authorization: `Bearer ${token}` },
})
return data return data
} }
export async function deleteProduct(token: string, id: string): Promise<void> { export async function deleteProduct(id: string): Promise<void> {
await apiClient.delete(`admin/products/${id}`, { await apiClient.delete(`admin/products/${id}`)
headers: { Authorization: `Bearer ${token}` },
})
} }
export async function createCategory( export async function createCategory(body: { name: string; slug?: string; sort?: number }): Promise<Category> {
token: string, const { data } = await apiClient.post<Category>('admin/categories', body)
body: { name: string; slug?: string; sort?: number },
): Promise<Category> {
const { data } = await apiClient.post<Category>('admin/categories', body, {
headers: { Authorization: `Bearer ${token}` },
})
return data return data
} }
+12 -1
View File
@@ -2,15 +2,25 @@ import { apiClient } from '@/shared/api/client'
export async function postProductReview( export async function postProductReview(
productId: string, productId: string,
body: { rating: number; text?: string | null }, body: { rating: number; text?: string | null; imageUrl?: string | null },
): Promise<void> { ): Promise<void> {
await apiClient.post(`products/${productId}/reviews`, body) 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 = { export type PublicReviewFeedItem = {
id: string id: string
rating: number rating: number
text: string | null text: string | null
imageUrl: string | null
createdAt: string createdAt: string
authorDisplay: string authorDisplay: string
productId: string productId: string
@@ -32,6 +42,7 @@ export type PublicProductReviewItem = {
id: string id: string
rating: number rating: number
text: string | null text: string | null
imageUrl: string | null
createdAt: string createdAt: string
authorDisplay: string authorDisplay: string
} }
@@ -18,23 +18,15 @@ export type AdminReviewsListResponse = {
pageSize: number pageSize: number
} }
export async function fetchAdminReviews( export async function fetchAdminReviews(params?: {
token: string, status?: string
params?: { status?: string; page?: number; pageSize?: number }, page?: number
): Promise<AdminReviewsListResponse> { pageSize?: number
const { data } = await apiClient.get<AdminReviewsListResponse>('admin/reviews', { }): Promise<AdminReviewsListResponse> {
params, const { data } = await apiClient.get<AdminReviewsListResponse>('admin/reviews', { params })
headers: { Authorization: `Bearer ${token}` },
})
return data return data
} }
export async function moderateReview(token: string, id: string, action: 'approve' | 'reject'): Promise<void> { export async function moderateReview(id: string, action: 'approve' | 'reject'): Promise<void> {
await apiClient.patch( await apiClient.patch(`admin/reviews/${id}`, { action })
`admin/reviews/${id}`,
{ action },
{
headers: { Authorization: `Bearer ${token}` },
},
)
} }
+12 -24
View File
@@ -8,40 +8,28 @@ export type AdminUsersListResponse = {
pageSize: number pageSize: number
} }
export async function fetchAdminUsers( export async function fetchAdminUsers(params?: {
token: string, q?: string
params?: { q?: string; page?: number; pageSize?: number }, page?: number
): Promise<AdminUsersListResponse> { pageSize?: number
const { data } = await apiClient.get<AdminUsersListResponse>('admin/users', { }): Promise<AdminUsersListResponse> {
params, const { data } = await apiClient.get<AdminUsersListResponse>('admin/users', { params })
headers: { Authorization: `Bearer ${token}` },
})
return data return data
} }
export async function createAdminUser( export async function createAdminUser(body: { email: string; name?: string | null }): Promise<AdminUser> {
token: string, const { data } = await apiClient.post<AdminUser>('admin/users', body)
body: { email: string; name?: string | null; password?: string },
): Promise<AdminUser> {
const { data } = await apiClient.post<AdminUser>('admin/users', body, {
headers: { Authorization: `Bearer ${token}` },
})
return data return data
} }
export async function updateAdminUser( export async function updateAdminUser(
token: string,
id: string, id: string,
body: Partial<{ email: string; name: string | null; password: string }>, body: Partial<{ email: string; name: string | null }>,
): Promise<AdminUser> { ): Promise<AdminUser> {
const { data } = await apiClient.patch<AdminUser>(`admin/users/${id}`, body, { const { data } = await apiClient.patch<AdminUser>(`admin/users/${id}`, body)
headers: { Authorization: `Bearer ${token}` },
})
return data return data
} }
export async function deleteAdminUser(token: string, id: string): Promise<void> { export async function deleteAdminUser(id: string): Promise<void> {
await apiClient.delete(`admin/users/${id}`, { await apiClient.delete(`admin/users/${id}`)
headers: { Authorization: `Bearer ${token}` },
})
} }
-1
View File
@@ -2,7 +2,6 @@ export type AdminUser = {
id: string id: string
email: string email: string
name: string | null name: string | null
hasPassword: boolean
createdAt: string createdAt: string
updatedAt: string updatedAt: string
} }
+1
View File
@@ -0,0 +1 @@
export { AdminInfoPage } from './ui/AdminInfoPage'
@@ -0,0 +1,216 @@
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions'
import DialogContent from '@mui/material/DialogContent'
import DialogTitle from '@mui/material/DialogTitle'
import FormControlLabel from '@mui/material/FormControlLabel'
import Stack from '@mui/material/Stack'
import Switch from '@mui/material/Switch'
import Table from '@mui/material/Table'
import TableBody from '@mui/material/TableBody'
import TableCell from '@mui/material/TableCell'
import TableHead from '@mui/material/TableHead'
import TableRow from '@mui/material/TableRow'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Controller, useForm } from 'react-hook-form'
import {
createInfoBlock,
deleteInfoBlock,
fetchAdminInfoBlocks,
type InfoPageBlock,
updateInfoBlock,
} from '@/entities/info/api/info-page-api'
import { getErrorMessage } from '@/shared/lib/get-error-message'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
import { useEditDialogState } from '@/shared/lib/use-edit-dialog-state'
import { EntityRowActions } from '@/shared/ui/EntityRowActions'
type FormState = {
key: string
title: string
body: string
sort: string
published: boolean
}
const emptyForm = (): FormState => ({ key: '', title: '', body: '', sort: '0', published: true })
export function AdminInfoPage() {
const qc = useQueryClient()
const { dialogOpen, editing, openCreateDialog, openEditDialog, closeDialog } = useEditDialogState<InfoPageBlock>()
const form = useForm<FormState>({ defaultValues: emptyForm(), mode: 'onChange' })
const blocksQuery = useQuery({
queryKey: ['admin', 'info-page', 'blocks'],
queryFn: fetchAdminInfoBlocks,
})
const saveMut = useMutation({
mutationFn: async () => {
const values = form.getValues()
const payload = {
key: values.key.trim(),
title: values.title.trim(),
body: values.body.trim(),
sort: Number(values.sort || 0),
published: values.published,
}
if (editing) return updateInfoBlock(editing.id, payload)
return createInfoBlock(payload)
},
onSuccess: async () => {
closeDialog()
form.reset(emptyForm())
await invalidateQueryKeys(qc, [
['admin', 'info-page', 'blocks'],
['info-page', 'public', 'blocks'],
])
},
})
const deleteMut = useMutation({
mutationFn: (id: string) => deleteInfoBlock(id),
onSuccess: async () => {
await invalidateQueryKeys(qc, [
['admin', 'info-page', 'blocks'],
['info-page', 'public', 'blocks'],
])
},
})
const openCreate = () => {
form.reset(emptyForm())
openCreateDialog()
}
const openEdit = (item: InfoPageBlock) => {
openEditDialog(item)
form.reset({
key: item.key,
title: item.title,
body: item.body,
sort: String(item.sort),
published: item.published,
})
}
const items = blocksQuery.data?.items ?? []
const err = saveMut.error ?? deleteMut.error
return (
<Box>
<Stack direction="row" sx={{ mb: 2, alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="h4">Информационная страница</Typography>
<Button variant="contained" onClick={openCreate}>
Новый блок
</Button>
</Stack>
<Typography color="text.secondary" sx={{ mb: 2 }}>
Управление блоками страницы с процессом покупки, оплаты и доставки.
</Typography>
{blocksQuery.isError && <Alert severity="error">Не удалось загрузить блоки.</Alert>}
{err && (
<Alert severity="error" sx={{ mb: 2 }}>
{getErrorMessage(err)}
</Alert>
)}
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Key</TableCell>
<TableCell>Заголовок</TableCell>
<TableCell>Порядок</TableCell>
<TableCell>Опубликован</TableCell>
<TableCell align="right">Действия</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((item) => (
<TableRow key={item.id} hover>
<TableCell>{item.key}</TableCell>
<TableCell>{item.title}</TableCell>
<TableCell>{item.sort}</TableCell>
<TableCell>{item.published ? 'Да' : 'Нет'}</TableCell>
<TableCell align="right">
<EntityRowActions
onEdit={() => openEdit(item)}
onDelete={() => deleteMut.mutate(item.id)}
deleteDisabled={deleteMut.isPending}
/>
</TableCell>
</TableRow>
))}
{items.length === 0 && !blocksQuery.isLoading && (
<TableRow>
<TableCell colSpan={5} sx={{ color: 'text.secondary' }}>
Блоков пока нет.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<Dialog open={dialogOpen} onClose={closeDialog} fullWidth maxWidth="md">
<DialogTitle>{editing ? 'Редактировать блок' : 'Новый блок'}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<Controller
control={form.control}
name="key"
render={({ field }) => <TextField label="Key (латиница, цифры, _-)" fullWidth required {...field} />}
/>
<Controller
control={form.control}
name="title"
render={({ field }) => <TextField label="Заголовок" fullWidth required {...field} />}
/>
<Controller
control={form.control}
name="body"
render={({ field }) => (
<TextField label="Содержимое" fullWidth multiline minRows={5} required {...field} />
)}
/>
<Controller
control={form.control}
name="sort"
render={({ field }) => <TextField label="Порядок сортировки" fullWidth {...field} />}
/>
<Controller
control={form.control}
name="published"
render={({ field }) => (
<FormControlLabel
control={<Switch checked={field.value} onChange={(_, v) => field.onChange(v)} />}
label="Показывать на публичной странице"
/>
)}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={closeDialog}>Отмена</Button>
<Button
variant="contained"
onClick={() => saveMut.mutate()}
disabled={
saveMut.isPending ||
!form.watch('key').trim() ||
!form.watch('title').trim() ||
!form.watch('body').trim()
}
>
{editing ? 'Сохранить' : 'Создать'}
</Button>
</DialogActions>
</Dialog>
</Box>
)
}
@@ -1,6 +1,7 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { useMemo, useState, useSyncExternalStore } from 'react' import { useMemo, useState } from 'react'
import AssignmentOutlinedIcon from '@mui/icons-material/AssignmentOutlined' import AssignmentOutlinedIcon from '@mui/icons-material/AssignmentOutlined'
import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined'
import MenuOutlinedIcon from '@mui/icons-material/MenuOutlined' import MenuOutlinedIcon from '@mui/icons-material/MenuOutlined'
import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined' import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined'
import RateReviewOutlinedIcon from '@mui/icons-material/RateReviewOutlined' import RateReviewOutlinedIcon from '@mui/icons-material/RateReviewOutlined'
@@ -19,13 +20,15 @@ import { useTheme } from '@mui/material/styles'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import useMediaQuery from '@mui/material/useMediaQuery' import useMediaQuery from '@mui/material/useMediaQuery'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom' import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
import { fetchAdminOrdersSummary } from '@/entities/order/api/admin-order-api' import { fetchAdminOrdersSummary } from '@/entities/order/api/admin-order-api'
import { AdminPage } from '@/pages/admin' import { AdminPage } from '@/pages/admin'
import { AdminInfoPage } from '@/pages/admin-info'
import { AdminOrdersPage } from '@/pages/admin-orders' import { AdminOrdersPage } from '@/pages/admin-orders'
import { AdminReviewsPage } from '@/pages/admin-reviews' import { AdminReviewsPage } from '@/pages/admin-reviews'
import { AdminUsersPage } from '@/pages/admin-users' import { AdminUsersPage } from '@/pages/admin-users'
import { getAdminToken, subscribeAdminTokenChange } from '@/shared/lib/admin-token' import { $user } from '@/shared/model/auth'
type NavItem = { type NavItem = {
to: string to: string
@@ -39,12 +42,13 @@ export function AdminLayoutPage() {
const theme = useTheme() const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('md')) const isMobile = useMediaQuery(theme.breakpoints.down('md'))
const [mobileOpen, setMobileOpen] = useState(false) const [mobileOpen, setMobileOpen] = useState(false)
const adminToken = useSyncExternalStore(subscribeAdminTokenChange, getAdminToken, () => null) const user = useUnit($user)
const isAdmin = Boolean(user?.isAdmin)
const ordersSummaryQuery = useQuery({ const ordersSummaryQuery = useQuery({
queryKey: ['admin', 'orders', 'summary', adminToken], queryKey: ['admin', 'orders', 'summary'],
queryFn: () => fetchAdminOrdersSummary(adminToken!), queryFn: fetchAdminOrdersSummary,
enabled: Boolean(adminToken), enabled: isAdmin,
refetchInterval: 45_000, refetchInterval: 45_000,
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
}) })
@@ -57,10 +61,15 @@ export function AdminLayoutPage() {
{ to: '/admin/orders', label: 'Заказы', icon: <AssignmentOutlinedIcon /> }, { to: '/admin/orders', label: 'Заказы', icon: <AssignmentOutlinedIcon /> },
{ to: '/admin/reviews', label: 'Отзывы', icon: <RateReviewOutlinedIcon /> }, { to: '/admin/reviews', label: 'Отзывы', icon: <RateReviewOutlinedIcon /> },
{ to: '/admin/users', label: 'Пользователи', icon: <PeopleOutlinedIcon /> }, { to: '/admin/users', label: 'Пользователи', icon: <PeopleOutlinedIcon /> },
{ to: '/admin/info', label: 'Инфо-страница', icon: <DescriptionOutlinedIcon /> },
], ],
[], [],
) )
if (!isAdmin) {
return <Navigate to="/auth" replace />
}
const activeTo = const activeTo =
navItems.find((x) => location.pathname === x.to)?.to ?? navItems.find((x) => location.pathname === x.to)?.to ??
navItems.find((x) => location.pathname.startsWith(`${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="orders" element={<AdminOrdersPage />} />
<Route path="reviews" element={<AdminReviewsPage />} /> <Route path="reviews" element={<AdminReviewsPage />} />
<Route path="users" element={<AdminUsersPage />} /> <Route path="users" element={<AdminUsersPage />} />
<Route path="info" element={<AdminInfoPage />} />
<Route path="*" element={<Navigate to="/admin" replace />} /> <Route path="*" element={<Navigate to="/admin" replace />} />
</Routes> </Routes>
</Box> </Box>
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react' import { Fragment, useMemo, useState } from 'react'
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
@@ -19,7 +19,6 @@ import TableRow from '@mui/material/TableRow'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Controller, useForm } from 'react-hook-form'
import { import {
fetchAdminOrder, fetchAdminOrder,
fetchAdminOrders, fetchAdminOrders,
@@ -27,15 +26,14 @@ import {
setAdminOrderStatus, setAdminOrderStatus,
} from '@/entities/order/api/admin-order-api' } from '@/entities/order/api/admin-order-api'
import { ORDER_STATUSES, getAdminNextOrderStatuses } from '@/shared/constants/order' 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 { 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' import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
type TokenFormState = { token: string }
export function AdminOrdersPage() { export function AdminOrdersPage() {
const qc = useQueryClient() const qc = useQueryClient()
const [token, setTokenState] = useState<string | null>(() => getAdminToken())
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [status, setStatus] = useState('') const [status, setStatus] = useState('')
const [deliveryType, setDeliveryType] = useState<'delivery' | 'pickup' | ''>('') const [deliveryType, setDeliveryType] = useState<'delivery' | 'pickup' | ''>('')
@@ -43,54 +41,38 @@ export function AdminOrdersPage() {
const [selectedId, setSelectedId] = useState<string | null>(null) const [selectedId, setSelectedId] = useState<string | null>(null)
const [msg, setMsg] = useState('') 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({ const ordersQuery = useQuery({
queryKey: ['admin', 'orders', token, { q, status, deliveryType }], queryKey: ['admin', 'orders', { q, status, deliveryType }],
queryFn: () => queryFn: () =>
fetchAdminOrders(token!, { fetchAdminOrders({
q: q.trim() || undefined, q: q.trim() || undefined,
status: status || undefined, status: status || undefined,
deliveryType: deliveryType || undefined, deliveryType: deliveryType || undefined,
}), }),
enabled: Boolean(token),
}) })
const orderDetailQuery = useQuery({ const orderDetailQuery = useQuery({
queryKey: ['admin', 'orders', 'detail', token, selectedId], queryKey: ['admin', 'orders', 'detail', selectedId],
queryFn: () => fetchAdminOrder(token!, selectedId!), queryFn: () => fetchAdminOrder(selectedId!),
enabled: Boolean(token && selectedId), enabled: Boolean(selectedId),
}) })
const statusMut = useMutation({ const statusMut = useMutation({
mutationFn: (next: string) => setAdminOrderStatus(token!, selectedId!, next), mutationFn: (next: string) => setAdminOrderStatus(selectedId!, next),
onSuccess: async () => { onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ['admin', 'orders'] }) await invalidateQueryKeys(qc, [
await qc.invalidateQueries({ queryKey: ['admin', 'orders', 'detail'] }) ['admin', 'orders'],
await qc.invalidateQueries({ queryKey: ['admin', 'orders', 'summary'] }) ['admin', 'orders', 'detail'],
['admin', 'orders', 'summary'],
])
}, },
}) })
const msgMut = useMutation({ const msgMut = useMutation({
mutationFn: () => postAdminOrderMessage(token!, selectedId!, msg.trim()), mutationFn: () => postAdminOrderMessage(selectedId!, msg.trim()),
onSuccess: async () => { onSuccess: async () => {
setMsg('') setMsg('')
await qc.invalidateQueries({ queryKey: ['admin', 'orders', 'detail'] }) await invalidateQueryKeys(qc, [['admin', 'orders', 'detail']])
}, },
}) })
@@ -99,7 +81,15 @@ export function AdminOrdersPage() {
setDialogOpen(true) 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 const detail = orderDetailQuery.data?.item
@@ -115,97 +105,74 @@ export function AdminOrdersPage() {
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Введите API-токен из <code>ADMIN_API_TOKEN</code> (сохраняется в sessionStorage). Управление заказами доступно пользователю с правами администратора.
</Typography> </Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2 }}> <Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2 }}>
<Controller <TextField size="small" label="Поиск (id/email)" value={q} onChange={(e) => setQ(e.target.value)} fullWidth />
control={tokenForm.control} <FormControl size="small" sx={{ minWidth: 220 }}>
name="token" <InputLabel id="status-label">Статус</InputLabel>
render={({ field }) => ( <Select
<TextField labelId="status-label"
label="Токен (Bearer)" label="Статус"
type="password" value={status}
fullWidth onChange={(e) => setStatus(String(e.target.value))}
{...field} >
placeholder={token ? '••••••••' : ''} <MenuItem value="">
/> <em>Все</em>
)} </MenuItem>
/> {ORDER_STATUSES.map((s) => (
<Button variant="contained" onClick={saveToken} sx={{ minWidth: 140 }}> <MenuItem key={s} value={s}>
Сохранить {orderStatusLabelRu(s)}
</Button> </MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 220 }}>
<InputLabel id="delivery-type-label">Способ получения</InputLabel>
<Select
labelId="delivery-type-label"
label="Способ получения"
value={deliveryType}
onChange={(e) => {
const v = String(e.target.value)
if (v === '' || v === 'delivery' || v === 'pickup') setDeliveryType(v)
}}
>
<MenuItem value="">
<em>Все</em>
</MenuItem>
<MenuItem value="delivery">Доставка</MenuItem>
<MenuItem value="pickup">Самовывоз</MenuItem>
</Select>
</FormControl>
</Stack> </Stack>
{!token && <Alert severity="info">После сохранения токена появится список заказов.</Alert>} {ordersQuery.isError && <Alert severity="error">Не удалось загрузить заказы.</Alert>}
{token && ( <Table size="small">
<> <TableHead>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2 }}> <TableRow>
<TextField <TableCell>ID</TableCell>
size="small" <TableCell>Покупатель</TableCell>
label="Поиск (id/email)" <TableCell>Создан</TableCell>
value={q} <TableCell>Сумма</TableCell>
onChange={(e) => setQ(e.target.value)} <TableCell>Позиций</TableCell>
fullWidth <TableCell align="right">Действия</TableCell>
/> </TableRow>
<FormControl size="small" sx={{ minWidth: 220 }}> </TableHead>
<InputLabel id="status-label">Статус</InputLabel> <TableBody>
<Select {groupedItems.map((group) => (
labelId="status-label" <Fragment key={`group:${group.statusCode}`}>
label="Статус"
value={status}
onChange={(e) => setStatus(String(e.target.value))}
>
<MenuItem value="">
<em>Все</em>
</MenuItem>
{ORDER_STATUSES.map((s) => (
<MenuItem key={s} value={s}>
{orderStatusLabelRu(s)}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 220 }}>
<InputLabel id="delivery-type-label">Способ получения</InputLabel>
<Select
labelId="delivery-type-label"
label="Способ получения"
value={deliveryType}
onChange={(e) => {
const v = String(e.target.value)
if (v === '' || v === 'delivery' || v === 'pickup') setDeliveryType(v)
}}
>
<MenuItem value="">
<em>Все</em>
</MenuItem>
<MenuItem value="delivery">Доставка</MenuItem>
<MenuItem value="pickup">Самовывоз</MenuItem>
</Select>
</FormControl>
</Stack>
{ordersQuery.isError && <Alert severity="error">Не удалось загрузить заказы.</Alert>}
<Table size="small">
<TableHead>
<TableRow> <TableRow>
<TableCell>ID</TableCell> <TableCell colSpan={6} sx={{ fontWeight: 700, bgcolor: 'action.hover' }}>
<TableCell>Покупатель</TableCell> {orderStatusLabelRu(group.statusCode)} ({group.items.length})
<TableCell>Статус</TableCell> </TableCell>
<TableCell>Сумма</TableCell>
<TableCell>Позиций</TableCell>
<TableCell align="right">Действия</TableCell>
</TableRow> </TableRow>
</TableHead> {group.items.map((o) => (
<TableBody>
{items.map((o) => (
<TableRow key={o.id} hover> <TableRow key={o.id} hover>
<TableCell>{o.id.slice(-8)}</TableCell> <TableCell>{o.id.slice(-8)}</TableCell>
<TableCell>{o.user.email}</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>{formatPriceRub(o.totalCents)}</TableCell>
<TableCell>{o.itemsCount}</TableCell> <TableCell>{o.itemsCount}</TableCell>
<TableCell align="right"> <TableCell align="right">
@@ -215,17 +182,17 @@ export function AdminOrdersPage() {
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
{ordersQuery.isSuccess && items.length === 0 && ( </Fragment>
<TableRow> ))}
<TableCell colSpan={6} sx={{ color: 'text.secondary' }}> {ordersQuery.isSuccess && items.length === 0 && (
Заказов пока нет. <TableRow>
</TableCell> <TableCell colSpan={6} sx={{ color: 'text.secondary' }}>
</TableRow> Заказов пока нет.
)} </TableCell>
</TableBody> </TableRow>
</Table> )}
</> </TableBody>
)} </Table>
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} fullWidth maxWidth="md"> <Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} fullWidth maxWidth="md">
<DialogTitle>Заказ</DialogTitle> <DialogTitle>Заказ</DialogTitle>
@@ -282,14 +249,9 @@ export function AdminOrdersPage() {
</Stack> </Stack>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}> <Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
<TextField <Box sx={{ flexGrow: 1, width: '100%' }}>
label="Ответ админа" <RichTextMessageEditor value={msg} onChange={setMsg} placeholder="Ответ админа" />
value={msg} </Box>
onChange={(e) => setMsg(e.target.value)}
fullWidth
multiline
minRows={2}
/>
<Button <Button
variant="contained" variant="contained"
onClick={() => msgMut.mutate()} onClick={() => msgMut.mutate()}
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useState } from 'react'
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
@@ -12,42 +12,22 @@ import TableRow from '@mui/material/TableRow'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' 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 { fetchAdminReviews, moderateReview } from '@/entities/review/api/admin-review-api'
import { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token' import { getErrorMessage } from '@/shared/lib/get-error-message'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
type TokenFormState = { token: string }
export function AdminReviewsPage() { export function AdminReviewsPage() {
const qc = useQueryClient() const qc = useQueryClient()
const [token, setTokenState] = useState<string | null>(() => getAdminToken()) const [status] = useState('pending')
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 reviewsQuery = useQuery({ const reviewsQuery = useQuery({
queryKey: ['admin', 'reviews', token], queryKey: ['admin', 'reviews', status],
queryFn: () => fetchAdminReviews(token!, { status: 'pending', page: 1, pageSize: 50 }), queryFn: () => fetchAdminReviews({ status, page: 1, pageSize: 50 }),
enabled: Boolean(token),
}) })
const modMut = useMutation({ const modMut = useMutation({
mutationFn: (params: { id: string; action: 'approve' | 'reject' }) => mutationFn: (params: { id: string; action: 'approve' | 'reject' }) => moderateReview(params.id, params.action),
moderateReview(token!, params.id, params.action), onSuccess: () => void invalidateQueryKeys(qc, [['admin', 'reviews']]),
onSuccess: () => void qc.invalidateQueries({ queryKey: ['admin', 'reviews'] }),
}) })
const error = modMut.error const error = modMut.error
@@ -61,84 +41,62 @@ export function AdminReviewsPage() {
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Введите API-токен из <code>ADMIN_API_TOKEN</code>. Модерация отзывов доступна пользователю с правами администратора.
</Typography> </Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2 }}> <Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2 }}>
<Controller <TextField size="small" label="Статус" value="pending" disabled />
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> </Stack>
{!token && <Alert severity="info">После сохранения токена появится список отзывов на модерации.</Alert>} {reviewsQuery.isError && <Alert severity="error">Не удалось загрузить отзывы.</Alert>}
{error && <Alert severity="error">{getErrorMessage(error)}</Alert>}
{token && ( <Table size="small">
<> <TableHead>
{reviewsQuery.isError && <Alert severity="error">Не удалось загрузить отзывы.</Alert>} <TableRow>
{error && <Alert severity="error">{(error as Error).message}</Alert>} <TableCell>Товар</TableCell>
<TableCell>Пользователь</TableCell>
<Table size="small"> <TableCell>Оценка</TableCell>
<TableHead> <TableCell>Текст</TableCell>
<TableRow> <TableCell align="right">Действия</TableCell>
<TableCell>Товар</TableCell> </TableRow>
<TableCell>Пользователь</TableCell> </TableHead>
<TableCell>Оценка</TableCell> <TableBody>
<TableCell>Текст</TableCell> {items.map((r) => (
<TableCell align="right">Действия</TableCell> <TableRow key={r.id} hover>
</TableRow> <TableCell>{r.product.title}</TableCell>
</TableHead> <TableCell>{r.user.email}</TableCell>
<TableBody> <TableCell>
{items.map((r) => ( <Chip label={String(r.rating)} size="small" />
<TableRow key={r.id} hover> </TableCell>
<TableCell>{r.product.title}</TableCell> <TableCell>{r.text ?? '—'}</TableCell>
<TableCell>{r.user.email}</TableCell> <TableCell align="right">
<TableCell> <Button
<Chip label={String(r.rating)} size="small" /> size="small"
</TableCell> onClick={() => modMut.mutate({ id: r.id, action: 'approve' })}
<TableCell>{r.text ?? '—'}</TableCell> disabled={modMut.isPending}
<TableCell align="right"> >
<Button Одобрить
size="small" </Button>
onClick={() => modMut.mutate({ id: r.id, action: 'approve' })} <Button
disabled={modMut.isPending} size="small"
> color="error"
Одобрить onClick={() => modMut.mutate({ id: r.id, action: 'reject' })}
</Button> disabled={modMut.isPending}
<Button >
size="small" Отклонить
color="error" </Button>
onClick={() => modMut.mutate({ id: r.id, action: 'reject' })} </TableCell>
disabled={modMut.isPending} </TableRow>
> ))}
Отклонить {reviewsQuery.isSuccess && items.length === 0 && (
</Button> <TableRow>
</TableCell> <TableCell colSpan={5} sx={{ color: 'text.secondary' }}>
</TableRow> Нет отзывов на модерации.
))} </TableCell>
{reviewsQuery.isSuccess && items.length === 0 && ( </TableRow>
<TableRow> )}
<TableCell colSpan={5} sx={{ color: 'text.secondary' }}> </TableBody>
Нет отзывов на модерации. </Table>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</>
)}
</Box> </Box>
) )
} }
@@ -20,17 +20,17 @@ import { Controller, useForm } from 'react-hook-form'
import { Link as RouterLink } from 'react-router-dom' import { Link as RouterLink } from 'react-router-dom'
import { createAdminUser, deleteAdminUser, fetchAdminUsers, updateAdminUser } from '@/entities/user/api/user-api' import { createAdminUser, deleteAdminUser, fetchAdminUsers, updateAdminUser } from '@/entities/user/api/user-api'
import type { AdminUser } from '@/entities/user/model/types' import type { AdminUser } from '@/entities/user/model/types'
import { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token' import { getErrorMessage } from '@/shared/lib/get-error-message'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
type TokenFormState = { token: string } import { useEditDialogState } from '@/shared/lib/use-edit-dialog-state'
import { EntityRowActions } from '@/shared/ui/EntityRowActions'
type UserFormState = { type UserFormState = {
email: string email: string
name: string name: string
password: string
} }
const emptyUserForm = (): UserFormState => ({ email: '', name: '', password: '' }) const emptyUserForm = (): UserFormState => ({ email: '', name: '' })
function formatDt(v: string) { function formatDt(v: string) {
try { try {
@@ -44,28 +44,17 @@ function formatDt(v: string) {
export function AdminUsersPage() { export function AdminUsersPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [token, setTokenState] = useState<string | null>(() => getAdminToken()) const { dialogOpen, editing, openCreateDialog, openEditDialog, closeDialog } = useEditDialogState<AdminUser>()
const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState<AdminUser | null>(null)
const [qInput, setQInput] = useState('') const [qInput, setQInput] = useState('')
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [page, setPage] = useState(0) const [page, setPage] = useState(0)
const [rowsPerPage, setRowsPerPage] = useState(20) const [rowsPerPage, setRowsPerPage] = useState(20)
const tokenForm = useForm<TokenFormState>({
defaultValues: { token: '' },
mode: 'onChange',
})
const userForm = useForm<UserFormState>({ const userForm = useForm<UserFormState>({
defaultValues: emptyUserForm(), defaultValues: emptyUserForm(),
mode: 'onChange', mode: 'onChange',
}) })
useEffect(() => {
tokenForm.reset({ token: '' })
}, [token, tokenForm])
useEffect(() => { useEffect(() => {
const t = window.setTimeout(() => { const t = window.setTimeout(() => {
setQ(qInput.trim()) setQ(qInput.trim())
@@ -74,81 +63,64 @@ export function AdminUsersPage() {
return () => window.clearTimeout(t) return () => window.clearTimeout(t)
}, [qInput]) }, [qInput])
const saveToken = () => {
const t = tokenForm.getValues('token').trim()
if (!t) {
clearAdminToken()
setTokenState(null)
return
}
setAdminToken(t)
setTokenState(t)
}
const usersQuery = useQuery({ const usersQuery = useQuery({
queryKey: ['admin', 'users', token, { q, page, rowsPerPage }], queryKey: ['admin', 'users', { q, page, rowsPerPage }],
queryFn: () => queryFn: () =>
fetchAdminUsers(token!, { fetchAdminUsers({
q: q || undefined, q: q || undefined,
page: page + 1, page: page + 1,
pageSize: rowsPerPage, pageSize: rowsPerPage,
}), }),
enabled: Boolean(token),
}) })
const createMut = useMutation({ const createMut = useMutation({
mutationFn: async () => { mutationFn: async () => {
const v = userForm.getValues() const v = userForm.getValues()
await createAdminUser(token!, { await createAdminUser({
email: v.email.trim(), email: v.email.trim(),
name: v.name.trim() || null, name: v.name.trim() || null,
password: v.password.trim() || undefined,
}) })
}, },
onSuccess: () => { onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) void invalidateQueryKeys(queryClient, [['admin', 'users']])
setDialogOpen(false) closeDialog()
}, },
}) })
const updateMut = useMutation({ const updateMut = useMutation({
mutationFn: async () => { mutationFn: async () => {
const v = userForm.getValues() const v = userForm.getValues()
await updateAdminUser(token!, editing!.id, { await updateAdminUser(editing!.id, {
email: v.email.trim(), email: v.email.trim(),
name: v.name.trim() || null, name: v.name.trim() || null,
...(v.password.trim() ? { password: v.password.trim() } : {}),
}) })
}, },
onSuccess: () => { onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) void invalidateQueryKeys(queryClient, [['admin', 'users']])
setDialogOpen(false) closeDialog()
}, },
}) })
const deleteMut = useMutation({ const deleteMut = useMutation({
mutationFn: async (id: string) => deleteAdminUser(token!, id), mutationFn: async (id: string) => deleteAdminUser(id),
onSuccess: () => { onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) void invalidateQueryKeys(queryClient, [['admin', 'users']])
}, },
}) })
const mutationError = createMut.error ?? updateMut.error ?? deleteMut.error const mutationError = createMut.error ?? updateMut.error ?? deleteMut.error
const openCreate = () => { const openCreate = () => {
setEditing(null)
userForm.reset(emptyUserForm()) userForm.reset(emptyUserForm())
setDialogOpen(true) openCreateDialog()
} }
const openEdit = (u: AdminUser) => { const openEdit = (u: AdminUser) => {
setEditing(u) openEditDialog(u)
userForm.reset({ userForm.reset({
email: u.email, email: u.email,
name: u.name ?? '', name: u.name ?? '',
password: '',
}) })
setDialogOpen(true)
} }
const emailValue = userForm.watch('email') const emailValue = userForm.watch('email')
@@ -171,124 +143,84 @@ export function AdminUsersPage() {
</Stack> </Stack>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> <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> </Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 3 }}> <Button variant="contained" onClick={openCreate}>
<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> </Button>
<TextField
size="small"
label="Поиск (email/имя)"
value={qInput}
onChange={(e) => setQInput(e.target.value)}
fullWidth
/>
</Stack> </Stack>
{!token && <Alert severity="info">После сохранения токена здесь появится список пользователей.</Alert>} {usersQuery.isError && (
<Alert severity="error" sx={{ mb: 2 }}>
{token && ( Ошибка загрузки. Проверьте, что сервер запущен, и у вас есть права администратора.
<> </Alert>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}>
<Button variant="contained" onClick={openCreate}>
Новый пользователь
</Button>
<TextField
size="small"
label="Поиск (email/имя)"
value={qInput}
onChange={(e) => setQInput(e.target.value)}
fullWidth
/>
</Stack>
{usersQuery.isError && (
<Alert severity="error" sx={{ mb: 2 }}>
Ошибка загрузки. Проверьте токен и что сервер запущен.
</Alert>
)}
{mutationError && (
<Alert severity="error" sx={{ mb: 2 }}>
{(mutationError as Error).message}
</Alert>
)}
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Почта</TableCell>
<TableCell>Имя</TableCell>
<TableCell>Пароль</TableCell>
<TableCell>Создан</TableCell>
<TableCell>Обновлён</TableCell>
<TableCell align="right">Действия</TableCell>
</TableRow>
</TableHead>
<TableBody>
{users.map((u) => (
<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>
</TableCell>
</TableRow>
))}
{users.length === 0 && !usersQuery.isLoading && (
<TableRow>
<TableCell colSpan={6} sx={{ color: 'text.secondary' }}>
Пользователей пока нет.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<TablePagination
component="div"
count={total}
page={page}
onPageChange={(_, p) => setPage(p)}
rowsPerPage={rowsPerPage}
onRowsPerPageChange={(e) => {
setRowsPerPage(Number(e.target.value))
setPage(0)
}}
rowsPerPageOptions={[10, 20, 50, 100]}
/>
</>
)} )}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} fullWidth maxWidth="xs"> {mutationError && (
<Alert severity="error" sx={{ mb: 2 }}>
{getErrorMessage(mutationError)}
</Alert>
)}
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Почта</TableCell>
<TableCell>Имя</TableCell>
<TableCell>Создан</TableCell>
<TableCell>Обновлён</TableCell>
<TableCell align="right">Действия</TableCell>
</TableRow>
</TableHead>
<TableBody>
{users.map((u) => (
<TableRow key={u.id} hover>
<TableCell>{u.email}</TableCell>
<TableCell>{u.name ?? '—'}</TableCell>
<TableCell>{formatDt(u.createdAt)}</TableCell>
<TableCell>{formatDt(u.updatedAt)}</TableCell>
<TableCell align="right">
<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={5} sx={{ color: 'text.secondary' }}>
Пользователей пока нет.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<TablePagination
component="div"
count={total}
page={page}
onPageChange={(_, p) => setPage(p)}
rowsPerPage={rowsPerPage}
onRowsPerPageChange={(e) => {
setRowsPerPage(Number(e.target.value))
setPage(0)
}}
rowsPerPageOptions={[10, 20, 50, 100]}
/>
<Dialog open={dialogOpen} onClose={closeDialog} fullWidth maxWidth="xs">
<DialogTitle>{editing ? 'Редактировать пользователя' : 'Новый пользователь'}</DialogTitle> <DialogTitle>{editing ? 'Редактировать пользователя' : 'Новый пользователь'}</DialogTitle>
<DialogContent> <DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}> <Stack spacing={2} sx={{ mt: 1 }}>
@@ -302,23 +234,10 @@ export function AdminUsersPage() {
name="name" name="name"
render={({ field }) => <TextField label="Имя/ник" fullWidth {...field} />} render={({ field }) => <TextField label="Имя/ник" fullWidth {...field} />}
/> />
<Controller
control={userForm.control}
name="password"
render={({ field }) => (
<TextField
label={editing ? 'Новый пароль (необязательно)' : 'Пароль (необязательно)'}
type="password"
fullWidth
helperText="Минимум 8 символов. Для редактирования можно оставить пустым."
{...field}
/>
)}
/>
</Stack> </Stack>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setDialogOpen(false)}>Отмена</Button> <Button onClick={closeDialog}>Отмена</Button>
<Button <Button
variant="contained" variant="contained"
onClick={() => (editing ? updateMut.mutate() : createMut.mutate())} onClick={() => (editing ? updateMut.mutate() : createMut.mutate())}
+65 -132
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react' import { useState } from 'react'
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
@@ -32,8 +32,11 @@ import {
} from '@/entities/product/api/product-api' } from '@/entities/product/api/product-api'
import type { Product } from '@/entities/product/model/types' import type { Product } from '@/entities/product/model/types'
import { apiClient } from '@/shared/api/client' import { apiClient } from '@/shared/api/client'
import { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token'
import { formatPriceRub } from '@/shared/lib/format-price' 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 = { type FormState = {
title: string title: string
@@ -67,16 +70,9 @@ const emptyForm = (): FormState => ({
export function AdminPage() { export function AdminPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [token, setToken] = useState<string | null>(() => getAdminToken()) const { dialogOpen, editing, openCreateDialog, openEditDialog, closeDialog } = useEditDialogState<Product>()
const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState<Product | null>(null)
const [catOpen, setCatOpen] = useState(false) const [catOpen, setCatOpen] = useState(false)
const tokenForm = useForm<{ token: string }>({
defaultValues: { token: '' },
mode: 'onChange',
})
const productForm = useForm<FormState>({ const productForm = useForm<FormState>({
defaultValues: emptyForm(), defaultValues: emptyForm(),
mode: 'onChange', mode: 'onChange',
@@ -91,40 +87,23 @@ export function AdminPage() {
const categoryIdValue = productForm.watch('categoryId') const categoryIdValue = productForm.watch('categoryId')
const inStockValue = productForm.watch('inStock') const inStockValue = productForm.watch('inStock')
useEffect(() => {
tokenForm.reset({ token: '' })
}, [token, tokenForm])
const categoriesQuery = useQuery({ const categoriesQuery = useQuery({
queryKey: ['categories'], queryKey: ['categories'],
queryFn: () => fetchCategories(), queryFn: () => fetchCategories(),
}) })
const productsQuery = useQuery({ const productsQuery = useQuery({
queryKey: ['admin', 'products', token], queryKey: ['admin', 'products'],
queryFn: () => fetchAdminProducts(token!), queryFn: fetchAdminProducts,
enabled: Boolean(token),
}) })
const saveToken = () => {
const t = tokenForm.getValues('token').trim()
if (!t) {
clearAdminToken()
setToken(null)
return
}
setAdminToken(t)
setToken(t)
}
const openCreate = () => { const openCreate = () => {
setEditing(null)
productForm.reset(emptyForm()) productForm.reset(emptyForm())
setDialogOpen(true) openCreateDialog()
} }
const openEdit = (p: Product) => { const openEdit = (p: Product) => {
setEditing(p) openEditDialog(p)
const urls = const urls =
(p.images ?? []) (p.images ?? [])
.slice() .slice()
@@ -144,7 +123,6 @@ export function AdminPage() {
leadTimeDays: p.leadTimeDays ? String(p.leadTimeDays) : '', leadTimeDays: p.leadTimeDays ? String(p.leadTimeDays) : '',
categoryId: p.categoryId, categoryId: p.categoryId,
}) })
setDialogOpen(true)
} }
const createMut = useMutation({ const createMut = useMutation({
@@ -164,7 +142,7 @@ export function AdminPage() {
.split(',') .split(',')
.map((x) => x.trim()) .map((x) => x.trim())
.filter(Boolean) .filter(Boolean)
await createProduct(token!, { await createProduct({
title: form.title.trim(), title: form.title.trim(),
slug: form.slug.trim() || undefined, slug: form.slug.trim() || undefined,
shortDescription: form.shortDescription.trim() || null, shortDescription: form.shortDescription.trim() || null,
@@ -180,9 +158,8 @@ export function AdminPage() {
}) })
}, },
onSuccess: () => { onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'products'] }) void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']])
void queryClient.invalidateQueries({ queryKey: ['products'] }) closeDialog()
setDialogOpen(false)
}, },
}) })
@@ -203,7 +180,7 @@ export function AdminPage() {
.split(',') .split(',')
.map((x) => x.trim()) .map((x) => x.trim())
.filter(Boolean) .filter(Boolean)
await updateProduct(token!, editing!.id, { await updateProduct(editing!.id, {
title: form.title.trim(), title: form.title.trim(),
slug: form.slug.trim(), slug: form.slug.trim(),
shortDescription: form.shortDescription.trim() || null, shortDescription: form.shortDescription.trim() || null,
@@ -219,30 +196,28 @@ export function AdminPage() {
}) })
}, },
onSuccess: () => { onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'products'] }) void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']])
void queryClient.invalidateQueries({ queryKey: ['products'] }) closeDialog()
setDialogOpen(false)
}, },
}) })
const deleteMut = useMutation({ const deleteMut = useMutation({
mutationFn: (id: string) => deleteProduct(token!, id), mutationFn: (id: string) => deleteProduct(id),
onSuccess: () => { onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'products'] }) void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']])
void queryClient.invalidateQueries({ queryKey: ['products'] })
}, },
}) })
const createCategoryMut = useMutation({ const createCategoryMut = useMutation({
mutationFn: () => { mutationFn: () => {
const v = categoryForm.getValues() const v = categoryForm.getValues()
return createCategory(token!, { return createCategory({
name: v.name.trim(), name: v.name.trim(),
slug: v.slug.trim() || undefined, slug: v.slug.trim() || undefined,
}) })
}, },
onSuccess: () => { onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['categories'] }) void invalidateQueryKeys(queryClient, [['categories']])
setCatOpen(false) setCatOpen(false)
categoryForm.reset({ name: '', slug: '' }) categoryForm.reset({ name: '', slug: '' })
}, },
@@ -260,10 +235,7 @@ export function AdminPage() {
const fd = new FormData() const fd = new FormData()
Array.from(files).forEach((f) => fd.append('files', f)) Array.from(files).forEach((f) => fd.append('files', f))
const { data } = await apiClient.post<{ urls: string[] }>('admin/uploads', fd, { const { data } = await apiClient.post<{ urls: string[] }>('admin/uploads', fd, {
headers: { headers: { 'Content-Type': 'multipart/form-data' },
Authorization: `Bearer ${token}`,
'Content-Type': 'multipart/form-data',
},
}) })
return data.urls return data.urls
}, },
@@ -288,92 +260,55 @@ export function AdminPage() {
Админка Админка
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> <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> </Typography>
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 3 }}> <Button variant="contained" onClick={openCreate}>
<Controller Новый товар
control={tokenForm.control} </Button>
name="token" <Button variant="outlined" onClick={() => setCatOpen(true)}>
render={({ field }) => ( Новая категория
<TextField
label="Токен (Bearer)"
type="password"
fullWidth
{...field}
placeholder={token ? '••••••••' : ''}
/>
)}
/>
<Button variant="contained" onClick={saveToken} sx={{ minWidth: 140 }}>
Сохранить
</Button> </Button>
</Stack> </Stack>
{!token && ( {productsQuery.isError && (
<Alert severity="info">После сохранения токена здесь появится список товаров и формы управления.</Alert> <Alert severity="error" sx={{ mb: 2 }}>
Ошибка загрузки. Проверьте, что сервер запущен, и у вас есть права администратора.
</Alert>
)} )}
{token && ( {mutationError && (
<> <Alert severity="error" sx={{ mb: 2 }}>
<Stack direction="row" spacing={2} sx={{ mb: 2 }}> {getErrorMessage(mutationError)}
<Button variant="contained" onClick={openCreate}> </Alert>
Новый товар
</Button>
<Button variant="outlined" onClick={() => setCatOpen(true)}>
Новая категория
</Button>
</Stack>
{productsQuery.isError && (
<Alert severity="error" sx={{ mb: 2 }}>
Ошибка загрузки. Проверьте токен и что сервер запущен.
</Alert>
)}
{mutationError && (
<Alert severity="error" sx={{ mb: 2 }}>
{(mutationError as Error).message}
</Alert>
)}
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Название</TableCell>
<TableCell>Категория</TableCell>
<TableCell>Цена</TableCell>
<TableCell>Витрина</TableCell>
<TableCell align="right">Действия</TableCell>
</TableRow>
</TableHead>
<TableBody>
{(productsQuery.data ?? []).map((p) => (
<TableRow key={p.id} hover>
<TableCell>{p.title}</TableCell>
<TableCell>{p.category?.name ?? '—'}</TableCell>
<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>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
)} )}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} fullWidth maxWidth="sm"> <Table size="small">
<TableHead>
<TableRow>
<TableCell>Название</TableCell>
<TableCell>Категория</TableCell>
<TableCell>Цена</TableCell>
<TableCell>Витрина</TableCell>
<TableCell align="right">Действия</TableCell>
</TableRow>
</TableHead>
<TableBody>
{(productsQuery.data ?? []).map((p) => (
<TableRow key={p.id} hover>
<TableCell>{p.title}</TableCell>
<TableCell>{p.category?.name ?? '—'}</TableCell>
<TableCell>{formatPriceRub(p.priceCents)}</TableCell>
<TableCell>{p.published ? 'да' : 'нет'}</TableCell>
<TableCell align="right">
<EntityRowActions onEdit={() => openEdit(p)} onDelete={() => deleteMut.mutate(p.id)} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Dialog open={dialogOpen} onClose={closeDialog} fullWidth maxWidth="sm">
<DialogTitle>{editing ? 'Редактировать товар' : 'Новый товар'}</DialogTitle> <DialogTitle>{editing ? 'Редактировать товар' : 'Новый товар'}</DialogTitle>
<DialogContent> <DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}> <Stack spacing={2} sx={{ mt: 1 }}>
@@ -448,7 +383,7 @@ export function AdminPage() {
flexDirection: { xs: 'column', sm: 'row' }, flexDirection: { xs: 'column', sm: 'row' },
}} }}
> >
<Button component="label" variant="outlined" disabled={uploadImagesMut.isPending || !token}> <Button component="label" variant="outlined" disabled={uploadImagesMut.isPending}>
Выбрать файлы Выбрать файлы
<input <input
hidden hidden
@@ -464,9 +399,7 @@ export function AdminPage() {
/> />
</Button> </Button>
{uploadImagesMut.isPending && <Typography color="text.secondary">Загрузка</Typography>} {uploadImagesMut.isPending && <Typography color="text.secondary">Загрузка</Typography>}
{uploadImagesMut.isError && ( {uploadImagesMut.isError && <Typography color="error">Не удалось загрузить фото</Typography>}
<Typography color="error">Не удалось загрузить фото (проверьте токен и сервер)</Typography>
)}
</Box> </Box>
{productForm.watch('imageUrls').length > 0 && ( {productForm.watch('imageUrls').length > 0 && (
@@ -559,7 +492,7 @@ export function AdminPage() {
</Stack> </Stack>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setDialogOpen(false)}>Отмена</Button> <Button onClick={closeDialog}>Отмена</Button>
<Button <Button
variant="contained" variant="contained"
onClick={handleSubmit} onClick={handleSubmit}
+2 -52
View File
@@ -34,16 +34,13 @@ export function AuthPage() {
const { register, watch } = useForm<{ const { register, watch } = useForm<{
email: string email: string
code: string code: string
password: string
}>({ }>({
defaultValues: { email: '', code: '', password: '' }, defaultValues: { email: '', code: '' },
mode: 'onChange', mode: 'onChange',
}) })
const email = watch('email') const email = watch('email')
const code = watch('code') const code = watch('code')
const password = watch('password')
useEffect(() => { useEffect(() => {
if (user) navigate('/', { replace: true }) if (user) navigate('/', { replace: true })
}, [navigate, user]) }, [navigate, user])
@@ -71,27 +68,7 @@ export function AuthPage() {
}, },
}) })
const registerPassword = useMutation({ const errMsg = getApiErrorMessage(requestCode.error || verifyCode.error)
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,
)
return ( return (
<Box> <Box>
@@ -144,33 +121,6 @@ export function AuthPage() {
Войти Войти
</Button> </Button>
</Stack> </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> </Stack>
</Box> </Box>
) )
+1
View File
@@ -0,0 +1 @@
export { InfoPage } from './ui/InfoPage'
+42
View File
@@ -0,0 +1,42 @@
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Paper from '@mui/material/Paper'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { useQuery } from '@tanstack/react-query'
import { fetchPublicInfoBlocks } from '@/entities/info/api/info-page-api'
export function InfoPage() {
const q = useQuery({
queryKey: ['info-page', 'public', 'blocks'],
queryFn: fetchPublicInfoBlocks,
})
return (
<Box>
<Typography variant="h4" gutterBottom>
Информация для покупателей
</Typography>
<Typography color="text.secondary" sx={{ mb: 2 }}>
Как оформить заказ, как проходит доставка, оплата и другие важные детали.
</Typography>
{q.isLoading && <Typography color="text.secondary">Загрузка</Typography>}
{q.isError && <Alert severity="error">Не удалось загрузить информацию.</Alert>}
{q.isSuccess && q.data.items.length === 0 && <Alert severity="info">Раздел пока не заполнен.</Alert>}
{q.isSuccess && q.data.items.length > 0 && (
<Stack spacing={2}>
{q.data.items.map((block) => (
<Paper key={block.id} variant="outlined" sx={{ p: 2, borderRadius: 2 }}>
<Typography variant="h6" sx={{ mb: 0.75 }}>
{block.title}
</Typography>
<Typography sx={{ whiteSpace: 'pre-wrap' }}>{block.body}</Typography>
</Paper>
))}
</Stack>
)}
</Box>
)
}
-43
View File
@@ -8,12 +8,10 @@ import Typography from '@mui/material/Typography'
import { useUnit } from 'effector-react' import { useUnit } from 'effector-react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { import {
$changePasswordError,
$requestEmailChangeCodeError, $requestEmailChangeCodeError,
$updateProfileError, $updateProfileError,
$user, $user,
$verifyEmailChangeError, $verifyEmailChangeError,
changePasswordFx,
requestEmailChangeCodeFx, requestEmailChangeCodeFx,
updateProfileFx, updateProfileFx,
verifyEmailChangeFx, verifyEmailChangeFx,
@@ -28,20 +26,13 @@ function getApiErrorMessage(error: unknown): string | null {
export function MePage() { export function MePage() {
const user = useUnit($user) const user = useUnit($user)
const pendingPassword = useUnit(changePasswordFx.pending)
const pendingEmailReq = useUnit(requestEmailChangeCodeFx.pending) const pendingEmailReq = useUnit(requestEmailChangeCodeFx.pending)
const pendingEmailVerify = useUnit(verifyEmailChangeFx.pending) const pendingEmailVerify = useUnit(verifyEmailChangeFx.pending)
const pendingProfile = useUnit(updateProfileFx.pending) const pendingProfile = useUnit(updateProfileFx.pending)
const errorPassword = useUnit($changePasswordError)
const errorEmailReq = useUnit($requestEmailChangeCodeError) const errorEmailReq = useUnit($requestEmailChangeCodeError)
const errorProfile = useUnit($updateProfileError) const errorProfile = useUnit($updateProfileError)
const errorEmailVerify = useUnit($verifyEmailChangeError) const errorEmailVerify = useUnit($verifyEmailChangeError)
const passwordForm = useForm<{ currentPassword: string; newPassword: string }>({
defaultValues: { currentPassword: '', newPassword: '' },
mode: 'onChange',
})
const emailForm = useForm<{ newEmail: string; code: string }>({ const emailForm = useForm<{ newEmail: string; code: string }>({
defaultValues: { newEmail: '', code: '' }, defaultValues: { newEmail: '', code: '' },
mode: 'onChange', mode: 'onChange',
@@ -52,7 +43,6 @@ export function MePage() {
mode: 'onChange', mode: 'onChange',
}) })
const passwordErrorMsg = getApiErrorMessage(errorPassword)
const emailErrorMsg = getApiErrorMessage(errorEmailReq) ?? getApiErrorMessage(errorEmailVerify) const emailErrorMsg = getApiErrorMessage(errorEmailReq) ?? getApiErrorMessage(errorEmailVerify)
const profileErrorMsg = getApiErrorMessage(errorProfile) const profileErrorMsg = getApiErrorMessage(errorProfile)
@@ -69,11 +59,6 @@ export function MePage() {
Текущая почта: <b>{user.email}</b> Текущая почта: <b>{user.email}</b>
</Typography> </Typography>
{passwordErrorMsg && (
<Alert severity="error" sx={{ mb: 2 }}>
{passwordErrorMsg}
</Alert>
)}
{emailErrorMsg && ( {emailErrorMsg && (
<Alert severity="error" sx={{ mb: 2 }}> <Alert severity="error" sx={{ mb: 2 }}>
{emailErrorMsg} {emailErrorMsg}
@@ -143,34 +128,6 @@ export function MePage() {
</Stack> </Stack>
</Stack> </Stack>
</Box> </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> </Stack>
</Box> </Box>
) )
@@ -8,13 +8,13 @@ import ListItem from '@mui/material/ListItem'
import ListItemButton from '@mui/material/ListItemButton' import ListItemButton from '@mui/material/ListItemButton'
import ListItemText from '@mui/material/ListItemText' import ListItemText from '@mui/material/ListItemText'
import Stack from '@mui/material/Stack' import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link as RouterLink } from 'react-router-dom' import { Link as RouterLink } from 'react-router-dom'
import { fetchMyOrder, postOrderMessage } from '@/entities/order/api/order-api' import { fetchMyOrder, postOrderMessage } from '@/entities/order/api/order-api'
import { fetchMyConversations, markOrderMessagesRead } from '@/entities/user/api/messages-api' import { fetchMyConversations, markOrderMessagesRead } from '@/entities/user/api/messages-api'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels' import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
export function MessagesPage() { export function MessagesPage() {
const qc = useQueryClient() const qc = useQueryClient()
@@ -177,14 +177,9 @@ export function MessagesPage() {
{order.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>} {order.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
</Stack> </Stack>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}> <Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
<TextField <Box sx={{ flexGrow: 1, width: '100%' }}>
label="Сообщение" <RichTextMessageEditor value={text} onChange={setText} placeholder="Сообщение" />
value={text} </Box>
onChange={(e) => setText(e.target.value)}
fullWidth
multiline
minRows={2}
/>
<Button <Button
variant="contained" variant="contained"
sx={{ minWidth: 140 }} sx={{ minWidth: 140 }}
@@ -9,7 +9,6 @@ import DialogTitle from '@mui/material/DialogTitle'
import Divider from '@mui/material/Divider' import Divider from '@mui/material/Divider'
import Rating from '@mui/material/Rating' import Rating from '@mui/material/Rating'
import Stack from '@mui/material/Stack' import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import axios from 'axios' import axios from 'axios'
@@ -21,10 +20,11 @@ import {
payOrderStub, payOrderStub,
postOrderMessage, postOrderMessage,
} from '@/entities/order/api/order-api' } 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 { markOrderMessagesRead } from '@/entities/user/api/messages-api'
import { formatPriceRub } from '@/shared/lib/format-price' import { formatPriceRub } from '@/shared/lib/format-price'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels' import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
function reviewSubmitErrorMessage(err: unknown): string { function reviewSubmitErrorMessage(err: unknown): string {
if (axios.isAxiosError(err)) { if (axios.isAxiosError(err)) {
@@ -59,6 +59,7 @@ export function OrderDetailPage() {
const [reviewTarget, setReviewTarget] = useState<{ productId: string; title: string } | null>(null) const [reviewTarget, setReviewTarget] = useState<{ productId: string; title: string } | null>(null)
const [reviewRating, setReviewRating] = useState<number>(5) const [reviewRating, setReviewRating] = useState<number>(5)
const [reviewText, setReviewText] = useState('') const [reviewText, setReviewText] = useState('')
const [reviewImageUrl, setReviewImageUrl] = useState<string | null>(null)
const orderQuery = useQuery({ const orderQuery = useQuery({
queryKey: ['me', 'orders', id], queryKey: ['me', 'orders', id],
@@ -108,16 +109,23 @@ export function OrderDetailPage() {
await postProductReview(reviewTarget.productId, { await postProductReview(reviewTarget.productId, {
rating: reviewRating, rating: reviewRating,
text: t.length ? t : null, text: t.length ? t : null,
imageUrl: reviewImageUrl,
}) })
}, },
onSuccess: async () => { onSuccess: async () => {
setReviewTarget(null) setReviewTarget(null)
setReviewRating(5) setReviewRating(5)
setReviewText('') setReviewText('')
setReviewImageUrl(null)
await qc.invalidateQueries({ queryKey: ['me', 'orders', id, 'review-eligibility'] }) await qc.invalidateQueries({ queryKey: ['me', 'orders', id, 'review-eligibility'] })
}, },
}) })
const uploadReviewImageMut = useMutation({
mutationFn: (file: File) => uploadReviewImage(file),
onSuccess: ({ url }) => setReviewImageUrl(url),
})
useEffect(() => { useEffect(() => {
if (!id || orderQuery.status !== 'success' || !order) return if (!id || orderQuery.status !== 'success' || !order) return
void (async () => { void (async () => {
@@ -326,14 +334,9 @@ export function OrderDetailPage() {
</Stack> </Stack>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}> <Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
<TextField <Box sx={{ flexGrow: 1, width: '100%' }}>
label="Сообщение" <RichTextMessageEditor value={text} onChange={setText} placeholder="Сообщение" />
value={text} </Box>
onChange={(e) => setText(e.target.value)}
fullWidth
multiline
minRows={2}
/>
<Button <Button
variant="contained" variant="contained"
onClick={() => msgMut.mutate()} onClick={() => msgMut.mutate()}
@@ -348,7 +351,13 @@ export function OrderDetailPage() {
<Dialog <Dialog
open={Boolean(reviewTarget)} open={Boolean(reviewTarget)}
onClose={() => !reviewMut.isPending && setReviewTarget(null)} onClose={() => {
if (reviewMut.isPending) return
setReviewTarget(null)
setReviewRating(5)
setReviewText('')
setReviewImageUrl(null)
}}
fullWidth fullWidth
maxWidth="sm" maxWidth="sm"
> >
@@ -363,15 +372,60 @@ export function OrderDetailPage() {
if (v !== null) setReviewRating(v) if (v !== null) setReviewRating(v)
}} }}
/> />
<TextField <Box sx={{ mt: 2 }}>
sx={{ mt: 2 }} <RichTextMessageEditor
label="Комментарий (необязательно)" value={reviewText}
value={reviewText} onChange={setReviewText}
onChange={(e) => setReviewText(e.target.value)} placeholder="Комментарий (необязательно)"
fullWidth />
multiline </Box>
minRows={3} <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 && ( {reviewMut.isError && (
<Alert severity="error" sx={{ mt: 2 }}> <Alert severity="error" sx={{ mt: 2 }}>
{reviewSubmitErrorMessage(reviewMut.error)} {reviewSubmitErrorMessage(reviewMut.error)}
@@ -379,7 +433,15 @@ export function OrderDetailPage() {
)} )}
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setReviewTarget(null)} disabled={reviewMut.isPending}> <Button
onClick={() => {
setReviewTarget(null)
setReviewRating(5)
setReviewText('')
setReviewImageUrl(null)
}}
disabled={reviewMut.isPending}
>
Отмена Отмена
</Button> </Button>
<Button variant="contained" disabled={reviewMut.isPending} onClick={() => reviewMut.mutate()}> <Button variant="contained" disabled={reviewMut.isPending} onClick={() => reviewMut.mutate()}>
+41 -26
View File
@@ -1,12 +1,16 @@
import { useMemo } from 'react'
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Divider from '@mui/material/Divider'
import Stack from '@mui/material/Stack' import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Link as RouterLink } from 'react-router-dom' import { Link as RouterLink } from 'react-router-dom'
import { fetchMyOrders } from '@/entities/order/api/order-api' import { fetchMyOrders } from '@/entities/order/api/order-api'
import { ORDER_STATUSES } from '@/shared/constants/order'
import { formatPriceRub } from '@/shared/lib/format-price' import { formatPriceRub } from '@/shared/lib/format-price'
import { groupOrdersByStatus } from '@/shared/lib/group-orders-by-status'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels' import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
export function OrdersPage() { export function OrdersPage() {
@@ -15,7 +19,8 @@ export function OrdersPage() {
queryFn: fetchMyOrders, 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 ( return (
<Box> <Box>
@@ -29,33 +34,43 @@ export function OrdersPage() {
<Alert severity="info">Заказов пока нет. Оформите заказ из корзины.</Alert> <Alert severity="info">Заказов пока нет. Оформите заказ из корзины.</Alert>
)} )}
<Stack spacing={2}> <Stack spacing={3}>
{items.map((o) => ( {groups.map((group) => (
<Box <Box key={group.status}>
key={o.id} <Typography variant="h6" sx={{ mb: 1 }}>
sx={{ {orderStatusLabelRu(group.status)} ({group.items.length})
border: 1, </Typography>
borderColor: 'divider', <Stack spacing={2}>
borderRadius: 2, {group.items.map((o) => (
p: 2, <Box
bgcolor: 'background.paper', key={o.id}
}} sx={{
> border: 1,
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ alignItems: { sm: 'center' } }}> borderColor: 'divider',
<Box sx={{ flexGrow: 1 }}> borderRadius: 2,
<Typography sx={{ fontWeight: 700 }}>Заказ #{o.id.slice(-6)}</Typography> p: 2,
<Typography color="text.secondary" variant="body2"> bgcolor: 'background.paper',
Статус: {orderStatusLabelRu(o.status)} · {o.itemsCount} поз. }}
</Typography> >
</Box> <Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ alignItems: { sm: 'center' } }}>
<Typography sx={{ fontWeight: 700 }}>{formatPriceRub(o.totalCents)}</Typography> <Box sx={{ flexGrow: 1 }}>
</Stack> <Typography sx={{ fontWeight: 700 }}>Заказ #{o.id.slice(-6)}</Typography>
<Typography color="text.secondary" variant="body2">
{new Date(o.createdAt).toLocaleString('ru-RU')} · {o.itemsCount} поз.
</Typography>
</Box>
<Typography sx={{ fontWeight: 700 }}>{formatPriceRub(o.totalCents)}</Typography>
</Stack>
<Stack direction="row" spacing={1} sx={{ mt: 1.5 }}> <Stack direction="row" spacing={1} sx={{ mt: 1.5 }}>
<Button component={RouterLink} to={`/me/orders/${o.id}`} size="small" variant="outlined"> <Button component={RouterLink} to={`/me/orders/${o.id}`} size="small" variant="outlined">
Открыть Открыть
</Button> </Button>
</Stack>
</Box>
))}
</Stack> </Stack>
<Divider sx={{ mt: 2 }} />
</Box> </Box>
))} ))}
</Stack> </Stack>
@@ -8,12 +8,10 @@ import Typography from '@mui/material/Typography'
import { useUnit } from 'effector-react' import { useUnit } from 'effector-react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { import {
$changePasswordError,
$requestEmailChangeCodeError, $requestEmailChangeCodeError,
$updateProfileError, $updateProfileError,
$user, $user,
$verifyEmailChangeError, $verifyEmailChangeError,
changePasswordFx,
requestEmailChangeCodeFx, requestEmailChangeCodeFx,
updateProfileFx, updateProfileFx,
verifyEmailChangeFx, verifyEmailChangeFx,
@@ -28,20 +26,13 @@ function getApiErrorMessage(error: unknown): string | null {
export function SettingsPage() { export function SettingsPage() {
const user = useUnit($user) const user = useUnit($user)
const pendingPassword = useUnit(changePasswordFx.pending)
const pendingEmailReq = useUnit(requestEmailChangeCodeFx.pending) const pendingEmailReq = useUnit(requestEmailChangeCodeFx.pending)
const pendingEmailVerify = useUnit(verifyEmailChangeFx.pending) const pendingEmailVerify = useUnit(verifyEmailChangeFx.pending)
const pendingProfile = useUnit(updateProfileFx.pending) const pendingProfile = useUnit(updateProfileFx.pending)
const errorPassword = useUnit($changePasswordError)
const errorEmailReq = useUnit($requestEmailChangeCodeError) const errorEmailReq = useUnit($requestEmailChangeCodeError)
const errorProfile = useUnit($updateProfileError) const errorProfile = useUnit($updateProfileError)
const errorEmailVerify = useUnit($verifyEmailChangeError) const errorEmailVerify = useUnit($verifyEmailChangeError)
const passwordForm = useForm<{ currentPassword: string; newPassword: string }>({
defaultValues: { currentPassword: '', newPassword: '' },
mode: 'onChange',
})
const emailForm = useForm<{ newEmail: string; code: string }>({ const emailForm = useForm<{ newEmail: string; code: string }>({
defaultValues: { newEmail: '', code: '' }, defaultValues: { newEmail: '', code: '' },
mode: 'onChange', mode: 'onChange',
@@ -52,7 +43,6 @@ export function SettingsPage() {
mode: 'onChange', mode: 'onChange',
}) })
const passwordErrorMsg = getApiErrorMessage(errorPassword)
const emailErrorMsg = getApiErrorMessage(errorEmailReq) ?? getApiErrorMessage(errorEmailVerify) const emailErrorMsg = getApiErrorMessage(errorEmailReq) ?? getApiErrorMessage(errorEmailVerify)
const profileErrorMsg = getApiErrorMessage(errorProfile) const profileErrorMsg = getApiErrorMessage(errorProfile)
@@ -69,11 +59,6 @@ export function SettingsPage() {
Текущая почта: <b>{user.email}</b> Текущая почта: <b>{user.email}</b>
</Typography> </Typography>
{passwordErrorMsg && (
<Alert severity="error" sx={{ mb: 2 }}>
{passwordErrorMsg}
</Alert>
)}
{emailErrorMsg && ( {emailErrorMsg && (
<Alert severity="error" sx={{ mb: 2 }}> <Alert severity="error" sx={{ mb: 2 }}>
{emailErrorMsg} {emailErrorMsg}
@@ -150,34 +135,6 @@ export function SettingsPage() {
</Stack> </Stack>
</Stack> </Stack>
</Box> </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> </Stack>
</Box> </Box>
) )
@@ -218,6 +218,21 @@ export function ProductPage() {
Без текстового комментария. Без текстового комментария.
</Typography> </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> </Stack>
</Paper> </Paper>
) )
-26
View File
@@ -1,26 +0,0 @@
const KEY = 'craftshop_admin_token'
const TOKEN_EVENT = 'craftshop_admin_token_change'
export function getAdminToken(): string | null {
return sessionStorage.getItem(KEY)
}
function notifyTokenListeners(): void {
window.dispatchEvent(new Event(TOKEN_EVENT))
}
/** Подписаться на смену токена (в т. ч. после setAdminToken). */
export function subscribeAdminTokenChange(cb: () => void): () => void {
window.addEventListener(TOKEN_EVENT, cb)
return () => window.removeEventListener(TOKEN_EVENT, cb)
}
export function setAdminToken(token: string): void {
sessionStorage.setItem(KEY, token)
notifyTokenListeners()
}
export function clearAdminToken(): void {
sessionStorage.removeItem(KEY)
notifyTokenListeners()
}
@@ -0,0 +1,4 @@
export function getErrorMessage(error: unknown, fallback = 'Произошла ошибка'): string {
if (error instanceof Error && error.message) return error.message
return fallback
}
@@ -0,0 +1,24 @@
type OrderLike = {
status: string
createdAt: string
}
export function groupOrdersByStatus<T extends OrderLike>(items: T[], statuses: readonly string[]) {
const byStatus = new Map<string, T[]>()
for (const status of statuses) byStatus.set(status, [])
for (const item of items) {
const list = byStatus.get(item.status) ?? []
list.push(item)
byStatus.set(item.status, list)
}
return statuses
.map((status) => ({
status,
items: (byStatus.get(status) ?? []).sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
),
}))
.filter((group) => group.items.length > 0)
}
@@ -0,0 +1,5 @@
import type { QueryClient, QueryKey } from '@tanstack/react-query'
export async function invalidateQueryKeys(queryClient: QueryClient, keys: QueryKey[]): Promise<void> {
await Promise.all(keys.map((queryKey) => queryClient.invalidateQueries({ queryKey })))
}
@@ -0,0 +1,28 @@
import { useCallback, useState } from 'react'
export function useEditDialogState<T>() {
const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState<T | null>(null)
const openCreateDialog = useCallback(() => {
setEditing(null)
setDialogOpen(true)
}, [])
const openEditDialog = useCallback((item: T) => {
setEditing(item)
setDialogOpen(true)
}, [])
const closeDialog = useCallback(() => {
setDialogOpen(false)
}, [])
return {
dialogOpen,
editing,
openCreateDialog,
openEditDialog,
closeDialog,
}
}
+2 -11
View File
@@ -1,7 +1,7 @@
import { createEffect, createEvent, createStore, sample } from 'effector' import { createEffect, createEvent, createStore, sample } from 'effector'
import { apiClient } from '@/shared/api/client' 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' 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 $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) => { export const requestEmailChangeCodeFx = createEffect(async (newEmail: string) => {
await apiClient.post('me/change-email/request-code', { newEmail }) await apiClient.post('me/change-email/request-code', { newEmail })
}) })
@@ -35,10 +30,6 @@ export const updateProfileFx = createEffect(async (params: UpdateProfileParams)
return data.user 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) export const $requestEmailChangeCodeError = createStore<unknown | null>(null)
.on(requestEmailChangeCodeFx.failData, (_, e) => e) .on(requestEmailChangeCodeFx.failData, (_, e) => e)
.reset(requestEmailChangeCodeFx, logout) .reset(requestEmailChangeCodeFx, logout)
@@ -70,7 +61,7 @@ sample({
}) })
sample({ sample({
clock: [changePasswordFx.doneData, verifyEmailChangeFx.doneData, updateProfileFx.doneData], clock: [verifyEmailChangeFx.doneData, updateProfileFx.doneData],
target: $user, target: $user,
}) })
+43
View File
@@ -0,0 +1,43 @@
import Button from '@mui/material/Button'
import Stack from '@mui/material/Stack'
type EntityRowActionsProps = {
onEdit?: () => void
onDelete?: () => void
deleteDisabled?: boolean
confirmDeleteMessage?: string
editLabel?: string
deleteLabel?: string
}
export function EntityRowActions({
onEdit,
onDelete,
deleteDisabled = false,
confirmDeleteMessage,
editLabel = 'Изменить',
deleteLabel = 'Удалить',
}: EntityRowActionsProps) {
return (
<Stack direction="row" spacing={0.5} sx={{ justifyContent: 'flex-end' }}>
{onEdit && (
<Button size="small" onClick={onEdit}>
{editLabel}
</Button>
)}
{onDelete && (
<Button
size="small"
color="error"
disabled={deleteDisabled}
onClick={() => {
if (confirmDeleteMessage && !confirm(confirmDeleteMessage)) return
onDelete()
}}
>
{deleteLabel}
</Button>
)}
</Stack>
)
}
@@ -0,0 +1,101 @@
import { useEffect } from 'react'
import FormatBoldOutlinedIcon from '@mui/icons-material/FormatBoldOutlined'
import FormatItalicOutlinedIcon from '@mui/icons-material/FormatItalicOutlined'
import FormatListBulletedOutlinedIcon from '@mui/icons-material/FormatListBulletedOutlined'
import Box from '@mui/material/Box'
import IconButton from '@mui/material/IconButton'
import Stack from '@mui/material/Stack'
import Placeholder from '@tiptap/extension-placeholder'
import { EditorContent, useEditor } from '@tiptap/react'
import TiptapStarterKit from '@tiptap/starter-kit'
type RichTextMessageEditorProps = {
value: string
onChange: (next: string) => void
placeholder?: string
disabled?: boolean
}
export function RichTextMessageEditor({
value,
onChange,
placeholder = 'Введите сообщение',
disabled = false,
}: RichTextMessageEditorProps) {
const editor = useEditor({
extensions: [
TiptapStarterKit.configure({ heading: false, codeBlock: false, blockquote: false, horizontalRule: false }),
Placeholder.configure({ placeholder }),
],
content: value,
editable: !disabled,
onUpdate: ({ editor: tiptap }) => onChange(tiptap.getText()),
})
useEffect(() => {
if (!editor) return
editor.setEditable(!disabled)
}, [disabled, editor])
useEffect(() => {
if (!editor) return
if (editor.getText() === value) return
editor.commands.setContent(value, false)
}, [editor, value])
return (
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, overflow: 'hidden', bgcolor: 'background.paper' }}>
<Stack direction="row" spacing={0.5} sx={{ p: 0.75, borderBottom: 1, borderColor: 'divider' }}>
<IconButton
size="small"
onClick={() => editor?.chain().focus().toggleBold().run()}
color={editor?.isActive('bold') ? 'primary' : 'default'}
disabled={disabled}
aria-label="Жирный"
>
<FormatBoldOutlinedIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => editor?.chain().focus().toggleItalic().run()}
color={editor?.isActive('italic') ? 'primary' : 'default'}
disabled={disabled}
aria-label="Курсив"
>
<FormatItalicOutlinedIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => editor?.chain().focus().toggleBulletList().run()}
color={editor?.isActive('bulletList') ? 'primary' : 'default'}
disabled={disabled}
aria-label="Список"
>
<FormatListBulletedOutlinedIcon fontSize="small" />
</IconButton>
</Stack>
<Box
sx={{
px: 1.5,
py: 1.25,
'& .ProseMirror': {
minHeight: 72,
outline: 'none',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
},
'& .ProseMirror p.is-editor-empty:first-of-type::before': {
content: `"${placeholder}"`,
color: 'text.disabled',
pointerEvents: 'none',
float: 'left',
height: 0,
},
}}
>
<EditorContent editor={editor} />
</Box>
</Box>
)
}
@@ -109,6 +109,22 @@ export function ReviewsBlock() {
{text} {text}
</Typography> </Typography>
</Stack> </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> </Paper>
) )
})} })}
+1 -1
View File
@@ -1,6 +1,6 @@
DATABASE_URL="file:./dev.db" DATABASE_URL="file:./dev.db"
PORT=3333 PORT=3333
ADMIN_API_TOKEN=замените-на-секрет ADMIN_EMAIL=admin@example.com
JWT_SECRET=замените-на-секрет-jwt JWT_SECRET=замените-на-секрет-jwt
# Разрешённый Origin фронта (через запятую при нескольких) # Разрешённый Origin фронта (через запятую при нескольких)
-10
View File
@@ -13,7 +13,6 @@
"@fastify/multipart": "^10.0.0", "@fastify/multipart": "^10.0.0",
"@fastify/static": "^9.1.3", "@fastify/static": "^9.1.3",
"@prisma/client": "5.22.0", "@prisma/client": "5.22.0",
"bcryptjs": "^3.0.3",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"fastify": "^5.8.5", "fastify": "^5.8.5",
"nodemailer": "^8.0.7" "nodemailer": "^8.0.7"
@@ -456,15 +455,6 @@
"node": "18 || 20 || >=22" "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": { "node_modules/bn.js": {
"version": "4.12.3", "version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
-1
View File
@@ -19,7 +19,6 @@
"@fastify/multipart": "^10.0.0", "@fastify/multipart": "^10.0.0",
"@fastify/static": "^9.1.3", "@fastify/static": "^9.1.3",
"@prisma/client": "5.22.0", "@prisma/client": "5.22.0",
"bcryptjs": "^3.0.3",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"fastify": "^5.8.5", "fastify": "^5.8.5",
"nodemailer": "^8.0.7" "nodemailer": "^8.0.7"
@@ -0,0 +1,20 @@
-- AlterTable
ALTER TABLE "Review" ADD COLUMN "imageUrl" TEXT;
-- CreateTable
CREATE TABLE "InfoPageBlock" (
"id" TEXT NOT NULL PRIMARY KEY,
"key" TEXT NOT NULL,
"title" TEXT NOT NULL,
"body" TEXT NOT NULL,
"sort" INTEGER NOT NULL DEFAULT 0,
"published" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "InfoPageBlock_key_key" ON "InfoPageBlock"("key");
-- CreateIndex
CREATE INDEX "InfoPageBlock_published_sort_idx" ON "InfoPageBlock"("published", "sort");
+14
View File
@@ -161,6 +161,7 @@ model Review {
id String @id @default(cuid()) id String @id @default(cuid())
rating Int rating Int
text String? text String?
imageUrl String?
/// 'pending' | 'approved' | 'rejected' /// 'pending' | 'approved' | 'rejected'
status String @default("pending") status String @default("pending")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -229,3 +230,16 @@ model AuthCode {
@@index([email, purpose]) @@index([email, purpose])
@@index([expiresAt]) @@index([expiresAt])
} }
model InfoPageBlock {
id String @id @default(cuid())
key String @unique
title String
body String
sort Int @default(0)
published Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([published, sort])
}
+2
View File
@@ -5,6 +5,7 @@ import jwt from '@fastify/jwt'
import multipart from '@fastify/multipart' import multipart from '@fastify/multipart'
import fastifyStatic from '@fastify/static' import fastifyStatic from '@fastify/static'
import path from 'node:path' import path from 'node:path'
import { ensureAdminUser } from './lib/bootstrap-admin.js'
import { registerAuth } from './plugins/auth.js' import { registerAuth } from './plugins/auth.js'
import { registerApiRoutes } from './routes/api.js' import { registerApiRoutes } from './routes/api.js'
import { registerAuthRoutes } from './routes/auth.js' import { registerAuthRoutes } from './routes/auth.js'
@@ -52,6 +53,7 @@ registerAuth(fastify)
await registerAuthRoutes(fastify) await registerAuthRoutes(fastify)
await registerOAuthSocialRoutes(fastify) await registerOAuthSocialRoutes(fastify)
await registerApiRoutes(fastify) await registerApiRoutes(fastify)
await ensureAdminUser()
fastify.get('/health', async () => ({ ok: true })) fastify.get('/health', async () => ({ ok: true }))
-8
View File
@@ -1,5 +1,4 @@
import crypto from 'node:crypto' import crypto from 'node:crypto'
import bcrypt from 'bcryptjs'
import { prisma } from './prisma.js' import { prisma } from './prisma.js'
import { sendLoginCodeEmail } from './email.js' import { sendLoginCodeEmail } from './email.js'
@@ -54,11 +53,4 @@ export async function verifyEmailCode({ email, purpose, code, userId = null }) {
return true return true
} }
export async function hashPassword(password) {
return bcrypt.hash(password, 10)
}
export async function verifyPassword(password, passwordHash) {
return bcrypt.compare(password, passwordHash)
}
+16
View File
@@ -0,0 +1,16 @@
import { normalizeEmail } from './auth.js'
import { prisma } from './prisma.js'
export async function ensureAdminUser() {
const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
if (!adminEmail) return
if (!adminEmail.includes('@')) {
throw new Error('ADMIN_EMAIL должен быть валидным email')
}
await prisma.user.upsert({
where: { email: adminEmail },
update: {},
create: { email: adminEmail },
})
}
+44
View File
@@ -0,0 +1,44 @@
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
export function safeImageExt(filename) {
const ext = path.extname(String(filename || '')).toLowerCase()
const allowed = new Set(['.png', '.jpg', '.jpeg', '.webp'])
return allowed.has(ext) ? ext : null
}
export function uploadError(message, statusCode = 400) {
const err = new Error(message)
err.statusCode = statusCode
return err
}
export async function persistMultipartImages(request, { maxFiles = 10 } = {}) {
if (!request.isMultipart()) {
throw uploadError('Ожидается multipart/form-data')
}
const uploadsDir = path.join(process.cwd(), 'uploads')
await fs.promises.mkdir(uploadsDir, { recursive: true })
const urls = []
const parts = request.parts()
for await (const part of parts) {
if (part.type !== 'file') continue
if (urls.length >= maxFiles) {
throw uploadError(`Можно загрузить не более ${maxFiles} файл(ов)`)
}
const ext = safeImageExt(part.filename)
if (!ext) {
throw uploadError('Разрешены только файлы: png, jpg, jpeg, webp')
}
const fileName = `${crypto.randomUUID()}${ext}`
const fullPath = path.join(uploadsDir, fileName)
await fs.promises.writeFile(fullPath, await part.toBuffer())
urls.push(`/uploads/${fileName}`)
}
return urls
}
+17 -10
View File
@@ -1,16 +1,23 @@
/**
* Простая защита админ-роутов: заголовок Authorization: Bearer <ADMIN_API_TOKEN>
*/
export function registerAuth(fastify) { export function registerAuth(fastify) {
function normalizeEmail(email) {
return String(email || '').trim().toLowerCase()
}
fastify.decorate('verifyAdmin', async function verifyAdmin(request, reply) { fastify.decorate('verifyAdmin', async function verifyAdmin(request, reply) {
const token = process.env.ADMIN_API_TOKEN const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
if (!token) { if (!adminEmail || !adminEmail.includes('@')) {
return reply.code(503).send({ error: 'ADMIN_API_TOKEN не задан в .env' }) return reply.code(503).send({ error: 'ADMIN_EMAIL не задан в .env' })
} }
const auth = request.headers.authorization
const match = typeof auth === 'string' ? auth.match(/^Bearer\s+(.+)$/i) : null try {
if (!match?.[1] || match[1] !== token) { await request.jwtVerify()
return reply.code(401).send({ error: 'Неверный или отсутствующий токен' }) } catch {
return reply.code(401).send({ error: 'Не авторизован' })
}
const userEmail = normalizeEmail(request.user?.email)
if (userEmail !== adminEmail) {
return reply.code(403).send({ error: 'Недостаточно прав' })
} }
}) })
} }
+2 -2
View File
@@ -1,7 +1,6 @@
import { import {
mapProductForApi, mapProductForApi,
parseMaterialsInput, parseMaterialsInput,
safeExtFromFilename,
slugify, slugify,
} from './api/_product-helpers.js' } from './api/_product-helpers.js'
import { registerAdminCategoryRoutes } from './api/admin-categories.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 { registerAdminProductRoutes } from './api/admin-products.js'
import { registerAdminReviewRoutes } from './api/admin-reviews.js' import { registerAdminReviewRoutes } from './api/admin-reviews.js'
import { registerAdminUserRoutes } from './api/admin-users.js' import { registerAdminUserRoutes } from './api/admin-users.js'
import { registerInfoPageRoutes } from './api/info-page.js'
import { registerPublicCatalogRoutes } from './api/public-catalog.js' import { registerPublicCatalogRoutes } from './api/public-catalog.js'
import { registerPublicReviewRoutes } from './api/public-reviews.js' import { registerPublicReviewRoutes } from './api/public-reviews.js'
export async function registerApiRoutes(fastify) { export async function registerApiRoutes(fastify) {
await registerPublicCatalogRoutes(fastify, { mapProductForApi }) await registerPublicCatalogRoutes(fastify, { mapProductForApi })
await registerPublicReviewRoutes(fastify) await registerPublicReviewRoutes(fastify)
await registerInfoPageRoutes(fastify)
await registerAdminProductRoutes(fastify, { await registerAdminProductRoutes(fastify, {
slugify, slugify,
safeExtFromFilename,
parseMaterialsInput, parseMaterialsInput,
mapProductForApi, mapProductForApi,
}) })
+12 -29
View File
@@ -1,11 +1,9 @@
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import { prisma } from '../../lib/prisma.js' import { prisma } from '../../lib/prisma.js'
import { persistMultipartImages } from '../../lib/upload-images.js'
export async function registerAdminProductRoutes( export async function registerAdminProductRoutes(
fastify, fastify,
{ slugify, safeExtFromFilename, parseMaterialsInput, mapProductForApi } = {}, { slugify, parseMaterialsInput, mapProductForApi } = {},
) { ) {
fastify.get( fastify.get(
'/api/admin/products', '/api/admin/products',
@@ -23,32 +21,17 @@ export async function registerAdminProductRoutes(
'/api/admin/uploads', '/api/admin/uploads',
{ preHandler: [fastify.verifyAdmin] }, { preHandler: [fastify.verifyAdmin] },
async (request, reply) => { async (request, reply) => {
if (!request.isMultipart()) { try {
reply.code(400).send({ error: 'Ожидается multipart/form-data' }) const urls = await persistMultipartImages(request, { maxFiles: 10 })
return return { urls }
} catch (error) {
const message = error instanceof Error ? error.message : 'Не удалось загрузить файлы'
const statusCode =
error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode)
? Number(error.statusCode)
: 400
return reply.code(statusCode).send({ error: message })
} }
const uploadsDir = path.join(process.cwd(), 'uploads')
await fs.promises.mkdir(uploadsDir, { recursive: true })
const urls = []
const parts = request.parts()
for await (const part of parts) {
if (part.type !== 'file') continue
const ext = safeExtFromFilename(part.filename)
if (!ext) {
reply.code(400).send({ error: 'Разрешены только файлы: png, jpg, jpeg, webp' })
return
}
const id = crypto.randomUUID()
const fileName = `${id}${ext}`
const fullPath = path.join(uploadsDir, fileName)
await fs.promises.writeFile(fullPath, await part.toBuffer())
urls.push(`/uploads/${fileName}`)
}
return { urls }
}, },
) )
+1 -24
View File
@@ -1,5 +1,5 @@
import { prisma } from '../../lib/prisma.js' import { prisma } from '../../lib/prisma.js'
import { hashPassword, normalizeEmail } from '../../lib/auth.js' import { normalizeEmail } from '../../lib/auth.js'
export async function registerAdminUserRoutes(fastify) { export async function registerAdminUserRoutes(fastify) {
fastify.get( fastify.get(
@@ -36,7 +36,6 @@ export async function registerAdminUserRoutes(fastify) {
id: true, id: true,
email: true, email: true,
name: true, name: true,
passwordHash: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
}, },
@@ -48,7 +47,6 @@ export async function registerAdminUserRoutes(fastify) {
id: u.id, id: u.id,
email: u.email, email: u.email,
name: u.name, name: u.name,
hasPassword: Boolean(u.passwordHash),
createdAt: u.createdAt, createdAt: u.createdAt,
updatedAt: u.updatedAt, updatedAt: u.updatedAt,
})) }))
@@ -76,24 +74,16 @@ export async function registerAdminUserRoutes(fastify) {
return 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 } }) const exists = await prisma.user.findUnique({ where: { email } })
if (exists) { if (exists) {
reply.code(409).send({ error: 'Почта уже занята' }) reply.code(409).send({ error: 'Почта уже занята' })
return return
} }
const passwordHash = password ? await hashPassword(password) : null
const user = await prisma.user.create({ const user = await prisma.user.create({
data: { data: {
email, email,
name: name && name.length ? name : null, name: name && name.length ? name : null,
passwordHash: passwordHash ?? undefined,
}, },
}) })
@@ -101,7 +91,6 @@ export async function registerAdminUserRoutes(fastify) {
id: user.id, id: user.id,
email: user.email, email: user.email,
name: user.name, name: user.name,
hasPassword: Boolean(user.passwordHash),
createdAt: user.createdAt, createdAt: user.createdAt,
updatedAt: user.updatedAt, updatedAt: user.updatedAt,
}) })
@@ -149,23 +138,11 @@ export async function registerAdminUserRoutes(fastify) {
data.name = name && name.length ? name : null 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 }) const user = await prisma.user.update({ where: { id }, data })
return { return {
id: user.id, id: user.id,
email: user.email, email: user.email,
name: user.name, name: user.name,
hasPassword: Boolean(user.passwordHash),
createdAt: user.createdAt, createdAt: user.createdAt,
updatedAt: user.updatedAt, updatedAt: user.updatedAt,
} }
+118
View File
@@ -0,0 +1,118 @@
import { prisma } from '../../lib/prisma.js'
function validateBlockPayload(body, reply) {
const key = String(body?.key || '').trim()
const title = String(body?.title || '').trim()
const content = String(body?.body || '').trim()
const sort = Number(body?.sort ?? 0)
const published = body?.published === undefined ? true : Boolean(body.published)
if (!key) return reply.code(400).send({ error: 'key обязателен' })
if (!/^[a-z0-9_-]{2,60}$/i.test(key)) {
return reply.code(400).send({ error: 'key должен быть 2-60 символов: буквы, цифры, "_" или "-"' })
}
if (!title) return reply.code(400).send({ error: 'title обязателен' })
if (title.length > 120) return reply.code(400).send({ error: 'title максимум 120 символов' })
if (!content) return reply.code(400).send({ error: 'body обязателен' })
if (content.length > 5000) return reply.code(400).send({ error: 'body максимум 5000 символов' })
if (!Number.isFinite(sort) || Math.abs(sort) > 10_000) return reply.code(400).send({ error: 'Некорректный sort' })
return { key, title, body: content, sort: Math.trunc(sort), published }
}
export async function registerInfoPageRoutes(fastify) {
fastify.get('/api/info-page/blocks', async () => {
const items = await prisma.infoPageBlock.findMany({
where: { published: true },
orderBy: [{ sort: 'asc' }, { createdAt: 'asc' }],
})
return { items }
})
fastify.get(
'/api/admin/info-page/blocks',
{ preHandler: [fastify.verifyAdmin] },
async () => {
const items = await prisma.infoPageBlock.findMany({ orderBy: [{ sort: 'asc' }, { createdAt: 'asc' }] })
return { items }
},
)
fastify.post(
'/api/admin/info-page/blocks',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const validated = validateBlockPayload(request.body, reply)
if (!validated) return
try {
const item = await prisma.infoPageBlock.create({ data: validated })
return reply.code(201).send({ item })
} catch {
return reply.code(409).send({ error: 'Блок с таким key уже существует' })
}
},
)
fastify.patch(
'/api/admin/info-page/blocks/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
const existing = await prisma.infoPageBlock.findUnique({ where: { id } })
if (!existing) return reply.code(404).send({ error: 'Блок не найден' })
const body = request.body ?? {}
const data = {}
if (body.key !== undefined) {
const key = String(body.key || '').trim()
if (!key) return reply.code(400).send({ error: 'key обязателен' })
if (!/^[a-z0-9_-]{2,60}$/i.test(key)) {
return reply.code(400).send({ error: 'key должен быть 2-60 символов: буквы, цифры, "_" или "-"' })
}
data.key = key
}
if (body.title !== undefined) {
const title = String(body.title || '').trim()
if (!title) return reply.code(400).send({ error: 'title обязателен' })
if (title.length > 120) return reply.code(400).send({ error: 'title максимум 120 символов' })
data.title = title
}
if (body.body !== undefined) {
const content = String(body.body || '').trim()
if (!content) return reply.code(400).send({ error: 'body обязателен' })
if (content.length > 5000) return reply.code(400).send({ error: 'body максимум 5000 символов' })
data.body = content
}
if (body.sort !== undefined) {
const sort = Number(body.sort)
if (!Number.isFinite(sort) || Math.abs(sort) > 10_000) return reply.code(400).send({ error: 'Некорректный sort' })
data.sort = Math.trunc(sort)
}
if (body.published !== undefined) {
data.published = Boolean(body.published)
}
try {
const item = await prisma.infoPageBlock.update({ where: { id }, data })
return { item }
} catch {
return reply.code(409).send({ error: 'Блок с таким key уже существует' })
}
},
)
fastify.delete(
'/api/admin/info-page/blocks/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
try {
await prisma.infoPageBlock.delete({ where: { id } })
return reply.code(204).send()
} catch {
return reply.code(404).send({ error: 'Блок не найден' })
}
},
)
}
+29
View File
@@ -1,7 +1,27 @@
import { publicReviewAuthorDisplay } from '../../lib/review-display.js' import { publicReviewAuthorDisplay } from '../../lib/review-display.js'
import { prisma } from '../../lib/prisma.js' import { prisma } from '../../lib/prisma.js'
import { persistMultipartImages } from '../../lib/upload-images.js'
export async function registerPublicReviewRoutes(fastify) { 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) => { fastify.get('/api/reviews/latest', async (request, reply) => {
const limitRaw = request.query?.limit const limitRaw = request.query?.limit
const limitParsed = typeof limitRaw === 'string' ? Number(limitRaw) : Number(limitRaw) const limitParsed = typeof limitRaw === 'string' ? Number(limitRaw) : Number(limitRaw)
@@ -22,6 +42,7 @@ export async function registerPublicReviewRoutes(fastify) {
id: r.id, id: r.id,
rating: r.rating, rating: r.rating,
text: r.text, text: r.text,
imageUrl: r.imageUrl,
createdAt: r.createdAt, createdAt: r.createdAt,
authorDisplay: publicReviewAuthorDisplay(r.user), authorDisplay: publicReviewAuthorDisplay(r.user),
productId: r.productId, productId: r.productId,
@@ -60,6 +81,7 @@ export async function registerPublicReviewRoutes(fastify) {
id: r.id, id: r.id,
rating: r.rating, rating: r.rating,
text: r.text, text: r.text,
imageUrl: r.imageUrl,
createdAt: r.createdAt, createdAt: r.createdAt,
authorDisplay: publicReviewAuthorDisplay(r.user), authorDisplay: publicReviewAuthorDisplay(r.user),
})) }))
@@ -84,6 +106,12 @@ export async function registerPublicReviewRoutes(fastify) {
const textRaw = request.body?.text const textRaw = request.body?.text
const text = textRaw === null || textRaw === undefined ? null : String(textRaw).trim() const text = textRaw === null || textRaw === undefined ? null : String(textRaw).trim()
if (text !== null && text.length > 1000) return reply.code(400).send({ error: 'Отзыв слишком длинный' }) 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 { try {
const created = await prisma.review.create({ const created = await prisma.review.create({
@@ -92,6 +120,7 @@ export async function registerPublicReviewRoutes(fastify) {
userId, userId,
rating: Math.floor(rating), rating: Math.floor(rating),
text: text && text.length ? text : null, text: text && text.length ? text : null,
imageUrl: imageUrl && imageUrl.length ? imageUrl : null,
status: 'pending', status: 'pending',
}, },
}) })
+17 -61
View File
@@ -1,5 +1,17 @@
import { prisma } from '../lib/prisma.js' 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) { export async function registerAuthRoutes(fastify) {
fastify.post('/api/auth/request-code', async (request, reply) => { 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 }) 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.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 } }
}) })
fastify.get( fastify.get(
@@ -68,7 +49,7 @@ export async function registerAuthRoutes(fastify) {
const userId = request.user.sub const userId = request.user.sub
const user = await prisma.user.findUnique({ where: { id: userId } }) const user = await prisma.user.findUnique({ where: { id: userId } })
if (!user) return { user: null } 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 }, where: { id: userId },
data: { email: newEmail }, data: { email: newEmail },
}) })
return { user: { id: user.id, email: user.email, name: user.name, phone: user.phone } } return { user: mapUserForClient(user) }
},
)
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 } }
}, },
) )
@@ -160,7 +116,7 @@ export async function registerAuthRoutes(fastify) {
where: { id: userId }, where: { id: userId },
data: { name: name && name.length ? name : null, phone: phone && phone.length ? phone : null }, 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) }
}, },
) )