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
+640
View File
@@ -13,6 +13,9 @@
"@mui/icons-material": "^9.0.0",
"@mui/material": "^9.0.0",
"@tanstack/react-query": "^5.100.5",
"@tiptap/extension-placeholder": "^3.22.5",
"@tiptap/react": "^3.22.5",
"@tiptap/starter-kit": "^3.22.5",
"axios": "^1.15.2",
"effector": "^23.4.4",
"effector-react": "^23.3.0",
@@ -618,6 +621,34 @@
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT",
"optional": true
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -1417,6 +1448,447 @@
"react": "^18 || ^19"
}
},
"node_modules/@tiptap/core": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.5.tgz",
"integrity": "sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "3.22.5"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.22.5.tgz",
"integrity": "sha512-ajyP5W8fG5Hrru47T/eF3xMKOpNvWofgNJqBTeNuGl02sYxsy9a4EunyFxudsaZP9WW3VOD4SaIWr5+MqpbnOQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.5"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.22.5.tgz",
"integrity": "sha512-l/uDtpJISiFFyfctvnODNWBN/XPZI1jVZRacTRDDnSn8+x6KQ7G2qgFYueU7KvVJGDFVT39Iio56mcFRG/Pozg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.5"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.22.5.tgz",
"integrity": "sha512-yrNlFQQJY5MmhBpmD8tnmaSmyUQrEvgyPKa3bzVeWEhDSG1CW4A0ZSMx3hrA9yFO0HWfw3IJmvSCycEZQBalpQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.5",
"@tiptap/pm": "3.22.5"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.22.5.tgz",
"integrity": "sha512-cf54fG9AybU8NgPMv1TOcoqAkELeRc/VpnSCt/rIJZphWQx9nsFmrtkrlCatrIcCaGtNZYwlHlMnC5LVVMu0uA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.22.5"
}
},
"node_modules/@tiptap/extension-code": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.22.5.tgz",
"integrity": "sha512-mwDNOJC9rYbDu/JcqrN4dbUQRklJU8Fuk2raxD/IvFw9qUIcPCmxQ2XT9UTKmZz/Ju7Kdy72fss6XpgWv6gLAQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.5"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.22.5.tgz",
"integrity": "sha512-d123kCfLdJTi4fue1m0+TNFztDkmIRSZGZmGu6H9KqwG5Q7IzjT9o8lzRsz+pXxYqHvqgYmXoEpM6srbzXx/Ag==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.5",
"@tiptap/pm": "3.22.5"
}
},
"node_modules/@tiptap/extension-document": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.22.5.tgz",
"integrity": "sha512-8NJERd+pCtvSuEP4C4WMGYmRRCV12ePZL7bC+QUdFlbdXg+kNZS0zZ7hh879tYA0Kidbi8rWWD1Tx+H2ezkmMw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.5"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.22.5.tgz",
"integrity": "sha512-Mp40DaFrY3sEUVtFqmxrR0BmU4G3k8GCYYNGqNa9OqWv7BrcFDC03V2n3okESDKt4MKkzhQQmypq+ouLy8dLfA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.22.5"
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.22.5.tgz",
"integrity": "sha512-dhem4sTPhyQgQ+pFp2Oud4k4FSQz9PVMgeQAC9288SmGwxBkJNveDAw6sKTMrumqDvwkJrtslXIupq9TZYQnzg==",
"license": "MIT",
"optional": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@floating-ui/dom": "^1.0.0",
"@tiptap/core": "3.22.5",
"@tiptap/pm": "3.22.5"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.22.5.tgz",
"integrity": "sha512-4WkMu7qqjbsm8hCQS+8X+la1wjriN0SKoRdvpfKH33qM50MB34tYJuGLAO+y7TTh4MMMco3AZCKPBL5JVMqNIg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.22.5"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.22.5.tgz",
"integrity": "sha512-n0R2mUVYZU2AVbJhg/WcY9+zx690wVwvsItHJf0DrYbf1tCYHx+PRHUt/AoXk6u8BSmnkb8/FDziS8m3mjfpSg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.5"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.22.5.tgz",
"integrity": "sha512-hjyEG4947PAhMBfP1G6B0QAh6+y9mp2C5BQmNjprA05/lQzDAT7KFZzNh8ZVp3ol6aICKq/N1gFOW9Dc/9FUOw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.5"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.22.5.tgz",
"integrity": "sha512-vUV0/ugIbXOc8SJib0h8UMhgcqZXWu/dkEhlswZN4VVven1o5enkfxEiDw+OyIJHi5rUkrdhsQ/KTxG/Xb7X8A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.5",
"@tiptap/pm": "3.22.5"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.22.5.tgz",
"integrity": "sha512-4T8baSiLkeIymTgEwirxDFt5YgYofkP3m1+MGYdGy2HKcOK+1vpvlPhEO1X5qtZngtJW5S4+njKjinRg52A4PA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.5"
}
},
"node_modules/@tiptap/extension-link": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.22.5.tgz",
"integrity": "sha512-d671MvF3GPKoS2OVxjIlQ7hIE7MS3hREdR+d4cvnnoiLLD+ZJ6KgDnxmWqF0a1s4qxLWK2KxKRSOIfYGE31QWQ==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.5",
"@tiptap/pm": "3.22.5"
}
},
"node_modules/@tiptap/extension-list": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.22.5.tgz",
"integrity": "sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.5",
"@tiptap/pm": "3.22.5"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.22.5.tgz",
"integrity": "sha512-W7uTmyKLhlsvuTPLv+8WwnsY+mlikBFIoLSvVcBaFt4MwpsZ+DeB6KQg02Y7tbtaAnG7rXu9Fvw2QORh2P728A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.22.5"
}
},
"node_modules/@tiptap/extension-list-keymap": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.22.5.tgz",
"integrity": "sha512-cGUnxJ0y515e1bVHNjUmbx7oWHoEon59w6BA5N2KwV9iW2mZZchlTX4yxJSOX+ixeVRChsa7YwC3Z1jUZ6AMEg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.22.5"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.22.5.tgz",
"integrity": "sha512-OXdh4k4CNrukwiSdWdEQ49uvgnqvR0Z9aNSP4HI5/kZQ/Te1NtRtYCpUrzWyO/7CtjcCisXHti0o9C/TV8YMbQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.22.5"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.22.5.tgz",
"integrity": "sha512-52KCto4+XKpnBWpIufspWLyq4UWxAWC72ANPdGuIhbi72NRTabiTbTVN40uwGSPkyakeESG0/vKdWJCVvB4f0g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.5"
}
},
"node_modules/@tiptap/extension-placeholder": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.22.5.tgz",
"integrity": "sha512-MZAohQ3FCS763BkhGXgaWRya6WruZjwRwEAkXP8vkxbERzl2OJRjniS4uXCWzAlRb3ttE103SnY7LMdM8FvsXw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.22.5"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.22.5.tgz",
"integrity": "sha512-42WrrFK5gOom/0znH85x12Mw5IQ/6O6DWdyUWoRIrNA/qJpuHtU8oVU+bIgU2tuomMGHruRjIzgBQv5sBjEtww==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.5"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.22.5.tgz",
"integrity": "sha512-bzpDOdAEo1JeoVZDIyV0oY0jGXkEG+AzF70SzHoRSjOvFDtKWunyXf9eO1OnOr2/fmMcckT2qwUBNBMQplWBzw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.5"
}
},
"node_modules/@tiptap/extension-underline": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.22.5.tgz",
"integrity": "sha512-9ut09rJD0iEbS6sk7yd2j6IwuFDLTNmDEGTDLodvqAfi+bq7ddsTDv0YviXoZaA9sdHAdTEVr2ITy2m6WK5jpA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.5"
}
},
"node_modules/@tiptap/extensions": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.22.5.tgz",
"integrity": "sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.22.5",
"@tiptap/pm": "3.22.5"
}
},
"node_modules/@tiptap/pm": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.5.tgz",
"integrity": "sha512-Cr9Mv4igxvI2tKMiahw48sZxva3PfDzypErH8IB82N+9qa9n9ygVMt0BOaDg53hLKxEEVeYr2S/wCcJIVFgBTw==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-commands": "^1.6.2",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-keymap": "^1.2.2",
"prosemirror-model": "^1.24.1",
"prosemirror-schema-list": "^1.5.0",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.4",
"prosemirror-transform": "^1.10.2",
"prosemirror-view": "^1.38.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/react": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.22.5.tgz",
"integrity": "sha512-36WHEs+vPmB//V1ff7Ujcnpz7Ey5g8lhpI/0+hoanSbdiPMTQ7qZVWwMovIkMKDlqWVp2fxBgeYM1861jyFzTw==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"fast-equals": "^5.3.3",
"use-sync-external-store": "^1.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"optionalDependencies": {
"@tiptap/extension-bubble-menu": "^3.22.5",
"@tiptap/extension-floating-menu": "^3.22.5"
},
"peerDependencies": {
"@tiptap/core": "3.22.5",
"@tiptap/pm": "3.22.5",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "3.22.5",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.22.5.tgz",
"integrity": "sha512-LZ/LYbwH6rnDi5DnRyagkuNsYAVyhM+yJvvz+ZuYA0JkPiTXJV86J5PWSKew8M0gVfMHcNVtKjfQCvViFCeIgw==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "^3.22.5",
"@tiptap/extension-blockquote": "^3.22.5",
"@tiptap/extension-bold": "^3.22.5",
"@tiptap/extension-bullet-list": "^3.22.5",
"@tiptap/extension-code": "^3.22.5",
"@tiptap/extension-code-block": "^3.22.5",
"@tiptap/extension-document": "^3.22.5",
"@tiptap/extension-dropcursor": "^3.22.5",
"@tiptap/extension-gapcursor": "^3.22.5",
"@tiptap/extension-hard-break": "^3.22.5",
"@tiptap/extension-heading": "^3.22.5",
"@tiptap/extension-horizontal-rule": "^3.22.5",
"@tiptap/extension-italic": "^3.22.5",
"@tiptap/extension-link": "^3.22.5",
"@tiptap/extension-list": "^3.22.5",
"@tiptap/extension-list-item": "^3.22.5",
"@tiptap/extension-list-keymap": "^3.22.5",
"@tiptap/extension-ordered-list": "^3.22.5",
"@tiptap/extension-paragraph": "^3.22.5",
"@tiptap/extension-strike": "^3.22.5",
"@tiptap/extension-text": "^3.22.5",
"@tiptap/extension-underline": "^3.22.5",
"@tiptap/extensions": "^3.22.5",
"@tiptap/pm": "^3.22.5"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -1515,6 +1987,12 @@
"@types/geojson": "*"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz",
@@ -3786,6 +4264,15 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -5238,6 +5725,12 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT"
},
"node_modules/linkifyjs": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
"license": "MIT"
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -5604,6 +6097,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/own-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
@@ -5851,6 +6350,135 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/prosemirror-changeset": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
"integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.4",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-transform": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.41.8",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
"integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/protocol-buffers-schema": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
@@ -6137,6 +6765,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/rw": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
@@ -7105,6 +7739,12 @@
}
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+3
View File
@@ -18,6 +18,9 @@
"@mui/icons-material": "^9.0.0",
"@mui/material": "^9.0.0",
"@tanstack/react-query": "^5.100.5",
"@tiptap/extension-placeholder": "^3.22.5",
"@tiptap/react": "^3.22.5",
"@tiptap/starter-kit": "^3.22.5",
"axios": "^1.15.2",
"effector": "^23.4.4",
"effector-react": "^23.3.0",
+2
View File
@@ -6,6 +6,7 @@ import { AuthCallbackPage, AuthPage } from '@/pages/auth'
import { CartPage } from '@/pages/cart'
import { CheckoutPage } from '@/pages/checkout'
import { HomePage } from '@/pages/home'
import { InfoPage } from '@/pages/info'
import { MeLayoutPage } from '@/pages/me'
import { ProductPage } from '@/pages/product'
@@ -21,6 +22,7 @@ export function App() {
<Route path="/auth/callback" element={<AuthCallbackPage />} />
<Route path="/cart" element={<CartPage />} />
<Route path="/checkout" element={<CheckoutPage />} />
<Route path="/info" element={<InfoPage />} />
<Route path="/me/*" element={<MeLayoutPage />} />
<Route path="/products/:id" element={<ProductPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
+54 -28
View File
@@ -2,6 +2,7 @@ import { useState } from 'react'
import AccountCircleOutlinedIcon from '@mui/icons-material/AccountCircleOutlined'
import DarkModeOutlinedIcon from '@mui/icons-material/DarkModeOutlined'
import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined'
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined'
import MenuOutlinedIcon from '@mui/icons-material/MenuOutlined'
import ShoppingCartOutlinedIcon from '@mui/icons-material/ShoppingCartOutlined'
import AppBar from '@mui/material/AppBar'
@@ -29,6 +30,7 @@ import { Link as RouterLink, useNavigate } from 'react-router-dom'
import type { ColorScheme } from '@/app/providers/theme-controller'
import { useThemeController } from '@/app/providers/theme-controller'
import { fetchMyCart } from '@/entities/cart/api/cart-api'
import { fetchMyOrders } from '@/entities/order/api/order-api'
import { STORE_NAME } from '@/shared/config'
import { $user, logout, tokenSet } from '@/shared/model/auth'
import { BearLogo } from '@/shared/ui/BearLogo'
@@ -37,7 +39,7 @@ type NavItem = { label: string; to: string }
const navItems: NavItem[] = [
{ label: 'Каталог', to: '/' },
{ label: 'Админка', to: '/admin' },
{ label: 'О покупке', to: '/info' },
]
function ThemeControlsDesktop(props: {
@@ -168,15 +170,26 @@ export function AppHeader() {
const { mode, resolvedMode, scheme, setMode, setScheme, cycleMode } = useThemeController()
const user = useUnit($user)
const navigate = useNavigate()
const isAdmin = Boolean(user?.isAdmin)
const cartQuery = useQuery({
queryKey: ['me', 'cart'],
queryFn: fetchMyCart,
enabled: Boolean(user),
enabled: Boolean(user) && !isAdmin,
})
const cartCount = cartQuery.data?.items?.length ?? 0
const ordersQuery = useQuery({
queryKey: ['me', 'orders'],
queryFn: fetchMyOrders,
enabled: Boolean(user) && !isAdmin,
})
const activeOrdersCount = (ordersQuery.data?.items ?? []).filter(
(o) => o.status !== 'DONE' && o.status !== 'CANCELLED',
).length
const [userAnchorEl, setUserAnchorEl] = useState<null | HTMLElement>(null)
const userMenuOpen = Boolean(userAnchorEl)
@@ -249,24 +262,35 @@ export function AppHeader() {
</Button>
))}
<Tooltip title={user ? 'Корзина' : 'Авторизуйтесь для совершения покупок'}>
<span>
<IconButton
color="inherit"
sx={{ ml: 1 }}
disabled={!user}
onClick={() => {
if (!user) navigate('/auth')
else navigate('/cart')
}}
aria-label="Корзина"
>
<Badge color="secondary" badgeContent={user ? cartCount : 0} invisible={!user || cartCount === 0}>
<ShoppingCartOutlinedIcon />
</Badge>
</IconButton>
</span>
</Tooltip>
{!isAdmin && (
<>
{user && (
<Tooltip title="Заказы">
<IconButton color="inherit" sx={{ ml: 1 }} onClick={() => navigate('/me/orders')} aria-label="Заказы">
<Badge color="secondary" badgeContent={activeOrdersCount} invisible={activeOrdersCount === 0}>
<LocalShippingOutlinedIcon />
</Badge>
</IconButton>
</Tooltip>
)}
<Tooltip title={user ? 'Корзина' : 'Авторизуйтесь для совершения покупок'}>
<IconButton
color="inherit"
sx={{ ml: 1 }}
onClick={() => {
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="Пользователь">
<Badge
@@ -330,14 +354,16 @@ export function AppHeader() {
{i.label}
</Button>
))}
<Button
variant="text"
onClick={() => go(user ? '/cart' : '/auth')}
sx={{ justifyContent: 'flex-start' }}
disabled={!user}
>
Корзина
</Button>
{!isAdmin && (
<Button variant="text" onClick={() => go(user ? '/cart' : '/auth')} sx={{ justifyContent: 'flex-start' }}>
Корзина
</Button>
)}
{user && !isAdmin && (
<Button variant="text" onClick={() => go('/me/orders')} sx={{ justifyContent: 'flex-start' }}>
Заказы
</Button>
)}
<Button variant="text" onClick={() => go(user ? '/me' : '/auth')} sx={{ justifyContent: 'flex-start' }}>
{user ? 'Профиль' : 'Вход / регистрация'}
</Button>
@@ -0,0 +1,41 @@
import { apiClient } from '@/shared/api/client'
export type InfoPageBlock = {
id: string
key: string
title: string
body: string
sort: number
published: boolean
createdAt: string
updatedAt: string
}
export async function fetchPublicInfoBlocks(): Promise<{ items: InfoPageBlock[] }> {
const { data } = await apiClient.get<{ items: InfoPageBlock[] }>('info-page/blocks')
return data
}
export async function fetchAdminInfoBlocks(): Promise<{ items: InfoPageBlock[] }> {
const { data } = await apiClient.get<{ items: InfoPageBlock[] }>('admin/info-page/blocks')
return data
}
export async function createInfoBlock(
body: Pick<InfoPageBlock, 'key' | 'title' | 'body' | 'sort' | 'published'>,
): Promise<{ item: InfoPageBlock }> {
const { data } = await apiClient.post<{ item: InfoPageBlock }>('admin/info-page/blocks', body)
return data
}
export async function updateInfoBlock(
id: string,
body: Partial<Pick<InfoPageBlock, 'key' | 'title' | 'body' | 'sort' | 'published'>>,
): Promise<{ item: InfoPageBlock }> {
const { data } = await apiClient.patch<{ item: InfoPageBlock }>(`admin/info-page/blocks/${id}`, body)
return data
}
export async function deleteInfoBlock(id: string): Promise<void> {
await apiClient.delete(`admin/info-page/blocks/${id}`)
}
@@ -49,47 +49,31 @@ export type AdminOrderDetailResponse = {
}
}
export async function fetchAdminOrdersSummary(token: string): Promise<{ attentionCount: number }> {
const { data } = await apiClient.get<{ attentionCount: number }>('admin/orders/summary', {
headers: { Authorization: `Bearer ${token}` },
})
export async function fetchAdminOrdersSummary(): Promise<{ attentionCount: number }> {
const { data } = await apiClient.get<{ attentionCount: number }>('admin/orders/summary')
return data
}
export async function fetchAdminOrders(
token: string,
params?: { status?: string; deliveryType?: 'delivery' | 'pickup'; q?: string; page?: number; pageSize?: number },
): Promise<AdminOrdersListResponse> {
const { data } = await apiClient.get<AdminOrdersListResponse>('admin/orders', {
params,
headers: { Authorization: `Bearer ${token}` },
})
export async function fetchAdminOrders(params?: {
status?: string
deliveryType?: 'delivery' | 'pickup'
q?: string
page?: number
pageSize?: number
}): Promise<AdminOrdersListResponse> {
const { data } = await apiClient.get<AdminOrdersListResponse>('admin/orders', { params })
return data
}
export async function fetchAdminOrder(token: string, id: string): Promise<AdminOrderDetailResponse> {
const { data } = await apiClient.get<AdminOrderDetailResponse>(`admin/orders/${id}`, {
headers: { Authorization: `Bearer ${token}` },
})
export async function fetchAdminOrder(id: string): Promise<AdminOrderDetailResponse> {
const { data } = await apiClient.get<AdminOrderDetailResponse>(`admin/orders/${id}`)
return data
}
export async function setAdminOrderStatus(token: string, id: string, status: string): Promise<void> {
await apiClient.patch(
`admin/orders/${id}/status`,
{ status },
{
headers: { Authorization: `Bearer ${token}` },
},
)
export async function setAdminOrderStatus(id: string, status: string): Promise<void> {
await apiClient.patch(`admin/orders/${id}/status`, { status })
}
export async function postAdminOrderMessage(token: string, id: string, text: string): Promise<void> {
await apiClient.post(
`admin/orders/${id}/messages`,
{ text },
{
headers: { Authorization: `Bearer ${token}` },
},
)
export async function postAdminOrderMessage(id: string, text: string): Promise<void> {
await apiClient.post(`admin/orders/${id}/messages`, { text })
}
+23 -40
View File
@@ -43,39 +43,31 @@ export async function fetchCategories(): Promise<Category[]> {
return data
}
export async function fetchAdminProducts(token: string): Promise<Product[]> {
const { data } = await apiClient.get<Product[]>('admin/products', {
headers: { Authorization: `Bearer ${token}` },
})
export async function fetchAdminProducts(): Promise<Product[]> {
const { data } = await apiClient.get<Product[]>('admin/products')
return data
}
export async function createProduct(
token: string,
body: {
title: string
slug?: string
shortDescription?: string | null
description?: string | null
quantity?: number | null
materials?: string[]
priceCents: number
imageUrl?: string | null
imageUrls?: string[]
published: boolean
inStock?: boolean
leadTimeDays?: number | null
categoryId: string
},
): Promise<Product> {
const { data } = await apiClient.post<Product>('admin/products', body, {
headers: { Authorization: `Bearer ${token}` },
})
export async function createProduct(body: {
title: string
slug?: string
shortDescription?: string | null
description?: string | null
quantity?: number | null
materials?: string[]
priceCents: number
imageUrl?: string | null
imageUrls?: string[]
published: boolean
inStock?: boolean
leadTimeDays?: number | null
categoryId: string
}): Promise<Product> {
const { data } = await apiClient.post<Product>('admin/products', body)
return data
}
export async function updateProduct(
token: string,
id: string,
body: Partial<{
title: string
@@ -93,24 +85,15 @@ export async function updateProduct(
categoryId: string
}>,
): Promise<Product> {
const { data } = await apiClient.patch<Product>(`admin/products/${id}`, body, {
headers: { Authorization: `Bearer ${token}` },
})
const { data } = await apiClient.patch<Product>(`admin/products/${id}`, body)
return data
}
export async function deleteProduct(token: string, id: string): Promise<void> {
await apiClient.delete(`admin/products/${id}`, {
headers: { Authorization: `Bearer ${token}` },
})
export async function deleteProduct(id: string): Promise<void> {
await apiClient.delete(`admin/products/${id}`)
}
export async function createCategory(
token: string,
body: { name: string; slug?: string; sort?: number },
): Promise<Category> {
const { data } = await apiClient.post<Category>('admin/categories', body, {
headers: { Authorization: `Bearer ${token}` },
})
export async function createCategory(body: { name: string; slug?: string; sort?: number }): Promise<Category> {
const { data } = await apiClient.post<Category>('admin/categories', body)
return data
}
+12 -1
View File
@@ -2,15 +2,25 @@ import { apiClient } from '@/shared/api/client'
export async function postProductReview(
productId: string,
body: { rating: number; text?: string | null },
body: { rating: number; text?: string | null; imageUrl?: string | null },
): Promise<void> {
await apiClient.post(`products/${productId}/reviews`, body)
}
export async function uploadReviewImage(file: File): Promise<{ url: string }> {
const fd = new FormData()
fd.append('file', file)
const { data } = await apiClient.post<{ url: string }>('reviews/upload-image', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return data
}
export type PublicReviewFeedItem = {
id: string
rating: number
text: string | null
imageUrl: string | null
createdAt: string
authorDisplay: string
productId: string
@@ -32,6 +42,7 @@ export type PublicProductReviewItem = {
id: string
rating: number
text: string | null
imageUrl: string | null
createdAt: string
authorDisplay: string
}
@@ -18,23 +18,15 @@ export type AdminReviewsListResponse = {
pageSize: number
}
export async function fetchAdminReviews(
token: string,
params?: { status?: string; page?: number; pageSize?: number },
): Promise<AdminReviewsListResponse> {
const { data } = await apiClient.get<AdminReviewsListResponse>('admin/reviews', {
params,
headers: { Authorization: `Bearer ${token}` },
})
export async function fetchAdminReviews(params?: {
status?: string
page?: number
pageSize?: number
}): Promise<AdminReviewsListResponse> {
const { data } = await apiClient.get<AdminReviewsListResponse>('admin/reviews', { params })
return data
}
export async function moderateReview(token: string, id: string, action: 'approve' | 'reject'): Promise<void> {
await apiClient.patch(
`admin/reviews/${id}`,
{ action },
{
headers: { Authorization: `Bearer ${token}` },
},
)
export async function moderateReview(id: string, action: 'approve' | 'reject'): Promise<void> {
await apiClient.patch(`admin/reviews/${id}`, { action })
}
+12 -24
View File
@@ -8,40 +8,28 @@ export type AdminUsersListResponse = {
pageSize: number
}
export async function fetchAdminUsers(
token: string,
params?: { q?: string; page?: number; pageSize?: number },
): Promise<AdminUsersListResponse> {
const { data } = await apiClient.get<AdminUsersListResponse>('admin/users', {
params,
headers: { Authorization: `Bearer ${token}` },
})
export async function fetchAdminUsers(params?: {
q?: string
page?: number
pageSize?: number
}): Promise<AdminUsersListResponse> {
const { data } = await apiClient.get<AdminUsersListResponse>('admin/users', { params })
return data
}
export async function createAdminUser(
token: string,
body: { email: string; name?: string | null; password?: string },
): Promise<AdminUser> {
const { data } = await apiClient.post<AdminUser>('admin/users', body, {
headers: { Authorization: `Bearer ${token}` },
})
export async function createAdminUser(body: { email: string; name?: string | null }): Promise<AdminUser> {
const { data } = await apiClient.post<AdminUser>('admin/users', body)
return data
}
export async function updateAdminUser(
token: string,
id: string,
body: Partial<{ email: string; name: string | null; password: string }>,
body: Partial<{ email: string; name: string | null }>,
): Promise<AdminUser> {
const { data } = await apiClient.patch<AdminUser>(`admin/users/${id}`, body, {
headers: { Authorization: `Bearer ${token}` },
})
const { data } = await apiClient.patch<AdminUser>(`admin/users/${id}`, body)
return data
}
export async function deleteAdminUser(token: string, id: string): Promise<void> {
await apiClient.delete(`admin/users/${id}`, {
headers: { Authorization: `Bearer ${token}` },
})
export async function deleteAdminUser(id: string): Promise<void> {
await apiClient.delete(`admin/users/${id}`)
}
-1
View File
@@ -2,7 +2,6 @@ export type AdminUser = {
id: string
email: string
name: string | null
hasPassword: boolean
createdAt: string
updatedAt: string
}
+1
View File
@@ -0,0 +1 @@
export { AdminInfoPage } from './ui/AdminInfoPage'
@@ -0,0 +1,216 @@
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions'
import DialogContent from '@mui/material/DialogContent'
import DialogTitle from '@mui/material/DialogTitle'
import FormControlLabel from '@mui/material/FormControlLabel'
import Stack from '@mui/material/Stack'
import Switch from '@mui/material/Switch'
import Table from '@mui/material/Table'
import TableBody from '@mui/material/TableBody'
import TableCell from '@mui/material/TableCell'
import TableHead from '@mui/material/TableHead'
import TableRow from '@mui/material/TableRow'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Controller, useForm } from 'react-hook-form'
import {
createInfoBlock,
deleteInfoBlock,
fetchAdminInfoBlocks,
type InfoPageBlock,
updateInfoBlock,
} from '@/entities/info/api/info-page-api'
import { getErrorMessage } from '@/shared/lib/get-error-message'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
import { useEditDialogState } from '@/shared/lib/use-edit-dialog-state'
import { EntityRowActions } from '@/shared/ui/EntityRowActions'
type FormState = {
key: string
title: string
body: string
sort: string
published: boolean
}
const emptyForm = (): FormState => ({ key: '', title: '', body: '', sort: '0', published: true })
export function AdminInfoPage() {
const qc = useQueryClient()
const { dialogOpen, editing, openCreateDialog, openEditDialog, closeDialog } = useEditDialogState<InfoPageBlock>()
const form = useForm<FormState>({ defaultValues: emptyForm(), mode: 'onChange' })
const blocksQuery = useQuery({
queryKey: ['admin', 'info-page', 'blocks'],
queryFn: fetchAdminInfoBlocks,
})
const saveMut = useMutation({
mutationFn: async () => {
const values = form.getValues()
const payload = {
key: values.key.trim(),
title: values.title.trim(),
body: values.body.trim(),
sort: Number(values.sort || 0),
published: values.published,
}
if (editing) return updateInfoBlock(editing.id, payload)
return createInfoBlock(payload)
},
onSuccess: async () => {
closeDialog()
form.reset(emptyForm())
await invalidateQueryKeys(qc, [
['admin', 'info-page', 'blocks'],
['info-page', 'public', 'blocks'],
])
},
})
const deleteMut = useMutation({
mutationFn: (id: string) => deleteInfoBlock(id),
onSuccess: async () => {
await invalidateQueryKeys(qc, [
['admin', 'info-page', 'blocks'],
['info-page', 'public', 'blocks'],
])
},
})
const openCreate = () => {
form.reset(emptyForm())
openCreateDialog()
}
const openEdit = (item: InfoPageBlock) => {
openEditDialog(item)
form.reset({
key: item.key,
title: item.title,
body: item.body,
sort: String(item.sort),
published: item.published,
})
}
const items = blocksQuery.data?.items ?? []
const err = saveMut.error ?? deleteMut.error
return (
<Box>
<Stack direction="row" sx={{ mb: 2, alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="h4">Информационная страница</Typography>
<Button variant="contained" onClick={openCreate}>
Новый блок
</Button>
</Stack>
<Typography color="text.secondary" sx={{ mb: 2 }}>
Управление блоками страницы с процессом покупки, оплаты и доставки.
</Typography>
{blocksQuery.isError && <Alert severity="error">Не удалось загрузить блоки.</Alert>}
{err && (
<Alert severity="error" sx={{ mb: 2 }}>
{getErrorMessage(err)}
</Alert>
)}
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Key</TableCell>
<TableCell>Заголовок</TableCell>
<TableCell>Порядок</TableCell>
<TableCell>Опубликован</TableCell>
<TableCell align="right">Действия</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((item) => (
<TableRow key={item.id} hover>
<TableCell>{item.key}</TableCell>
<TableCell>{item.title}</TableCell>
<TableCell>{item.sort}</TableCell>
<TableCell>{item.published ? 'Да' : 'Нет'}</TableCell>
<TableCell align="right">
<EntityRowActions
onEdit={() => openEdit(item)}
onDelete={() => deleteMut.mutate(item.id)}
deleteDisabled={deleteMut.isPending}
/>
</TableCell>
</TableRow>
))}
{items.length === 0 && !blocksQuery.isLoading && (
<TableRow>
<TableCell colSpan={5} sx={{ color: 'text.secondary' }}>
Блоков пока нет.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<Dialog open={dialogOpen} onClose={closeDialog} fullWidth maxWidth="md">
<DialogTitle>{editing ? 'Редактировать блок' : 'Новый блок'}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<Controller
control={form.control}
name="key"
render={({ field }) => <TextField label="Key (латиница, цифры, _-)" fullWidth required {...field} />}
/>
<Controller
control={form.control}
name="title"
render={({ field }) => <TextField label="Заголовок" fullWidth required {...field} />}
/>
<Controller
control={form.control}
name="body"
render={({ field }) => (
<TextField label="Содержимое" fullWidth multiline minRows={5} required {...field} />
)}
/>
<Controller
control={form.control}
name="sort"
render={({ field }) => <TextField label="Порядок сортировки" fullWidth {...field} />}
/>
<Controller
control={form.control}
name="published"
render={({ field }) => (
<FormControlLabel
control={<Switch checked={field.value} onChange={(_, v) => field.onChange(v)} />}
label="Показывать на публичной странице"
/>
)}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={closeDialog}>Отмена</Button>
<Button
variant="contained"
onClick={() => saveMut.mutate()}
disabled={
saveMut.isPending ||
!form.watch('key').trim() ||
!form.watch('title').trim() ||
!form.watch('body').trim()
}
>
{editing ? 'Сохранить' : 'Создать'}
</Button>
</DialogActions>
</Dialog>
</Box>
)
}
@@ -1,6 +1,7 @@
import type { ReactNode } from 'react'
import { useMemo, useState, useSyncExternalStore } from 'react'
import { useMemo, useState } from 'react'
import AssignmentOutlinedIcon from '@mui/icons-material/AssignmentOutlined'
import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined'
import MenuOutlinedIcon from '@mui/icons-material/MenuOutlined'
import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined'
import RateReviewOutlinedIcon from '@mui/icons-material/RateReviewOutlined'
@@ -19,13 +20,15 @@ import { useTheme } from '@mui/material/styles'
import Typography from '@mui/material/Typography'
import useMediaQuery from '@mui/material/useMediaQuery'
import { useQuery } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
import { fetchAdminOrdersSummary } from '@/entities/order/api/admin-order-api'
import { AdminPage } from '@/pages/admin'
import { AdminInfoPage } from '@/pages/admin-info'
import { AdminOrdersPage } from '@/pages/admin-orders'
import { AdminReviewsPage } from '@/pages/admin-reviews'
import { AdminUsersPage } from '@/pages/admin-users'
import { getAdminToken, subscribeAdminTokenChange } from '@/shared/lib/admin-token'
import { $user } from '@/shared/model/auth'
type NavItem = {
to: string
@@ -39,12 +42,13 @@ export function AdminLayoutPage() {
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
const [mobileOpen, setMobileOpen] = useState(false)
const adminToken = useSyncExternalStore(subscribeAdminTokenChange, getAdminToken, () => null)
const user = useUnit($user)
const isAdmin = Boolean(user?.isAdmin)
const ordersSummaryQuery = useQuery({
queryKey: ['admin', 'orders', 'summary', adminToken],
queryFn: () => fetchAdminOrdersSummary(adminToken!),
enabled: Boolean(adminToken),
queryKey: ['admin', 'orders', 'summary'],
queryFn: fetchAdminOrdersSummary,
enabled: isAdmin,
refetchInterval: 45_000,
refetchOnWindowFocus: true,
})
@@ -57,10 +61,15 @@ export function AdminLayoutPage() {
{ to: '/admin/orders', label: 'Заказы', icon: <AssignmentOutlinedIcon /> },
{ to: '/admin/reviews', label: 'Отзывы', icon: <RateReviewOutlinedIcon /> },
{ to: '/admin/users', label: 'Пользователи', icon: <PeopleOutlinedIcon /> },
{ to: '/admin/info', label: 'Инфо-страница', icon: <DescriptionOutlinedIcon /> },
],
[],
)
if (!isAdmin) {
return <Navigate to="/auth" replace />
}
const activeTo =
navItems.find((x) => location.pathname === x.to)?.to ??
navItems.find((x) => location.pathname.startsWith(`${x.to}/`))?.to ??
@@ -142,6 +151,7 @@ export function AdminLayoutPage() {
<Route path="orders" element={<AdminOrdersPage />} />
<Route path="reviews" element={<AdminReviewsPage />} />
<Route path="users" element={<AdminUsersPage />} />
<Route path="info" element={<AdminInfoPage />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
</Routes>
</Box>
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { Fragment, useMemo, useState } from 'react'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
@@ -19,7 +19,6 @@ import TableRow from '@mui/material/TableRow'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Controller, useForm } from 'react-hook-form'
import {
fetchAdminOrder,
fetchAdminOrders,
@@ -27,15 +26,14 @@ import {
setAdminOrderStatus,
} from '@/entities/order/api/admin-order-api'
import { ORDER_STATUSES, getAdminNextOrderStatuses } from '@/shared/constants/order'
import { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token'
import { formatPriceRub } from '@/shared/lib/format-price'
import { groupOrdersByStatus } from '@/shared/lib/group-orders-by-status'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
type TokenFormState = { token: string }
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
export function AdminOrdersPage() {
const qc = useQueryClient()
const [token, setTokenState] = useState<string | null>(() => getAdminToken())
const [q, setQ] = useState('')
const [status, setStatus] = useState('')
const [deliveryType, setDeliveryType] = useState<'delivery' | 'pickup' | ''>('')
@@ -43,54 +41,38 @@ export function AdminOrdersPage() {
const [selectedId, setSelectedId] = useState<string | null>(null)
const [msg, setMsg] = useState('')
const tokenForm = useForm<TokenFormState>({ defaultValues: { token: '' }, mode: 'onChange' })
useEffect(() => {
tokenForm.reset({ token: '' })
}, [token, tokenForm])
const saveToken = () => {
const t = tokenForm.getValues('token').trim()
if (!t) {
clearAdminToken()
setTokenState(null)
return
}
setAdminToken(t)
setTokenState(t)
}
const ordersQuery = useQuery({
queryKey: ['admin', 'orders', token, { q, status, deliveryType }],
queryKey: ['admin', 'orders', { q, status, deliveryType }],
queryFn: () =>
fetchAdminOrders(token!, {
fetchAdminOrders({
q: q.trim() || undefined,
status: status || undefined,
deliveryType: deliveryType || undefined,
}),
enabled: Boolean(token),
})
const orderDetailQuery = useQuery({
queryKey: ['admin', 'orders', 'detail', token, selectedId],
queryFn: () => fetchAdminOrder(token!, selectedId!),
enabled: Boolean(token && selectedId),
queryKey: ['admin', 'orders', 'detail', selectedId],
queryFn: () => fetchAdminOrder(selectedId!),
enabled: Boolean(selectedId),
})
const statusMut = useMutation({
mutationFn: (next: string) => setAdminOrderStatus(token!, selectedId!, next),
mutationFn: (next: string) => setAdminOrderStatus(selectedId!, next),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ['admin', 'orders'] })
await qc.invalidateQueries({ queryKey: ['admin', 'orders', 'detail'] })
await qc.invalidateQueries({ queryKey: ['admin', 'orders', 'summary'] })
await invalidateQueryKeys(qc, [
['admin', 'orders'],
['admin', 'orders', 'detail'],
['admin', 'orders', 'summary'],
])
},
})
const msgMut = useMutation({
mutationFn: () => postAdminOrderMessage(token!, selectedId!, msg.trim()),
mutationFn: () => postAdminOrderMessage(selectedId!, msg.trim()),
onSuccess: async () => {
setMsg('')
await qc.invalidateQueries({ queryKey: ['admin', 'orders', 'detail'] })
await invalidateQueryKeys(qc, [['admin', 'orders', 'detail']])
},
})
@@ -99,7 +81,15 @@ export function AdminOrdersPage() {
setDialogOpen(true)
}
const items = ordersQuery.data?.items ?? []
const items = useMemo(() => ordersQuery.data?.items ?? [], [ordersQuery.data?.items])
const groupedItems = useMemo(
() =>
groupOrdersByStatus(items, ORDER_STATUSES).map((group) => ({
statusCode: group.status,
items: group.items,
})),
[items],
)
const detail = orderDetailQuery.data?.item
@@ -115,97 +105,74 @@ export function AdminOrdersPage() {
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Введите API-токен из <code>ADMIN_API_TOKEN</code> (сохраняется в sessionStorage).
Управление заказами доступно пользователю с правами администратора.
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2 }}>
<Controller
control={tokenForm.control}
name="token"
render={({ field }) => (
<TextField
label="Токен (Bearer)"
type="password"
fullWidth
{...field}
placeholder={token ? '••••••••' : ''}
/>
)}
/>
<Button variant="contained" onClick={saveToken} sx={{ minWidth: 140 }}>
Сохранить
</Button>
<TextField size="small" label="Поиск (id/email)" value={q} onChange={(e) => setQ(e.target.value)} fullWidth />
<FormControl size="small" sx={{ minWidth: 220 }}>
<InputLabel id="status-label">Статус</InputLabel>
<Select
labelId="status-label"
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>
{!token && <Alert severity="info">После сохранения токена появится список заказов.</Alert>}
{ordersQuery.isError && <Alert severity="error">Не удалось загрузить заказы.</Alert>}
{token && (
<>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2 }}>
<TextField
size="small"
label="Поиск (id/email)"
value={q}
onChange={(e) => setQ(e.target.value)}
fullWidth
/>
<FormControl size="small" sx={{ minWidth: 220 }}>
<InputLabel id="status-label">Статус</InputLabel>
<Select
labelId="status-label"
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>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Покупатель</TableCell>
<TableCell>Создан</TableCell>
<TableCell>Сумма</TableCell>
<TableCell>Позиций</TableCell>
<TableCell align="right">Действия</TableCell>
</TableRow>
</TableHead>
<TableBody>
{groupedItems.map((group) => (
<Fragment key={`group:${group.statusCode}`}>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Покупатель</TableCell>
<TableCell>Статус</TableCell>
<TableCell>Сумма</TableCell>
<TableCell>Позиций</TableCell>
<TableCell align="right">Действия</TableCell>
<TableCell colSpan={6} sx={{ fontWeight: 700, bgcolor: 'action.hover' }}>
{orderStatusLabelRu(group.statusCode)} ({group.items.length})
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((o) => (
{group.items.map((o) => (
<TableRow key={o.id} hover>
<TableCell>{o.id.slice(-8)}</TableCell>
<TableCell>{o.user.email}</TableCell>
<TableCell>{orderStatusLabelRu(o.status)}</TableCell>
<TableCell>{new Date(o.createdAt).toLocaleString('ru-RU')}</TableCell>
<TableCell>{formatPriceRub(o.totalCents)}</TableCell>
<TableCell>{o.itemsCount}</TableCell>
<TableCell align="right">
@@ -215,17 +182,17 @@ export function AdminOrdersPage() {
</TableCell>
</TableRow>
))}
{ordersQuery.isSuccess && items.length === 0 && (
<TableRow>
<TableCell colSpan={6} sx={{ color: 'text.secondary' }}>
Заказов пока нет.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</>
)}
</Fragment>
))}
{ordersQuery.isSuccess && items.length === 0 && (
<TableRow>
<TableCell colSpan={6} sx={{ color: 'text.secondary' }}>
Заказов пока нет.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} fullWidth maxWidth="md">
<DialogTitle>Заказ</DialogTitle>
@@ -282,14 +249,9 @@ export function AdminOrdersPage() {
</Stack>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
<TextField
label="Ответ админа"
value={msg}
onChange={(e) => setMsg(e.target.value)}
fullWidth
multiline
minRows={2}
/>
<Box sx={{ flexGrow: 1, width: '100%' }}>
<RichTextMessageEditor value={msg} onChange={setMsg} placeholder="Ответ админа" />
</Box>
<Button
variant="contained"
onClick={() => msgMut.mutate()}
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useState } from 'react'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
@@ -12,42 +12,22 @@ import TableRow from '@mui/material/TableRow'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Controller, useForm } from 'react-hook-form'
import { fetchAdminReviews, moderateReview } from '@/entities/review/api/admin-review-api'
import { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token'
type TokenFormState = { token: string }
import { getErrorMessage } from '@/shared/lib/get-error-message'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
export function AdminReviewsPage() {
const qc = useQueryClient()
const [token, setTokenState] = useState<string | null>(() => getAdminToken())
const tokenForm = useForm<TokenFormState>({ defaultValues: { token: '' }, mode: 'onChange' })
useEffect(() => {
tokenForm.reset({ token: '' })
}, [token, tokenForm])
const saveToken = () => {
const t = tokenForm.getValues('token').trim()
if (!t) {
clearAdminToken()
setTokenState(null)
return
}
setAdminToken(t)
setTokenState(t)
}
const [status] = useState('pending')
const reviewsQuery = useQuery({
queryKey: ['admin', 'reviews', token],
queryFn: () => fetchAdminReviews(token!, { status: 'pending', page: 1, pageSize: 50 }),
enabled: Boolean(token),
queryKey: ['admin', 'reviews', status],
queryFn: () => fetchAdminReviews({ status, page: 1, pageSize: 50 }),
})
const modMut = useMutation({
mutationFn: (params: { id: string; action: 'approve' | 'reject' }) =>
moderateReview(token!, params.id, params.action),
onSuccess: () => void qc.invalidateQueries({ queryKey: ['admin', 'reviews'] }),
mutationFn: (params: { id: string; action: 'approve' | 'reject' }) => moderateReview(params.id, params.action),
onSuccess: () => void invalidateQueryKeys(qc, [['admin', 'reviews']]),
})
const error = modMut.error
@@ -61,84 +41,62 @@ export function AdminReviewsPage() {
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Введите API-токен из <code>ADMIN_API_TOKEN</code>.
Модерация отзывов доступна пользователю с правами администратора.
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2 }}>
<Controller
control={tokenForm.control}
name="token"
render={({ field }) => (
<TextField
label="Токен (Bearer)"
type="password"
fullWidth
{...field}
placeholder={token ? '••••••••' : ''}
/>
)}
/>
<Button variant="contained" onClick={saveToken} sx={{ minWidth: 140 }}>
Сохранить
</Button>
<TextField size="small" label="Статус" value="pending" disabled />
</Stack>
{!token && <Alert severity="info">После сохранения токена появится список отзывов на модерации.</Alert>}
{reviewsQuery.isError && <Alert severity="error">Не удалось загрузить отзывы.</Alert>}
{error && <Alert severity="error">{getErrorMessage(error)}</Alert>}
{token && (
<>
{reviewsQuery.isError && <Alert severity="error">Не удалось загрузить отзывы.</Alert>}
{error && <Alert severity="error">{(error as Error).message}</Alert>}
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Товар</TableCell>
<TableCell>Пользователь</TableCell>
<TableCell>Оценка</TableCell>
<TableCell>Текст</TableCell>
<TableCell align="right">Действия</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((r) => (
<TableRow key={r.id} hover>
<TableCell>{r.product.title}</TableCell>
<TableCell>{r.user.email}</TableCell>
<TableCell>
<Chip label={String(r.rating)} size="small" />
</TableCell>
<TableCell>{r.text ?? '—'}</TableCell>
<TableCell align="right">
<Button
size="small"
onClick={() => modMut.mutate({ id: r.id, action: 'approve' })}
disabled={modMut.isPending}
>
Одобрить
</Button>
<Button
size="small"
color="error"
onClick={() => modMut.mutate({ id: r.id, action: 'reject' })}
disabled={modMut.isPending}
>
Отклонить
</Button>
</TableCell>
</TableRow>
))}
{reviewsQuery.isSuccess && items.length === 0 && (
<TableRow>
<TableCell colSpan={5} sx={{ color: 'text.secondary' }}>
Нет отзывов на модерации.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</>
)}
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Товар</TableCell>
<TableCell>Пользователь</TableCell>
<TableCell>Оценка</TableCell>
<TableCell>Текст</TableCell>
<TableCell align="right">Действия</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((r) => (
<TableRow key={r.id} hover>
<TableCell>{r.product.title}</TableCell>
<TableCell>{r.user.email}</TableCell>
<TableCell>
<Chip label={String(r.rating)} size="small" />
</TableCell>
<TableCell>{r.text ?? '—'}</TableCell>
<TableCell align="right">
<Button
size="small"
onClick={() => modMut.mutate({ id: r.id, action: 'approve' })}
disabled={modMut.isPending}
>
Одобрить
</Button>
<Button
size="small"
color="error"
onClick={() => modMut.mutate({ id: r.id, action: 'reject' })}
disabled={modMut.isPending}
>
Отклонить
</Button>
</TableCell>
</TableRow>
))}
{reviewsQuery.isSuccess && items.length === 0 && (
<TableRow>
<TableCell colSpan={5} sx={{ color: 'text.secondary' }}>
Нет отзывов на модерации.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Box>
)
}
@@ -20,17 +20,17 @@ import { Controller, useForm } from 'react-hook-form'
import { Link as RouterLink } from 'react-router-dom'
import { createAdminUser, deleteAdminUser, fetchAdminUsers, updateAdminUser } from '@/entities/user/api/user-api'
import type { AdminUser } from '@/entities/user/model/types'
import { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token'
type TokenFormState = { token: string }
import { getErrorMessage } from '@/shared/lib/get-error-message'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
import { useEditDialogState } from '@/shared/lib/use-edit-dialog-state'
import { EntityRowActions } from '@/shared/ui/EntityRowActions'
type UserFormState = {
email: string
name: string
password: string
}
const emptyUserForm = (): UserFormState => ({ email: '', name: '', password: '' })
const emptyUserForm = (): UserFormState => ({ email: '', name: '' })
function formatDt(v: string) {
try {
@@ -44,28 +44,17 @@ function formatDt(v: string) {
export function AdminUsersPage() {
const queryClient = useQueryClient()
const [token, setTokenState] = useState<string | null>(() => getAdminToken())
const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState<AdminUser | null>(null)
const { dialogOpen, editing, openCreateDialog, openEditDialog, closeDialog } = useEditDialogState<AdminUser>()
const [qInput, setQInput] = useState('')
const [q, setQ] = useState('')
const [page, setPage] = useState(0)
const [rowsPerPage, setRowsPerPage] = useState(20)
const tokenForm = useForm<TokenFormState>({
defaultValues: { token: '' },
mode: 'onChange',
})
const userForm = useForm<UserFormState>({
defaultValues: emptyUserForm(),
mode: 'onChange',
})
useEffect(() => {
tokenForm.reset({ token: '' })
}, [token, tokenForm])
useEffect(() => {
const t = window.setTimeout(() => {
setQ(qInput.trim())
@@ -74,81 +63,64 @@ export function AdminUsersPage() {
return () => window.clearTimeout(t)
}, [qInput])
const saveToken = () => {
const t = tokenForm.getValues('token').trim()
if (!t) {
clearAdminToken()
setTokenState(null)
return
}
setAdminToken(t)
setTokenState(t)
}
const usersQuery = useQuery({
queryKey: ['admin', 'users', token, { q, page, rowsPerPage }],
queryKey: ['admin', 'users', { q, page, rowsPerPage }],
queryFn: () =>
fetchAdminUsers(token!, {
fetchAdminUsers({
q: q || undefined,
page: page + 1,
pageSize: rowsPerPage,
}),
enabled: Boolean(token),
})
const createMut = useMutation({
mutationFn: async () => {
const v = userForm.getValues()
await createAdminUser(token!, {
await createAdminUser({
email: v.email.trim(),
name: v.name.trim() || null,
password: v.password.trim() || undefined,
})
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
setDialogOpen(false)
void invalidateQueryKeys(queryClient, [['admin', 'users']])
closeDialog()
},
})
const updateMut = useMutation({
mutationFn: async () => {
const v = userForm.getValues()
await updateAdminUser(token!, editing!.id, {
await updateAdminUser(editing!.id, {
email: v.email.trim(),
name: v.name.trim() || null,
...(v.password.trim() ? { password: v.password.trim() } : {}),
})
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
setDialogOpen(false)
void invalidateQueryKeys(queryClient, [['admin', 'users']])
closeDialog()
},
})
const deleteMut = useMutation({
mutationFn: async (id: string) => deleteAdminUser(token!, id),
mutationFn: async (id: string) => deleteAdminUser(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
void invalidateQueryKeys(queryClient, [['admin', 'users']])
},
})
const mutationError = createMut.error ?? updateMut.error ?? deleteMut.error
const openCreate = () => {
setEditing(null)
userForm.reset(emptyUserForm())
setDialogOpen(true)
openCreateDialog()
}
const openEdit = (u: AdminUser) => {
setEditing(u)
openEditDialog(u)
userForm.reset({
email: u.email,
name: u.name ?? '',
password: '',
})
setDialogOpen(true)
}
const emailValue = userForm.watch('email')
@@ -171,124 +143,84 @@ export function AdminUsersPage() {
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Введите API-токен из{' '}
<Typography component="span" sx={{ fontFamily: 'monospace' }}>
.env
</Typography>{' '}
сервера (<code>ADMIN_API_TOKEN</code>). Он сохраняется только в памяти браузера (sessionStorage).
Управление пользователями доступно пользователю с правами администратора.
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 3 }}>
<Controller
control={tokenForm.control}
name="token"
render={({ field }) => (
<TextField
label="Токен (Bearer)"
type="password"
fullWidth
{...field}
placeholder={token ? '••••••••' : ''}
/>
)}
/>
<Button variant="contained" onClick={saveToken} sx={{ minWidth: 140 }}>
Сохранить
<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>
{!token && <Alert severity="info">После сохранения токена здесь появится список пользователей.</Alert>}
{token && (
<>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}>
<Button variant="contained" onClick={openCreate}>
Новый пользователь
</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]}
/>
</>
{usersQuery.isError && (
<Alert severity="error" sx={{ mb: 2 }}>
Ошибка загрузки. Проверьте, что сервер запущен, и у вас есть права администратора.
</Alert>
)}
<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>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
@@ -302,23 +234,10 @@ export function AdminUsersPage() {
name="name"
render={({ field }) => <TextField label="Имя/ник" fullWidth {...field} />}
/>
<Controller
control={userForm.control}
name="password"
render={({ field }) => (
<TextField
label={editing ? 'Новый пароль (необязательно)' : 'Пароль (необязательно)'}
type="password"
fullWidth
helperText="Минимум 8 символов. Для редактирования можно оставить пустым."
{...field}
/>
)}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>Отмена</Button>
<Button onClick={closeDialog}>Отмена</Button>
<Button
variant="contained"
onClick={() => (editing ? updateMut.mutate() : createMut.mutate())}
+65 -132
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useState } from 'react'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
@@ -32,8 +32,11 @@ import {
} from '@/entities/product/api/product-api'
import type { Product } from '@/entities/product/model/types'
import { apiClient } from '@/shared/api/client'
import { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token'
import { formatPriceRub } from '@/shared/lib/format-price'
import { getErrorMessage } from '@/shared/lib/get-error-message'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
import { useEditDialogState } from '@/shared/lib/use-edit-dialog-state'
import { EntityRowActions } from '@/shared/ui/EntityRowActions'
type FormState = {
title: string
@@ -67,16 +70,9 @@ const emptyForm = (): FormState => ({
export function AdminPage() {
const queryClient = useQueryClient()
const [token, setToken] = useState<string | null>(() => getAdminToken())
const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState<Product | null>(null)
const { dialogOpen, editing, openCreateDialog, openEditDialog, closeDialog } = useEditDialogState<Product>()
const [catOpen, setCatOpen] = useState(false)
const tokenForm = useForm<{ token: string }>({
defaultValues: { token: '' },
mode: 'onChange',
})
const productForm = useForm<FormState>({
defaultValues: emptyForm(),
mode: 'onChange',
@@ -91,40 +87,23 @@ export function AdminPage() {
const categoryIdValue = productForm.watch('categoryId')
const inStockValue = productForm.watch('inStock')
useEffect(() => {
tokenForm.reset({ token: '' })
}, [token, tokenForm])
const categoriesQuery = useQuery({
queryKey: ['categories'],
queryFn: () => fetchCategories(),
})
const productsQuery = useQuery({
queryKey: ['admin', 'products', token],
queryFn: () => fetchAdminProducts(token!),
enabled: Boolean(token),
queryKey: ['admin', 'products'],
queryFn: fetchAdminProducts,
})
const saveToken = () => {
const t = tokenForm.getValues('token').trim()
if (!t) {
clearAdminToken()
setToken(null)
return
}
setAdminToken(t)
setToken(t)
}
const openCreate = () => {
setEditing(null)
productForm.reset(emptyForm())
setDialogOpen(true)
openCreateDialog()
}
const openEdit = (p: Product) => {
setEditing(p)
openEditDialog(p)
const urls =
(p.images ?? [])
.slice()
@@ -144,7 +123,6 @@ export function AdminPage() {
leadTimeDays: p.leadTimeDays ? String(p.leadTimeDays) : '',
categoryId: p.categoryId,
})
setDialogOpen(true)
}
const createMut = useMutation({
@@ -164,7 +142,7 @@ export function AdminPage() {
.split(',')
.map((x) => x.trim())
.filter(Boolean)
await createProduct(token!, {
await createProduct({
title: form.title.trim(),
slug: form.slug.trim() || undefined,
shortDescription: form.shortDescription.trim() || null,
@@ -180,9 +158,8 @@ export function AdminPage() {
})
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'products'] })
void queryClient.invalidateQueries({ queryKey: ['products'] })
setDialogOpen(false)
void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']])
closeDialog()
},
})
@@ -203,7 +180,7 @@ export function AdminPage() {
.split(',')
.map((x) => x.trim())
.filter(Boolean)
await updateProduct(token!, editing!.id, {
await updateProduct(editing!.id, {
title: form.title.trim(),
slug: form.slug.trim(),
shortDescription: form.shortDescription.trim() || null,
@@ -219,30 +196,28 @@ export function AdminPage() {
})
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'products'] })
void queryClient.invalidateQueries({ queryKey: ['products'] })
setDialogOpen(false)
void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']])
closeDialog()
},
})
const deleteMut = useMutation({
mutationFn: (id: string) => deleteProduct(token!, id),
mutationFn: (id: string) => deleteProduct(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'products'] })
void queryClient.invalidateQueries({ queryKey: ['products'] })
void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']])
},
})
const createCategoryMut = useMutation({
mutationFn: () => {
const v = categoryForm.getValues()
return createCategory(token!, {
return createCategory({
name: v.name.trim(),
slug: v.slug.trim() || undefined,
})
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['categories'] })
void invalidateQueryKeys(queryClient, [['categories']])
setCatOpen(false)
categoryForm.reset({ name: '', slug: '' })
},
@@ -260,10 +235,7 @@ export function AdminPage() {
const fd = new FormData()
Array.from(files).forEach((f) => fd.append('files', f))
const { data } = await apiClient.post<{ urls: string[] }>('admin/uploads', fd, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'multipart/form-data',
},
headers: { 'Content-Type': 'multipart/form-data' },
})
return data.urls
},
@@ -288,92 +260,55 @@ export function AdminPage() {
Админка
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Введите API-токен из{' '}
<Typography component="span" sx={{ fontFamily: 'monospace' }}>
.env
</Typography>{' '}
сервера (<code>ADMIN_API_TOKEN</code>). Он сохраняется только в памяти браузера (sessionStorage).
Управление товарами и категориями. Доступно пользователю с правами администратора.
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 3 }}>
<Controller
control={tokenForm.control}
name="token"
render={({ field }) => (
<TextField
label="Токен (Bearer)"
type="password"
fullWidth
{...field}
placeholder={token ? '••••••••' : ''}
/>
)}
/>
<Button variant="contained" onClick={saveToken} sx={{ minWidth: 140 }}>
Сохранить
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
<Button variant="contained" onClick={openCreate}>
Новый товар
</Button>
<Button variant="outlined" onClick={() => setCatOpen(true)}>
Новая категория
</Button>
</Stack>
{!token && (
<Alert severity="info">После сохранения токена здесь появится список товаров и формы управления.</Alert>
{productsQuery.isError && (
<Alert severity="error" sx={{ mb: 2 }}>
Ошибка загрузки. Проверьте, что сервер запущен, и у вас есть права администратора.
</Alert>
)}
{token && (
<>
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
<Button variant="contained" onClick={openCreate}>
Новый товар
</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>
</>
{mutationError && (
<Alert severity="error" sx={{ mb: 2 }}>
{getErrorMessage(mutationError)}
</Alert>
)}
<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>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
@@ -448,7 +383,7 @@ export function AdminPage() {
flexDirection: { xs: 'column', sm: 'row' },
}}
>
<Button component="label" variant="outlined" disabled={uploadImagesMut.isPending || !token}>
<Button component="label" variant="outlined" disabled={uploadImagesMut.isPending}>
Выбрать файлы
<input
hidden
@@ -464,9 +399,7 @@ export function AdminPage() {
/>
</Button>
{uploadImagesMut.isPending && <Typography color="text.secondary">Загрузка</Typography>}
{uploadImagesMut.isError && (
<Typography color="error">Не удалось загрузить фото (проверьте токен и сервер)</Typography>
)}
{uploadImagesMut.isError && <Typography color="error">Не удалось загрузить фото</Typography>}
</Box>
{productForm.watch('imageUrls').length > 0 && (
@@ -559,7 +492,7 @@ export function AdminPage() {
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>Отмена</Button>
<Button onClick={closeDialog}>Отмена</Button>
<Button
variant="contained"
onClick={handleSubmit}
+2 -52
View File
@@ -34,16 +34,13 @@ export function AuthPage() {
const { register, watch } = useForm<{
email: string
code: string
password: string
}>({
defaultValues: { email: '', code: '', password: '' },
defaultValues: { email: '', code: '' },
mode: 'onChange',
})
const email = watch('email')
const code = watch('code')
const password = watch('password')
useEffect(() => {
if (user) navigate('/', { replace: true })
}, [navigate, user])
@@ -71,27 +68,7 @@ export function AuthPage() {
},
})
const registerPassword = useMutation({
mutationFn: async () => {
const { data } = await apiClient.post<AuthResponse>('auth/register', { email, password })
tokenSet(data.token)
setMessage(`Регистрация выполнена: ${data.user.email}`)
navigate('/', { replace: true })
},
})
const loginPassword = useMutation({
mutationFn: async () => {
const { data } = await apiClient.post<AuthResponse>('auth/login', { email, password })
tokenSet(data.token)
setMessage(`Вход выполнен: ${data.user.email}`)
navigate('/', { replace: true })
},
})
const errMsg = getApiErrorMessage(
requestCode.error || verifyCode.error || registerPassword.error || loginPassword.error,
)
const errMsg = getApiErrorMessage(requestCode.error || verifyCode.error)
return (
<Box>
@@ -144,33 +121,6 @@ export function AuthPage() {
Войти
</Button>
</Stack>
<Divider />
<Typography variant="h6">Вариант 2: Email + пароль</Typography>
<TextField
label="Пароль"
type="password"
{...register('password')}
fullWidth
helperText="Минимум 8 символов для регистрации"
/>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<Button
variant="contained"
onClick={() => registerPassword.mutate()}
disabled={!email || password.length < 8 || registerPassword.isPending}
>
Зарегистрироваться
</Button>
<Button
variant="outlined"
onClick={() => loginPassword.mutate()}
disabled={!email || !password || loginPassword.isPending}
>
Войти
</Button>
</Stack>
</Stack>
</Box>
)
+1
View File
@@ -0,0 +1 @@
export { InfoPage } from './ui/InfoPage'
+42
View File
@@ -0,0 +1,42 @@
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Paper from '@mui/material/Paper'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { useQuery } from '@tanstack/react-query'
import { fetchPublicInfoBlocks } from '@/entities/info/api/info-page-api'
export function InfoPage() {
const q = useQuery({
queryKey: ['info-page', 'public', 'blocks'],
queryFn: fetchPublicInfoBlocks,
})
return (
<Box>
<Typography variant="h4" gutterBottom>
Информация для покупателей
</Typography>
<Typography color="text.secondary" sx={{ mb: 2 }}>
Как оформить заказ, как проходит доставка, оплата и другие важные детали.
</Typography>
{q.isLoading && <Typography color="text.secondary">Загрузка</Typography>}
{q.isError && <Alert severity="error">Не удалось загрузить информацию.</Alert>}
{q.isSuccess && q.data.items.length === 0 && <Alert severity="info">Раздел пока не заполнен.</Alert>}
{q.isSuccess && q.data.items.length > 0 && (
<Stack spacing={2}>
{q.data.items.map((block) => (
<Paper key={block.id} variant="outlined" sx={{ p: 2, borderRadius: 2 }}>
<Typography variant="h6" sx={{ mb: 0.75 }}>
{block.title}
</Typography>
<Typography sx={{ whiteSpace: 'pre-wrap' }}>{block.body}</Typography>
</Paper>
))}
</Stack>
)}
</Box>
)
}
-43
View File
@@ -8,12 +8,10 @@ import Typography from '@mui/material/Typography'
import { useUnit } from 'effector-react'
import { useForm } from 'react-hook-form'
import {
$changePasswordError,
$requestEmailChangeCodeError,
$updateProfileError,
$user,
$verifyEmailChangeError,
changePasswordFx,
requestEmailChangeCodeFx,
updateProfileFx,
verifyEmailChangeFx,
@@ -28,20 +26,13 @@ function getApiErrorMessage(error: unknown): string | null {
export function MePage() {
const user = useUnit($user)
const pendingPassword = useUnit(changePasswordFx.pending)
const pendingEmailReq = useUnit(requestEmailChangeCodeFx.pending)
const pendingEmailVerify = useUnit(verifyEmailChangeFx.pending)
const pendingProfile = useUnit(updateProfileFx.pending)
const errorPassword = useUnit($changePasswordError)
const errorEmailReq = useUnit($requestEmailChangeCodeError)
const errorProfile = useUnit($updateProfileError)
const errorEmailVerify = useUnit($verifyEmailChangeError)
const passwordForm = useForm<{ currentPassword: string; newPassword: string }>({
defaultValues: { currentPassword: '', newPassword: '' },
mode: 'onChange',
})
const emailForm = useForm<{ newEmail: string; code: string }>({
defaultValues: { newEmail: '', code: '' },
mode: 'onChange',
@@ -52,7 +43,6 @@ export function MePage() {
mode: 'onChange',
})
const passwordErrorMsg = getApiErrorMessage(errorPassword)
const emailErrorMsg = getApiErrorMessage(errorEmailReq) ?? getApiErrorMessage(errorEmailVerify)
const profileErrorMsg = getApiErrorMessage(errorProfile)
@@ -69,11 +59,6 @@ export function MePage() {
Текущая почта: <b>{user.email}</b>
</Typography>
{passwordErrorMsg && (
<Alert severity="error" sx={{ mb: 2 }}>
{passwordErrorMsg}
</Alert>
)}
{emailErrorMsg && (
<Alert severity="error" sx={{ mb: 2 }}>
{emailErrorMsg}
@@ -143,34 +128,6 @@ export function MePage() {
</Stack>
</Stack>
</Box>
<Divider />
<Box>
<Typography variant="h6" gutterBottom>
Смена пароля
</Typography>
<Stack spacing={2}>
<TextField
label="Текущий пароль (если установлен)"
type="password"
{...passwordForm.register('currentPassword')}
/>
<TextField label="Новый пароль" type="password" {...passwordForm.register('newPassword')} />
<Button
variant="contained"
disabled={passwordForm.watch('newPassword').length < 8 || pendingPassword}
onClick={() =>
changePasswordFx({
currentPassword: passwordForm.getValues('currentPassword') || undefined,
newPassword: passwordForm.getValues('newPassword'),
})
}
>
Сохранить пароль
</Button>
</Stack>
</Box>
</Stack>
</Box>
)
@@ -8,13 +8,13 @@ import ListItem from '@mui/material/ListItem'
import ListItemButton from '@mui/material/ListItemButton'
import ListItemText from '@mui/material/ListItemText'
import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link as RouterLink } from 'react-router-dom'
import { fetchMyOrder, postOrderMessage } from '@/entities/order/api/order-api'
import { fetchMyConversations, markOrderMessagesRead } from '@/entities/user/api/messages-api'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
export function MessagesPage() {
const qc = useQueryClient()
@@ -177,14 +177,9 @@ export function MessagesPage() {
{order.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
</Stack>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
<TextField
label="Сообщение"
value={text}
onChange={(e) => setText(e.target.value)}
fullWidth
multiline
minRows={2}
/>
<Box sx={{ flexGrow: 1, width: '100%' }}>
<RichTextMessageEditor value={text} onChange={setText} placeholder="Сообщение" />
</Box>
<Button
variant="contained"
sx={{ minWidth: 140 }}
@@ -9,7 +9,6 @@ import DialogTitle from '@mui/material/DialogTitle'
import Divider from '@mui/material/Divider'
import Rating from '@mui/material/Rating'
import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import axios from 'axios'
@@ -21,10 +20,11 @@ import {
payOrderStub,
postOrderMessage,
} from '@/entities/order/api/order-api'
import { postProductReview } from '@/entities/product/api/reviews-api'
import { postProductReview, uploadReviewImage } from '@/entities/product/api/reviews-api'
import { markOrderMessagesRead } from '@/entities/user/api/messages-api'
import { formatPriceRub } from '@/shared/lib/format-price'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
function reviewSubmitErrorMessage(err: unknown): string {
if (axios.isAxiosError(err)) {
@@ -59,6 +59,7 @@ export function OrderDetailPage() {
const [reviewTarget, setReviewTarget] = useState<{ productId: string; title: string } | null>(null)
const [reviewRating, setReviewRating] = useState<number>(5)
const [reviewText, setReviewText] = useState('')
const [reviewImageUrl, setReviewImageUrl] = useState<string | null>(null)
const orderQuery = useQuery({
queryKey: ['me', 'orders', id],
@@ -108,16 +109,23 @@ export function OrderDetailPage() {
await postProductReview(reviewTarget.productId, {
rating: reviewRating,
text: t.length ? t : null,
imageUrl: reviewImageUrl,
})
},
onSuccess: async () => {
setReviewTarget(null)
setReviewRating(5)
setReviewText('')
setReviewImageUrl(null)
await qc.invalidateQueries({ queryKey: ['me', 'orders', id, 'review-eligibility'] })
},
})
const uploadReviewImageMut = useMutation({
mutationFn: (file: File) => uploadReviewImage(file),
onSuccess: ({ url }) => setReviewImageUrl(url),
})
useEffect(() => {
if (!id || orderQuery.status !== 'success' || !order) return
void (async () => {
@@ -326,14 +334,9 @@ export function OrderDetailPage() {
</Stack>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
<TextField
label="Сообщение"
value={text}
onChange={(e) => setText(e.target.value)}
fullWidth
multiline
minRows={2}
/>
<Box sx={{ flexGrow: 1, width: '100%' }}>
<RichTextMessageEditor value={text} onChange={setText} placeholder="Сообщение" />
</Box>
<Button
variant="contained"
onClick={() => msgMut.mutate()}
@@ -348,7 +351,13 @@ export function OrderDetailPage() {
<Dialog
open={Boolean(reviewTarget)}
onClose={() => !reviewMut.isPending && setReviewTarget(null)}
onClose={() => {
if (reviewMut.isPending) return
setReviewTarget(null)
setReviewRating(5)
setReviewText('')
setReviewImageUrl(null)
}}
fullWidth
maxWidth="sm"
>
@@ -363,15 +372,60 @@ export function OrderDetailPage() {
if (v !== null) setReviewRating(v)
}}
/>
<TextField
sx={{ mt: 2 }}
label="Комментарий (необязательно)"
value={reviewText}
onChange={(e) => setReviewText(e.target.value)}
fullWidth
multiline
minRows={3}
/>
<Box sx={{ mt: 2 }}>
<RichTextMessageEditor
value={reviewText}
onChange={setReviewText}
placeholder="Комментарий (необязательно)"
/>
</Box>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5} sx={{ mt: 2, alignItems: { sm: 'center' } }}>
<Button component="label" variant="outlined" disabled={uploadReviewImageMut.isPending}>
{reviewImageUrl ? 'Заменить фото' : 'Прикрепить фото'}
<input
hidden
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(e) => {
const file = e.target.files?.[0]
if (!file) return
uploadReviewImageMut.mutate(file)
e.currentTarget.value = ''
}}
/>
</Button>
{reviewImageUrl && (
<Button
color="error"
variant="text"
onClick={() => setReviewImageUrl(null)}
disabled={reviewMut.isPending}
>
Удалить фото
</Button>
)}
</Stack>
{reviewImageUrl && (
<Box
component="img"
src={reviewImageUrl}
alt="Фото к отзыву"
sx={{
mt: 1,
width: 120,
height: 120,
objectFit: 'cover',
borderRadius: 1.5,
border: 1,
borderColor: 'divider',
}}
/>
)}
{uploadReviewImageMut.isError && (
<Alert severity="error" sx={{ mt: 2 }}>
Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.
</Alert>
)}
{reviewMut.isError && (
<Alert severity="error" sx={{ mt: 2 }}>
{reviewSubmitErrorMessage(reviewMut.error)}
@@ -379,7 +433,15 @@ export function OrderDetailPage() {
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setReviewTarget(null)} disabled={reviewMut.isPending}>
<Button
onClick={() => {
setReviewTarget(null)
setReviewRating(5)
setReviewText('')
setReviewImageUrl(null)
}}
disabled={reviewMut.isPending}
>
Отмена
</Button>
<Button variant="contained" disabled={reviewMut.isPending} onClick={() => reviewMut.mutate()}>
+41 -26
View File
@@ -1,12 +1,16 @@
import { useMemo } from 'react'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Divider from '@mui/material/Divider'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { useQuery } from '@tanstack/react-query'
import { Link as RouterLink } from 'react-router-dom'
import { fetchMyOrders } from '@/entities/order/api/order-api'
import { ORDER_STATUSES } from '@/shared/constants/order'
import { formatPriceRub } from '@/shared/lib/format-price'
import { groupOrdersByStatus } from '@/shared/lib/group-orders-by-status'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
export function OrdersPage() {
@@ -15,7 +19,8 @@ export function OrdersPage() {
queryFn: fetchMyOrders,
})
const items = ordersQuery.data?.items ?? []
const items = useMemo(() => ordersQuery.data?.items ?? [], [ordersQuery.data?.items])
const groups = useMemo(() => groupOrdersByStatus(items, ORDER_STATUSES), [items])
return (
<Box>
@@ -29,33 +34,43 @@ export function OrdersPage() {
<Alert severity="info">Заказов пока нет. Оформите заказ из корзины.</Alert>
)}
<Stack spacing={2}>
{items.map((o) => (
<Box
key={o.id}
sx={{
border: 1,
borderColor: 'divider',
borderRadius: 2,
p: 2,
bgcolor: 'background.paper',
}}
>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ alignItems: { sm: 'center' } }}>
<Box sx={{ flexGrow: 1 }}>
<Typography sx={{ fontWeight: 700 }}>Заказ #{o.id.slice(-6)}</Typography>
<Typography color="text.secondary" variant="body2">
Статус: {orderStatusLabelRu(o.status)} · {o.itemsCount} поз.
</Typography>
</Box>
<Typography sx={{ fontWeight: 700 }}>{formatPriceRub(o.totalCents)}</Typography>
</Stack>
<Stack spacing={3}>
{groups.map((group) => (
<Box key={group.status}>
<Typography variant="h6" sx={{ mb: 1 }}>
{orderStatusLabelRu(group.status)} ({group.items.length})
</Typography>
<Stack spacing={2}>
{group.items.map((o) => (
<Box
key={o.id}
sx={{
border: 1,
borderColor: 'divider',
borderRadius: 2,
p: 2,
bgcolor: 'background.paper',
}}
>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ alignItems: { sm: 'center' } }}>
<Box sx={{ flexGrow: 1 }}>
<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 }}>
<Button component={RouterLink} to={`/me/orders/${o.id}`} size="small" variant="outlined">
Открыть
</Button>
<Stack direction="row" spacing={1} sx={{ mt: 1.5 }}>
<Button component={RouterLink} to={`/me/orders/${o.id}`} size="small" variant="outlined">
Открыть
</Button>
</Stack>
</Box>
))}
</Stack>
<Divider sx={{ mt: 2 }} />
</Box>
))}
</Stack>
@@ -8,12 +8,10 @@ import Typography from '@mui/material/Typography'
import { useUnit } from 'effector-react'
import { useForm } from 'react-hook-form'
import {
$changePasswordError,
$requestEmailChangeCodeError,
$updateProfileError,
$user,
$verifyEmailChangeError,
changePasswordFx,
requestEmailChangeCodeFx,
updateProfileFx,
verifyEmailChangeFx,
@@ -28,20 +26,13 @@ function getApiErrorMessage(error: unknown): string | null {
export function SettingsPage() {
const user = useUnit($user)
const pendingPassword = useUnit(changePasswordFx.pending)
const pendingEmailReq = useUnit(requestEmailChangeCodeFx.pending)
const pendingEmailVerify = useUnit(verifyEmailChangeFx.pending)
const pendingProfile = useUnit(updateProfileFx.pending)
const errorPassword = useUnit($changePasswordError)
const errorEmailReq = useUnit($requestEmailChangeCodeError)
const errorProfile = useUnit($updateProfileError)
const errorEmailVerify = useUnit($verifyEmailChangeError)
const passwordForm = useForm<{ currentPassword: string; newPassword: string }>({
defaultValues: { currentPassword: '', newPassword: '' },
mode: 'onChange',
})
const emailForm = useForm<{ newEmail: string; code: string }>({
defaultValues: { newEmail: '', code: '' },
mode: 'onChange',
@@ -52,7 +43,6 @@ export function SettingsPage() {
mode: 'onChange',
})
const passwordErrorMsg = getApiErrorMessage(errorPassword)
const emailErrorMsg = getApiErrorMessage(errorEmailReq) ?? getApiErrorMessage(errorEmailVerify)
const profileErrorMsg = getApiErrorMessage(errorProfile)
@@ -69,11 +59,6 @@ export function SettingsPage() {
Текущая почта: <b>{user.email}</b>
</Typography>
{passwordErrorMsg && (
<Alert severity="error" sx={{ mb: 2 }}>
{passwordErrorMsg}
</Alert>
)}
{emailErrorMsg && (
<Alert severity="error" sx={{ mb: 2 }}>
{emailErrorMsg}
@@ -150,34 +135,6 @@ export function SettingsPage() {
</Stack>
</Stack>
</Box>
<Divider />
<Box>
<Typography variant="h6" gutterBottom>
Смена пароля
</Typography>
<Stack spacing={2}>
<TextField
label="Текущий пароль (если установлен)"
type="password"
{...passwordForm.register('currentPassword')}
/>
<TextField label="Новый пароль" type="password" {...passwordForm.register('newPassword')} />
<Button
variant="contained"
disabled={passwordForm.watch('newPassword').length < 8 || pendingPassword}
onClick={() =>
changePasswordFx({
currentPassword: passwordForm.getValues('currentPassword') || undefined,
newPassword: passwordForm.getValues('newPassword'),
})
}
>
Сохранить пароль
</Button>
</Stack>
</Box>
</Stack>
</Box>
)
@@ -218,6 +218,21 @@ export function ProductPage() {
Без текстового комментария.
</Typography>
)}
{rv.imageUrl && (
<Box
component="img"
src={rv.imageUrl}
alt="Фото к отзыву"
sx={{
width: 140,
height: 140,
objectFit: 'cover',
borderRadius: 1.5,
border: 1,
borderColor: 'divider',
}}
/>
)}
</Stack>
</Paper>
)
-26
View File
@@ -1,26 +0,0 @@
const KEY = 'craftshop_admin_token'
const TOKEN_EVENT = 'craftshop_admin_token_change'
export function getAdminToken(): string | null {
return sessionStorage.getItem(KEY)
}
function notifyTokenListeners(): void {
window.dispatchEvent(new Event(TOKEN_EVENT))
}
/** Подписаться на смену токена (в т. ч. после setAdminToken). */
export function subscribeAdminTokenChange(cb: () => void): () => void {
window.addEventListener(TOKEN_EVENT, cb)
return () => window.removeEventListener(TOKEN_EVENT, cb)
}
export function setAdminToken(token: string): void {
sessionStorage.setItem(KEY, token)
notifyTokenListeners()
}
export function clearAdminToken(): void {
sessionStorage.removeItem(KEY)
notifyTokenListeners()
}
@@ -0,0 +1,4 @@
export function getErrorMessage(error: unknown, fallback = 'Произошла ошибка'): string {
if (error instanceof Error && error.message) return error.message
return fallback
}
@@ -0,0 +1,24 @@
type OrderLike = {
status: string
createdAt: string
}
export function groupOrdersByStatus<T extends OrderLike>(items: T[], statuses: readonly string[]) {
const byStatus = new Map<string, T[]>()
for (const status of statuses) byStatus.set(status, [])
for (const item of items) {
const list = byStatus.get(item.status) ?? []
list.push(item)
byStatus.set(item.status, list)
}
return statuses
.map((status) => ({
status,
items: (byStatus.get(status) ?? []).sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
),
}))
.filter((group) => group.items.length > 0)
}
@@ -0,0 +1,5 @@
import type { QueryClient, QueryKey } from '@tanstack/react-query'
export async function invalidateQueryKeys(queryClient: QueryClient, keys: QueryKey[]): Promise<void> {
await Promise.all(keys.map((queryKey) => queryClient.invalidateQueries({ queryKey })))
}
@@ -0,0 +1,28 @@
import { useCallback, useState } from 'react'
export function useEditDialogState<T>() {
const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState<T | null>(null)
const openCreateDialog = useCallback(() => {
setEditing(null)
setDialogOpen(true)
}, [])
const openEditDialog = useCallback((item: T) => {
setEditing(item)
setDialogOpen(true)
}, [])
const closeDialog = useCallback(() => {
setDialogOpen(false)
}, [])
return {
dialogOpen,
editing,
openCreateDialog,
openEditDialog,
closeDialog,
}
}
+2 -11
View File
@@ -1,7 +1,7 @@
import { createEffect, createEvent, createStore, sample } from 'effector'
import { apiClient } from '@/shared/api/client'
export type AuthUser = { id: string; email: string; name?: string | null; phone?: string | null }
export type AuthUser = { id: string; email: string; name?: string | null; phone?: string | null; isAdmin?: boolean }
const TOKEN_KEY = 'craftshop_auth_token'
@@ -14,11 +14,6 @@ export const $token = createStore<string | null>(null)
export const $user = createStore<AuthUser | null>(null).reset(logout)
export const changePasswordFx = createEffect(async (params: { currentPassword?: string; newPassword: string }) => {
const { data } = await apiClient.patch<{ user: AuthUser }>('me/password', params)
return data.user
})
export const requestEmailChangeCodeFx = createEffect(async (newEmail: string) => {
await apiClient.post('me/change-email/request-code', { newEmail })
})
@@ -35,10 +30,6 @@ export const updateProfileFx = createEffect(async (params: UpdateProfileParams)
return data.user
})
export const $changePasswordError = createStore<unknown | null>(null)
.on(changePasswordFx.failData, (_, e) => e)
.reset(changePasswordFx, logout)
export const $requestEmailChangeCodeError = createStore<unknown | null>(null)
.on(requestEmailChangeCodeFx.failData, (_, e) => e)
.reset(requestEmailChangeCodeFx, logout)
@@ -70,7 +61,7 @@ sample({
})
sample({
clock: [changePasswordFx.doneData, verifyEmailChangeFx.doneData, updateProfileFx.doneData],
clock: [verifyEmailChangeFx.doneData, updateProfileFx.doneData],
target: $user,
})
+43
View File
@@ -0,0 +1,43 @@
import Button from '@mui/material/Button'
import Stack from '@mui/material/Stack'
type EntityRowActionsProps = {
onEdit?: () => void
onDelete?: () => void
deleteDisabled?: boolean
confirmDeleteMessage?: string
editLabel?: string
deleteLabel?: string
}
export function EntityRowActions({
onEdit,
onDelete,
deleteDisabled = false,
confirmDeleteMessage,
editLabel = 'Изменить',
deleteLabel = 'Удалить',
}: EntityRowActionsProps) {
return (
<Stack direction="row" spacing={0.5} sx={{ justifyContent: 'flex-end' }}>
{onEdit && (
<Button size="small" onClick={onEdit}>
{editLabel}
</Button>
)}
{onDelete && (
<Button
size="small"
color="error"
disabled={deleteDisabled}
onClick={() => {
if (confirmDeleteMessage && !confirm(confirmDeleteMessage)) return
onDelete()
}}
>
{deleteLabel}
</Button>
)}
</Stack>
)
}
@@ -0,0 +1,101 @@
import { useEffect } from 'react'
import FormatBoldOutlinedIcon from '@mui/icons-material/FormatBoldOutlined'
import FormatItalicOutlinedIcon from '@mui/icons-material/FormatItalicOutlined'
import FormatListBulletedOutlinedIcon from '@mui/icons-material/FormatListBulletedOutlined'
import Box from '@mui/material/Box'
import IconButton from '@mui/material/IconButton'
import Stack from '@mui/material/Stack'
import Placeholder from '@tiptap/extension-placeholder'
import { EditorContent, useEditor } from '@tiptap/react'
import TiptapStarterKit from '@tiptap/starter-kit'
type RichTextMessageEditorProps = {
value: string
onChange: (next: string) => void
placeholder?: string
disabled?: boolean
}
export function RichTextMessageEditor({
value,
onChange,
placeholder = 'Введите сообщение',
disabled = false,
}: RichTextMessageEditorProps) {
const editor = useEditor({
extensions: [
TiptapStarterKit.configure({ heading: false, codeBlock: false, blockquote: false, horizontalRule: false }),
Placeholder.configure({ placeholder }),
],
content: value,
editable: !disabled,
onUpdate: ({ editor: tiptap }) => onChange(tiptap.getText()),
})
useEffect(() => {
if (!editor) return
editor.setEditable(!disabled)
}, [disabled, editor])
useEffect(() => {
if (!editor) return
if (editor.getText() === value) return
editor.commands.setContent(value, false)
}, [editor, value])
return (
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, overflow: 'hidden', bgcolor: 'background.paper' }}>
<Stack direction="row" spacing={0.5} sx={{ p: 0.75, borderBottom: 1, borderColor: 'divider' }}>
<IconButton
size="small"
onClick={() => editor?.chain().focus().toggleBold().run()}
color={editor?.isActive('bold') ? 'primary' : 'default'}
disabled={disabled}
aria-label="Жирный"
>
<FormatBoldOutlinedIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => editor?.chain().focus().toggleItalic().run()}
color={editor?.isActive('italic') ? 'primary' : 'default'}
disabled={disabled}
aria-label="Курсив"
>
<FormatItalicOutlinedIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => editor?.chain().focus().toggleBulletList().run()}
color={editor?.isActive('bulletList') ? 'primary' : 'default'}
disabled={disabled}
aria-label="Список"
>
<FormatListBulletedOutlinedIcon fontSize="small" />
</IconButton>
</Stack>
<Box
sx={{
px: 1.5,
py: 1.25,
'& .ProseMirror': {
minHeight: 72,
outline: 'none',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
},
'& .ProseMirror p.is-editor-empty:first-of-type::before': {
content: `"${placeholder}"`,
color: 'text.disabled',
pointerEvents: 'none',
float: 'left',
height: 0,
},
}}
>
<EditorContent editor={editor} />
</Box>
</Box>
)
}
@@ -109,6 +109,22 @@ export function ReviewsBlock() {
{text}
</Typography>
</Stack>
{r.imageUrl && (
<Box
component="img"
src={r.imageUrl}
alt="Фото к отзыву"
sx={{
mt: 1.5,
width: 120,
height: 120,
objectFit: 'cover',
borderRadius: 1.5,
border: 1,
borderColor: 'divider',
}}
/>
)}
</Paper>
)
})}