From 326521c9e67094f4662102bfa36357a18b425f3e Mon Sep 17 00:00:00 2001 From: "@kirill.komarov" Date: Wed, 29 Apr 2026 18:34:25 +0500 Subject: [PATCH] base commit --- client/package-lock.json | 536 +++++++++++++++++- client/package.json | 2 + client/src/app/App.tsx | 4 +- client/src/entities/user/api/address-api.ts | 49 ++ client/src/entities/user/model/types.ts | 14 + .../ui/AddressMapPicker.tsx | 231 ++++++++ client/src/main.tsx | 1 + client/src/pages/auth/ui/AuthPage.tsx | 17 +- client/src/pages/home/ui/HomePage.tsx | 72 ++- client/src/pages/me/index.ts | 2 +- client/src/pages/me/ui/MeLayoutPage.tsx | 136 +++++ .../pages/me/ui/sections/AddressesPage.tsx | 318 +++++++++++ .../src/pages/me/ui/sections/MessagesPage.tsx | 13 + .../src/pages/me/ui/sections/OrdersPage.tsx | 13 + .../src/pages/me/ui/sections/SettingsPage.tsx | 184 ++++++ client/src/shared/model/auth.ts | 6 +- .../20260429130733_user_phone/migration.sql | 2 + .../migration.sql | 22 + server/prisma/schema.prisma | 22 + server/src/routes/auth.js | 217 ++++++- 20 files changed, 1825 insertions(+), 36 deletions(-) create mode 100644 client/src/entities/user/api/address-api.ts create mode 100644 client/src/features/address-map-picker/ui/AddressMapPicker.tsx create mode 100644 client/src/pages/me/ui/MeLayoutPage.tsx create mode 100644 client/src/pages/me/ui/sections/AddressesPage.tsx create mode 100644 client/src/pages/me/ui/sections/MessagesPage.tsx create mode 100644 client/src/pages/me/ui/sections/OrdersPage.tsx create mode 100644 client/src/pages/me/ui/sections/SettingsPage.tsx create mode 100644 server/prisma/migrations/20260429130733_user_phone/migration.sql create mode 100644 server/prisma/migrations/20260429130833_shipping_addresses/migration.sql diff --git a/client/package-lock.json b/client/package-lock.json index 621fef6..9f90035 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -16,9 +16,11 @@ "axios": "^1.15.2", "effector": "^23.4.4", "effector-react": "^23.3.0", + "maplibre-gl": "^5.24.0", "react": "^19.2.5", "react-dom": "^19.2.5", "react-hook-form": "^7.74.0", + "react-map-gl": "^8.1.1", "react-router-dom": "^7.14.2", "swiper": "^12.1.3" }, @@ -728,6 +730,111 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.1.0.tgz", + "integrity": "sha512-uFJhNh36BR4OCuWIEiWaEix9CA2WzT6CAIcqVjWYpnx8+QDtS+oC4QehRrx5cX4mgWs37MmKnwUejeHxVymzNg==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz", + "integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.1" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.0.tgz", + "integrity": "sha512-2eIY4gZxeKIVOZVNkAMb+5NgXhgsMQpOveTQAvnp53LYqHGJZDidk7Ew0Tged9PThidpbS+NFTh0g4zivhPDzQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "24.8.1", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.8.1.tgz", + "integrity": "sha512-zxa92qF96ZNojLxeAjnaRpjVCy+swoUNJvDhtpC90k7u5F0TMr4GmvNqMKvYrMoPB8d7gRSXbMG1hBbmgESIsw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "rw": "^1.3.3", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/mlt": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.9.tgz", + "integrity": "sha512-g/tD8EYJB97udq33ipuJ9a4Q7fcbZnTEnUrgnEc/tLMmEL+zaCbR+X5fkDBO2dgpaAMsLH179qE3UXg2N0Nc/g==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.0.tgz", + "integrity": "sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/vector-tile": "^2.0.4", + "@maplibre/geojson-vt": "^5.0.4", + "@types/geojson": "^7946.0.16", + "@types/supercluster": "^7.1.3", + "pbf": "^4.0.1", + "supercluster": "^8.0.1" + } + }, + "node_modules/@maplibre/vt-pbf/node_modules/@maplibre/geojson-vt": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz", + "integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==", + "license": "ISC" + }, "node_modules/@mui/core-downloads-tracker": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.0.0.tgz", @@ -1335,6 +1442,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1393,6 +1506,15 @@ "@types/react": "*" } }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "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", @@ -1918,6 +2040,66 @@ "win32" ] }, + "node_modules/@vis.gl/react-mapbox": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@vis.gl/react-mapbox/-/react-mapbox-8.1.1.tgz", + "integrity": "sha512-KMDTjtWESXxHS4uqWxjsvgQUHvuL3Z6SdKe68o7Nxma2qUfuyH3x4TCkIqGn3FQTrFvZLWvTnSAbGvtm+Kd13A==", + "license": "MIT", + "peerDependencies": { + "mapbox-gl": ">=3.5.0", + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + }, + "peerDependenciesMeta": { + "mapbox-gl": { + "optional": true + } + } + }, + "node_modules/@vis.gl/react-maplibre": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@vis.gl/react-maplibre/-/react-maplibre-8.1.1.tgz", + "integrity": "sha512-iUOfzJAhFAJwEZp1644tQb7LOTFgi5/GzdaztkhzNgFVuoF2Ez7guvwZjQAKB9CN2TlHTgNuYH8UW85kO7cVhw==", + "license": "MIT", + "dependencies": { + "@maplibre/maplibre-gl-style-spec": "^19.2.1" + }, + "peerDependencies": { + "maplibre-gl": ">=4.0.0", + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + }, + "peerDependenciesMeta": { + "maplibre-gl": { + "optional": true + } + } + }, + "node_modules/@vis.gl/react-maplibre/node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "19.3.3", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-19.3.3.tgz", + "integrity": "sha512-cOZZOVhDSulgK0meTsTkmNXb1ahVvmTmWmfx9gRBwc6hq98wS9JP35ESIoNq3xqEan+UN+gn8187Z6E4NKhLsw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^3.0.0", + "minimist": "^1.2.8", + "rw": "^1.3.3", + "sort-object": "^3.0.3" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@vis.gl/react-maplibre/node_modules/json-stringify-pretty-compact": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-3.0.0.tgz", + "integrity": "sha512-Rc2suX5meI0S3bfdZuA7JMFBGkJ875ApfVyq2WHELjBiiG22My/l7/8zPpH/CfFVQHuVLd8NLR0nv6vi0BYYKA==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", @@ -2010,6 +2192,15 @@ "node": ">= 0.4" } }, + "node_modules/arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -2148,6 +2339,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assign-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", + "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -2316,6 +2516,25 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bytewise": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bytewise/-/bytewise-1.1.0.tgz", + "integrity": "sha512-rHuuseJ9iQ0na6UDhnrRVDh8YnWVlU6xM3VH6q/+yHDeUH2zIhUzP+2/h3LIrhLDBtTqzWpE3p3tP/boefskKQ==", + "license": "MIT", + "dependencies": { + "bytewise-core": "^1.2.2", + "typewise": "^1.0.3" + } + }, + "node_modules/bytewise-core": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/bytewise-core/-/bytewise-core-1.2.3.tgz", + "integrity": "sha512-nZD//kc78OOxeYtRlVk8/zXqTB4gf/nlguL1ggWA8FuchMyOxcyHR4QPQZMUmA7czC+YnaBrPUCubqAWe50DaA==", + "license": "MIT", + "dependencies": { + "typewise-core": "^1.2" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -2712,6 +2931,12 @@ "node": ">= 0.4" } }, + "node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, "node_modules/effector": { "version": "23.4.4", "resolved": "https://registry.npmjs.org/effector/-/effector-23.4.4.tgz", @@ -3535,6 +3760,18 @@ "node": ">=0.10.0" } }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3846,6 +4083,21 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/get-value": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", + "integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -4270,6 +4522,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -4382,6 +4643,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -4541,6 +4814,15 @@ "dev": true, "license": "ISC" }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -4604,6 +4886,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -4633,6 +4921,12 @@ "node": ">=4.0" } }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4982,6 +5276,40 @@ "yallist": "^3.0.2" } }, + "node_modules/maplibre-gl": { + "version": "5.24.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz", + "integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.1.0", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/geojson-vt": "^6.1.0", + "@maplibre/maplibre-gl-style-spec": "^24.8.1", + "@maplibre/mlt": "^1.1.8", + "@maplibre/vt-pbf": "^4.3.0", + "@types/geojson": "^7946.0.16", + "earcut": "^3.0.2", + "gl-matrix": "^3.4.4", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^4.0.1", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5059,7 +5387,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5071,6 +5398,12 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -5386,6 +5719,18 @@ "node": ">=8" } }, + "node_modules/pbf": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", + "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5444,6 +5789,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -5500,6 +5851,12 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -5519,6 +5876,12 @@ "node": ">=6" } }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, "node_modules/react": { "version": "19.2.5", "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", @@ -5562,6 +5925,30 @@ "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", "license": "MIT" }, + "node_modules/react-map-gl": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/react-map-gl/-/react-map-gl-8.1.1.tgz", + "integrity": "sha512-aSqFAFoxvY7wxbGI93Dz0E41171mkAb3GcNbnkFIotmu88OFw495os6mIDZSi7irYNT/PZEIOEHUxhun4ToGuQ==", + "license": "MIT", + "dependencies": { + "@vis.gl/react-mapbox": "8.1.1", + "@vis.gl/react-maplibre": "8.1.1" + }, + "peerDependencies": { + "mapbox-gl": ">=1.13.0", + "maplibre-gl": ">=1.13.0", + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + }, + "peerDependenciesMeta": { + "mapbox-gl": { + "optional": true + }, + "maplibre-gl": { + "optional": true + } + } + }, "node_modules/react-router": { "version": "7.14.2", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz", @@ -5700,6 +6087,15 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/rolldown": { "version": "1.0.0-rc.17", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", @@ -5741,6 +6137,12 @@ "dev": true, "license": "MIT" }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, "node_modules/safe-array-concat": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", @@ -5867,6 +6269,21 @@ "node": ">= 0.4" } }, + "node_modules/set-value": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", + "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.3", + "split-string": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5966,6 +6383,41 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sort-asc": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/sort-asc/-/sort-asc-0.2.0.tgz", + "integrity": "sha512-umMGhjPeHAI6YjABoSTrFp2zaBtXBej1a0yKkuMUyjjqu6FJsTF+JYwCswWDg+zJfk/5npWUUbd33HH/WLzpaA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sort-desc": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/sort-desc/-/sort-desc-0.2.0.tgz", + "integrity": "sha512-NqZqyvL4VPW+RAxxXnB8gvE1kyikh8+pR+T+CXLksVRN9eiQqkQlPwqWYU0mF9Jm7UnctShlxLyAt1CaBOTL1w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sort-object": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sort-object/-/sort-object-3.0.3.tgz", + "integrity": "sha512-nK7WOY8jik6zaG9CRwZTaD5O7ETWDLZYMM12pqY8htll+7dYeqGfEUPcUBHOpSJg2vJOrvFIY2Dl5cX2ih1hAQ==", + "license": "MIT", + "dependencies": { + "bytewise": "^1.1.0", + "get-value": "^2.0.2", + "is-extendable": "^0.1.1", + "sort-asc": "^0.2.0", + "sort-desc": "^0.2.0", + "union-value": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -5985,6 +6437,43 @@ "node": ">=0.10.0" } }, + "node_modules/split-string": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", + "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split-string/node_modules/extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", + "license": "MIT", + "dependencies": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split-string/node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/stable-hash-x": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", @@ -6128,6 +6617,15 @@ "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", "license": "MIT" }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -6205,6 +6703,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6368,6 +6872,21 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/typewise": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typewise/-/typewise-1.0.3.tgz", + "integrity": "sha512-aXofE06xGhaQSPzt8hlTY+/YWQhm9P0jYUp1f2XtmW/3Bk0qzXcyFWAtPoo2uTGQj1ZwbDuSyuxicq+aDo8lCQ==", + "license": "MIT", + "dependencies": { + "typewise-core": "^1.2.0" + } + }, + "node_modules/typewise-core": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/typewise-core/-/typewise-core-1.2.0.tgz", + "integrity": "sha512-2SCC/WLzj2SbUwzFOzqMCkz5amXLlxtJqDKTICqg30x+2DZxcfZN2MvQZmGfXWKNWaKK9pBPsvkcwv8bF/gxKg==", + "license": "MIT" + }, "node_modules/uglify-js": { "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", @@ -6408,6 +6927,21 @@ "dev": true, "license": "MIT" }, + "node_modules/union-value": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", + "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", + "license": "MIT", + "dependencies": { + "arr-union": "^3.1.0", + "get-value": "^2.0.6", + "is-extendable": "^0.1.1", + "set-value": "^2.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/unrs-resolver": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", diff --git a/client/package.json b/client/package.json index 6669f2d..6a1ebd1 100644 --- a/client/package.json +++ b/client/package.json @@ -21,9 +21,11 @@ "axios": "^1.15.2", "effector": "^23.4.4", "effector-react": "^23.3.0", + "maplibre-gl": "^5.24.0", "react": "^19.2.5", "react-dom": "^19.2.5", "react-hook-form": "^7.74.0", + "react-map-gl": "^8.1.1", "react-router-dom": "^7.14.2", "swiper": "^12.1.3" }, diff --git a/client/src/app/App.tsx b/client/src/app/App.tsx index 2dc97e6..979859e 100644 --- a/client/src/app/App.tsx +++ b/client/src/app/App.tsx @@ -5,7 +5,7 @@ import { AdminPage } from '@/pages/admin' import { AdminUsersPage } from '@/pages/admin-users' import { AuthPage } from '@/pages/auth' import { HomePage } from '@/pages/home' -import { MePage } from '@/pages/me/ui/MePage' +import { MeLayoutPage } from '@/pages/me' import { ProductPage } from '@/pages/product' export function App() { @@ -18,7 +18,7 @@ export function App() { } /> } /> } /> - } /> + } /> } /> } /> diff --git a/client/src/entities/user/api/address-api.ts b/client/src/entities/user/api/address-api.ts new file mode 100644 index 0000000..d79656b --- /dev/null +++ b/client/src/entities/user/api/address-api.ts @@ -0,0 +1,49 @@ +import type { ShippingAddress } from '@/entities/user/model/types' +import { apiClient } from '@/shared/api/client' + +export type AddressesListResponse = { items: ShippingAddress[] } + +export async function fetchMyAddresses(): Promise { + const { data } = await apiClient.get('me/addresses') + return data +} + +export async function createMyAddress(body: { + label?: string | null + recipientName: string + recipientPhone: string + addressLine: string + comment?: string | null + lat: number + lng: number + isDefault?: boolean +}): Promise<{ item: ShippingAddress }> { + const { data } = await apiClient.post<{ item: ShippingAddress }>('me/addresses', body) + return data +} + +export async function updateMyAddress( + id: string, + body: Partial<{ + label: string | null + recipientName: string + recipientPhone: string + addressLine: string + comment: string | null + lat: number + lng: number + isDefault: boolean + }>, +): Promise<{ item: ShippingAddress }> { + const { data } = await apiClient.patch<{ item: ShippingAddress }>(`me/addresses/${id}`, body) + return data +} + +export async function deleteMyAddress(id: string): Promise { + await apiClient.delete(`me/addresses/${id}`) +} + +export async function setMyAddressDefault(id: string): Promise<{ item: ShippingAddress }> { + const { data } = await apiClient.post<{ item: ShippingAddress }>(`me/addresses/${id}/default`) + return data +} diff --git a/client/src/entities/user/model/types.ts b/client/src/entities/user/model/types.ts index b538c0f..a90437f 100644 --- a/client/src/entities/user/model/types.ts +++ b/client/src/entities/user/model/types.ts @@ -6,3 +6,17 @@ export type AdminUser = { createdAt: string updatedAt: string } + +export type ShippingAddress = { + id: string + label: string | null + recipientName: string + recipientPhone: string + addressLine: string + comment: string | null + lat: number + lng: number + isDefault: boolean + createdAt: string + updatedAt: string +} diff --git a/client/src/features/address-map-picker/ui/AddressMapPicker.tsx b/client/src/features/address-map-picker/ui/AddressMapPicker.tsx new file mode 100644 index 0000000..7249b67 --- /dev/null +++ b/client/src/features/address-map-picker/ui/AddressMapPicker.tsx @@ -0,0 +1,231 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import CircularProgress from '@mui/material/CircularProgress' +import List from '@mui/material/List' +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 * as maplibregl from 'maplibre-gl' +import Map, { Marker } from 'react-map-gl/maplibre' +import type { MapMouseEvent } from 'react-map-gl/maplibre' + +type NominatimItem = { display_name: string; lat: string; lon: string } + +async function reverseGeocode(pos: { lat: number; lng: number }): Promise { + const url = new URL('https://nominatim.openstreetmap.org/reverse') + url.searchParams.set('format', 'jsonv2') + url.searchParams.set('lat', String(pos.lat)) + url.searchParams.set('lon', String(pos.lng)) + url.searchParams.set('accept-language', 'ru') + const res = await fetch(url.toString(), { headers: { 'User-Agent': 'craftshop-demo' } }) + if (!res.ok) return null + const data = (await res.json()) as { display_name?: string } + return data.display_name ? String(data.display_name) : null +} + +type LatLng = { lat: number; lng: number } + +async function searchPlaces(q: string, signal?: AbortSignal): Promise { + const url = new URL('https://nominatim.openstreetmap.org/search') + url.searchParams.set('format', 'jsonv2') + url.searchParams.set('q', q) + url.searchParams.set('accept-language', 'ru') + url.searchParams.set('limit', '5') + const res = await fetch(url.toString(), { + headers: { 'User-Agent': 'craftshop-demo' }, + signal, + }) + if (!res.ok) return [] + const data = (await res.json()) as NominatimItem[] + return Array.isArray(data) ? data : [] +} + +export function AddressMapPicker(props: { + value: { lat: number; lng: number } | null + onChange: (v: { lat: number; lng: number; addressLine?: string | null }) => void +}) { + const { value, onChange } = props + const [q, setQ] = useState('') + const [searching, setSearching] = useState(false) + const [results, setResults] = useState([]) + const [hint, setHint] = useState(null) + const abortRef = useRef(null) + const lastQueryRef = useRef('') + const lastRequestAtRef = useRef(0) + + const qTrimmed = q.trim() + const visibleResults = qTrimmed.length >= 3 ? results : [] + + const center = useMemo(() => { + if (value) return { lat: value.lat, lng: value.lng } + return { lat: 55.751244, lng: 37.618423 } // Москва (fallback) + }, [value]) + + const pick = async (pos: LatLng) => { + setHint(null) + onChange({ lat: pos.lat, lng: pos.lng }) + try { + const addr = await reverseGeocode(pos) + if (addr) { + setHint(addr) + onChange({ lat: pos.lat, lng: pos.lng, addressLine: addr }) + } + } catch { + // ignore + } + } + + useEffect(() => { + const s = qTrimmed + if (s.length < 3) { + return + } + + const t = window.setTimeout(async () => { + // throttle: не чаще 1 запроса в 900ms + const now = Date.now() + if (now - lastRequestAtRef.current < 900) return + if (s === lastQueryRef.current) return + + lastQueryRef.current = s + lastRequestAtRef.current = now + + abortRef.current?.abort() + const ac = new AbortController() + abortRef.current = ac + + setSearching(true) + try { + setResults(await searchPlaces(s, ac.signal)) + } catch (e) { + if ((e as { name?: string })?.name !== 'AbortError') { + setResults([]) + } + } finally { + setSearching(false) + } + }, 450) + + return () => { + window.clearTimeout(t) + } + }, [qTrimmed]) + + return ( + + Выбор на карте + + + setQ(e.target.value)} fullWidth /> + + + + {visibleResults.length > 0 && ( + + {visibleResults.map((r) => ( + { + const lat = Number(r.lat) + const lng = Number(r.lon) + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return + void pick({ lat, lng }) + }} + > + + + ))} + + )} + + + { + const { lng, lat } = e.lngLat + void pick({ lat, lng }) + }} + > + {value && ( + { + const { lng, lat } = e.lngLat + void pick({ lat, lng }) + }} + anchor="bottom" + > + + + )} + + + + + {hint && ( + + Подсказка адреса: {hint} + + )} + + + ) +} diff --git a/client/src/main.tsx b/client/src/main.tsx index 7e48bfd..8802b4e 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -2,6 +2,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { App } from '@/app/App' import '@/app/styles/global.css' +import 'maplibre-gl/dist/maplibre-gl.css' import { readStoredToken, tokenSet } from '@/shared/model/auth' tokenSet(readStoredToken()) diff --git a/client/src/pages/auth/ui/AuthPage.tsx b/client/src/pages/auth/ui/AuthPage.tsx index 568efe7..05c4f07 100644 --- a/client/src/pages/auth/ui/AuthPage.tsx +++ b/client/src/pages/auth/ui/AuthPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import Alert from '@mui/material/Alert' import Box from '@mui/material/Box' import Button from '@mui/material/Button' @@ -8,10 +8,12 @@ import TextField from '@mui/material/TextField' import Typography from '@mui/material/Typography' import { useMutation } from '@tanstack/react-query' import { useForm } from 'react-hook-form' +import { useUnit } from 'effector-react' +import { useNavigate } from 'react-router-dom' import { apiClient } from '@/shared/api/client' -import { tokenSet } from '@/shared/model/auth' +import { $user, tokenSet } from '@/shared/model/auth' -type AuthResponse = { token: string; user: { id: string; email: string } } +type AuthResponse = { token: string; user: { id: string; email: string; name?: string | null; phone?: string | null } } function getApiErrorMessage(err: unknown): string | null { if (!err || typeof err !== 'object') return null @@ -24,6 +26,8 @@ function getApiErrorMessage(err: unknown): string | null { export function AuthPage() { const [message, setMessage] = useState(null) + const navigate = useNavigate() + const user = useUnit($user) const { register, watch } = useForm<{ email: string code: string @@ -37,6 +41,10 @@ export function AuthPage() { const code = watch('code') const password = watch('password') + useEffect(() => { + if (user) navigate('/', { replace: true }) + }, [navigate, user]) + const requestCode = useMutation({ mutationFn: async () => { await apiClient.post('auth/request-code', { email }) @@ -49,6 +57,7 @@ export function AuthPage() { const { data } = await apiClient.post('auth/verify-code', { email, code }) tokenSet(data.token) setMessage(`Вход выполнен: ${data.user.email}`) + navigate('/', { replace: true }) }, }) @@ -57,6 +66,7 @@ export function AuthPage() { const { data } = await apiClient.post('auth/register', { email, password }) tokenSet(data.token) setMessage(`Регистрация выполнена: ${data.user.email}`) + navigate('/', { replace: true }) }, }) @@ -65,6 +75,7 @@ export function AuthPage() { const { data } = await apiClient.post('auth/login', { email, password }) tokenSet(data.token) setMessage(`Вход выполнен: ${data.user.email}`) + navigate('/', { replace: true }) }, }) diff --git a/client/src/pages/home/ui/HomePage.tsx b/client/src/pages/home/ui/HomePage.tsx index 565c3b4..56e2d8b 100644 --- a/client/src/pages/home/ui/HomePage.tsx +++ b/client/src/pages/home/ui/HomePage.tsx @@ -3,11 +3,13 @@ import Alert from '@mui/material/Alert' import Box from '@mui/material/Box' import Button from '@mui/material/Button' import Collapse from '@mui/material/Collapse' +import Divider from '@mui/material/Divider' import FormControl from '@mui/material/FormControl' import Grid from '@mui/material/Grid' import InputLabel from '@mui/material/InputLabel' import MenuItem from '@mui/material/MenuItem' import Pagination from '@mui/material/Pagination' +import Paper from '@mui/material/Paper' import Select from '@mui/material/Select' import type { SelectChangeEvent } from '@mui/material/Select' import Skeleton from '@mui/material/Skeleton' @@ -235,26 +237,58 @@ export function HomePage() { ))} - - - - Масштаб карточек - - { - if (v === 70 || v === 90 || v === 110 || v === 130) setCardScale(v) - }} - > - S - M - L - XL - - + + + + + + Масштаб карточек + + Выберите размер карточек в каталоге + + + + { + if (v === 70 || v === 90 || v === 110 || v === 130) setCardScale(v) + }} + sx={{ + alignSelf: { xs: 'flex-start', sm: 'auto' }, + '& .MuiToggleButton-root': { + px: 2, + fontWeight: 700, + letterSpacing: 0.2, + textTransform: 'none', + }, + '& .MuiToggleButton-root.Mui-selected': { + bgcolor: 'primary.main', + color: 'primary.contrastText', + '&:hover': { bgcolor: 'primary.dark' }, + }, + }} + > + S + M + L + XL + + diff --git a/client/src/pages/me/index.ts b/client/src/pages/me/index.ts index 2823959..9191b2c 100644 --- a/client/src/pages/me/index.ts +++ b/client/src/pages/me/index.ts @@ -1 +1 @@ -export { MePage } from './ui/MePage' +export { MeLayoutPage } from './ui/MeLayoutPage' diff --git a/client/src/pages/me/ui/MeLayoutPage.tsx b/client/src/pages/me/ui/MeLayoutPage.tsx new file mode 100644 index 0000000..2b8ae20 --- /dev/null +++ b/client/src/pages/me/ui/MeLayoutPage.tsx @@ -0,0 +1,136 @@ +import type { ReactNode } from 'react' +import { useMemo, useState } from 'react' +import ChatOutlinedIcon from '@mui/icons-material/ChatOutlined' +import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined' +import MenuOutlinedIcon from '@mui/icons-material/MenuOutlined' +import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined' +import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined' +import Alert from '@mui/material/Alert' +import Box from '@mui/material/Box' +import Divider from '@mui/material/Divider' +import Drawer from '@mui/material/Drawer' +import IconButton from '@mui/material/IconButton' +import List from '@mui/material/List' +import ListItemButton from '@mui/material/ListItemButton' +import ListItemIcon from '@mui/material/ListItemIcon' +import ListItemText from '@mui/material/ListItemText' +import Stack from '@mui/material/Stack' +import { useTheme } from '@mui/material/styles' +import Typography from '@mui/material/Typography' +import useMediaQuery from '@mui/material/useMediaQuery' +import { useUnit } from 'effector-react' +import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom' +import { AddressesPage } from '@/pages/me/ui/sections/AddressesPage' +import { MessagesPage } from '@/pages/me/ui/sections/MessagesPage' +import { OrdersPage } from '@/pages/me/ui/sections/OrdersPage' +import { SettingsPage } from '@/pages/me/ui/sections/SettingsPage' +import { $user } from '@/shared/model/auth' + +type NavItem = { + to: string + label: string + icon: ReactNode +} + +export function MeLayoutPage() { + const user = useUnit($user) + const navigate = useNavigate() + const location = useLocation() + const theme = useTheme() + const isMobile = useMediaQuery(theme.breakpoints.down('md')) + const [mobileOpen, setMobileOpen] = useState(false) + + const navItems: NavItem[] = useMemo( + () => [ + { to: '/me/orders', label: 'Заказы', icon: }, + { to: '/me/messages', label: 'Сообщения', icon: }, + { to: '/me/settings', label: 'Настройки', icon: }, + { to: '/me/addresses', label: 'Адреса доставки', icon: }, + ], + [], + ) + + if (!user) { + return Нужно войти. Перейдите на страницу «Вход». + } + + const activeTo = + navItems.find((x) => location.pathname === x.to)?.to ?? + navItems.find((x) => location.pathname.startsWith(`${x.to}/`))?.to ?? + null + + const nav = ( + + + + Кабинет + + + {user.name?.trim() || user.email} + + + + + {navItems.map((i) => ( + { + navigate(i.to) + setMobileOpen(false) + }} + > + {i.icon} + + + ))} + + + ) + + return ( + + {isMobile ? ( + <> + + setMobileOpen(true)} aria-label="Открыть меню профиля"> + + + + Профиль + + + setMobileOpen(false)} ModalProps={{ keepMounted: true }}> + {nav} + + + ) : ( + + {nav} + + )} + + + + } /> + } /> + } /> + } /> + } /> + } /> + + + + ) +} diff --git a/client/src/pages/me/ui/sections/AddressesPage.tsx b/client/src/pages/me/ui/sections/AddressesPage.tsx new file mode 100644 index 0000000..eabb537 --- /dev/null +++ b/client/src/pages/me/ui/sections/AddressesPage.tsx @@ -0,0 +1,318 @@ +import { useState } from 'react' +import Alert from '@mui/material/Alert' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import Chip from '@mui/material/Chip' +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 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 { + createMyAddress, + deleteMyAddress, + fetchMyAddresses, + setMyAddressDefault, + updateMyAddress, +} from '@/entities/user/api/address-api' +import type { ShippingAddress } from '@/entities/user/model/types' +import { AddressMapPicker } from '@/features/address-map-picker/ui/AddressMapPicker' + +export function AddressesPage() { + const queryClient = useQueryClient() + const [open, setOpen] = useState(false) + const [editing, setEditing] = useState(null) + + const addressesQuery = useQuery({ + queryKey: ['me', 'addresses'], + queryFn: fetchMyAddresses, + }) + + const form = useForm<{ + label: string + recipientName: string + recipientPhone: string + addressLine: string + comment: string + lat: number | null + lng: number | null + isDefault: boolean + }>({ + defaultValues: { + label: '', + recipientName: '', + recipientPhone: '', + addressLine: '', + comment: '', + lat: null, + lng: null, + isDefault: false, + }, + mode: 'onChange', + }) + + const createMut = useMutation({ + mutationFn: async () => { + const v = form.getValues() + if (v.lat === null || v.lng === null) throw new Error('Выберите точку на карте') + await createMyAddress({ + label: v.label.trim() || null, + recipientName: v.recipientName.trim(), + recipientPhone: v.recipientPhone.trim(), + addressLine: v.addressLine.trim(), + comment: v.comment.trim() || null, + lat: v.lat, + lng: v.lng, + isDefault: v.isDefault, + }) + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['me', 'addresses'] }) + setOpen(false) + }, + }) + + const updateMut = useMutation({ + mutationFn: async () => { + const v = form.getValues() + if (v.lat === null || v.lng === null) throw new Error('Выберите точку на карте') + await updateMyAddress(editing!.id, { + label: v.label.trim() || null, + recipientName: v.recipientName.trim(), + recipientPhone: v.recipientPhone.trim(), + addressLine: v.addressLine.trim(), + comment: v.comment.trim() || null, + lat: v.lat, + lng: v.lng, + isDefault: v.isDefault, + }) + }, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['me', 'addresses'] }) + setOpen(false) + }, + }) + + const deleteMut = useMutation({ + mutationFn: (id: string) => deleteMyAddress(id), + onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['me', 'addresses'] }), + }) + + const defaultMut = useMutation({ + mutationFn: (id: string) => setMyAddressDefault(id), + onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['me', 'addresses'] }), + }) + + const error = createMut.error ?? updateMut.error ?? deleteMut.error ?? defaultMut.error + + const items = addressesQuery.data?.items ?? [] + + const openCreate = () => { + setEditing(null) + form.reset({ + label: '', + recipientName: '', + recipientPhone: '', + addressLine: '', + comment: '', + lat: null, + lng: null, + isDefault: items.length === 0, + }) + setOpen(true) + } + + const openEdit = (a: ShippingAddress) => { + setEditing(a) + form.reset({ + label: a.label ?? '', + recipientName: a.recipientName, + recipientPhone: a.recipientPhone, + addressLine: a.addressLine, + comment: a.comment ?? '', + lat: a.lat, + lng: a.lng, + isDefault: a.isDefault, + }) + setOpen(true) + } + + return ( + + + Адреса доставки + + + + Можно добавить несколько адресов. Для каждого адреса задаются ФИО и телефон получателя отдельно. + + + + + + + {addressesQuery.isError && ( + + Не удалось загрузить адреса. Проверьте, что вы вошли в аккаунт и сервер запущен. + + )} + {error && ( + + {(error as Error).message} + + )} + + + {items.map((a) => ( + + + {a.label?.trim() ? a.label : 'Адрес'} + {a.isDefault && } + + + {a.addressLine} + + Получатель: {a.recipientName} · {a.recipientPhone} + + {a.comment && ( + + Комментарий: {a.comment} + + )} + + + {!a.isDefault && ( + + )} + + + + + ))} + + {addressesQuery.isSuccess && items.length === 0 && ( + Адресов пока нет — добавьте первый адрес доставки. + )} + + + setOpen(false)} fullWidth maxWidth="md"> + {editing ? 'Редактировать адрес' : 'Новый адрес'} + + + } + /> + + } + /> + } + /> + + + } + /> + } + /> + + ( + ( + { + latField.onChange(v.lat) + lngField.onChange(v.lng) + if (v.addressLine) form.setValue('addressLine', v.addressLine, { shouldDirty: true }) + }} + /> + )} + /> + )} + /> + + ( + field.onChange(v)} />} + label="Адрес по умолчанию" + /> + )} + /> + + + + + + + + + ) +} diff --git a/client/src/pages/me/ui/sections/MessagesPage.tsx b/client/src/pages/me/ui/sections/MessagesPage.tsx new file mode 100644 index 0000000..5e2a69b --- /dev/null +++ b/client/src/pages/me/ui/sections/MessagesPage.tsx @@ -0,0 +1,13 @@ +import Box from '@mui/material/Box' +import Typography from '@mui/material/Typography' + +export function MessagesPage() { + return ( + + + Сообщения + + Скоро здесь появятся сообщения и уведомления. + + ) +} diff --git a/client/src/pages/me/ui/sections/OrdersPage.tsx b/client/src/pages/me/ui/sections/OrdersPage.tsx new file mode 100644 index 0000000..ad86416 --- /dev/null +++ b/client/src/pages/me/ui/sections/OrdersPage.tsx @@ -0,0 +1,13 @@ +import Box from '@mui/material/Box' +import Typography from '@mui/material/Typography' + +export function OrdersPage() { + return ( + + + Заказы + + Скоро здесь появится история заказов. + + ) +} diff --git a/client/src/pages/me/ui/sections/SettingsPage.tsx b/client/src/pages/me/ui/sections/SettingsPage.tsx new file mode 100644 index 0000000..3ddf2c4 --- /dev/null +++ b/client/src/pages/me/ui/sections/SettingsPage.tsx @@ -0,0 +1,184 @@ +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 TextField from '@mui/material/TextField' +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, +} from '@/shared/model/auth' +import type { AxiosError } from 'axios' + +function getApiErrorMessage(error: unknown): string | null { + const e = error as AxiosError<{ error?: string }> + const msg = e?.response?.data?.error + return msg ? String(msg) : 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', + }) + + const profileForm = useForm<{ name: string; phone: string }>({ + defaultValues: { name: user?.name ? String(user.name) : '', phone: user?.phone ? String(user.phone) : '' }, + mode: 'onChange', + }) + + const passwordErrorMsg = getApiErrorMessage(errorPassword) + const emailErrorMsg = getApiErrorMessage(errorEmailReq) ?? getApiErrorMessage(errorEmailVerify) + const profileErrorMsg = getApiErrorMessage(errorProfile) + + if (!user) { + return Нужно войти. Перейдите на страницу «Вход». + } + + return ( + + + Настройки + + + Текущая почта: {user.email} + + + {passwordErrorMsg && ( + + {passwordErrorMsg} + + )} + {emailErrorMsg && ( + + {emailErrorMsg} + + )} + {profileErrorMsg && ( + + {profileErrorMsg} + + )} + + + + + Профиль + + + + + + + + + + + + + Смена почты + + + + + + + + + + + + + + + + Смена пароля + + + + + + + + + + ) +} diff --git a/client/src/shared/model/auth.ts b/client/src/shared/model/auth.ts index 46b1bf2..be0e0dd 100644 --- a/client/src/shared/model/auth.ts +++ b/client/src/shared/model/auth.ts @@ -1,7 +1,7 @@ import { createEffect, createEvent, createStore, sample } from 'effector' import { apiClient } from '@/shared/api/client' -export type AuthUser = { id: string; email: string; name?: string | null } +export type AuthUser = { id: string; email: string; name?: string | null; phone?: string | null } const TOKEN_KEY = 'craftshop_auth_token' @@ -28,7 +28,9 @@ export const verifyEmailChangeFx = createEffect(async (params: { newEmail: strin return data.user }) -export const updateProfileFx = createEffect(async (params: { name: string | null }) => { +export type UpdateProfileParams = { name: string | null; phone?: string | null } + +export const updateProfileFx = createEffect(async (params: UpdateProfileParams) => { const { data } = await apiClient.patch<{ user: AuthUser }>('me/profile', params) return data.user }) diff --git a/server/prisma/migrations/20260429130733_user_phone/migration.sql b/server/prisma/migrations/20260429130733_user_phone/migration.sql new file mode 100644 index 0000000..8ef463c --- /dev/null +++ b/server/prisma/migrations/20260429130733_user_phone/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "phone" TEXT; diff --git a/server/prisma/migrations/20260429130833_shipping_addresses/migration.sql b/server/prisma/migrations/20260429130833_shipping_addresses/migration.sql new file mode 100644 index 0000000..59b5b3d --- /dev/null +++ b/server/prisma/migrations/20260429130833_shipping_addresses/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable +CREATE TABLE "ShippingAddress" ( + "id" TEXT NOT NULL PRIMARY KEY, + "label" TEXT, + "recipientName" TEXT NOT NULL, + "recipientPhone" TEXT NOT NULL, + "addressLine" TEXT NOT NULL, + "comment" TEXT, + "lat" REAL NOT NULL, + "lng" REAL NOT NULL, + "isDefault" BOOLEAN NOT NULL DEFAULT false, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + "userId" TEXT NOT NULL, + CONSTRAINT "ShippingAddress_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "ShippingAddress_userId_isDefault_idx" ON "ShippingAddress"("userId", "isDefault"); + +-- CreateIndex +CREATE INDEX "ShippingAddress_userId_updatedAt_idx" ON "ShippingAddress"("userId", "updatedAt"); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index db4189b..90fc11f 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -56,11 +56,33 @@ model User { id String @id @default(cuid()) email String @unique name String? + phone String? passwordHash String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt codes AuthCode[] + addresses ShippingAddress[] +} + +model ShippingAddress { + id String @id @default(cuid()) + label String? + recipientName String + recipientPhone String + addressLine String + comment String? + lat Float + lng Float + isDefault Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + + @@index([userId, isDefault]) + @@index([userId, updatedAt]) } model AuthCode { diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index 0220410..76adc64 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -27,7 +27,7 @@ export async function registerAuthRoutes(fastify) { }) const token = fastify.jwt.sign({ sub: user.id, email: user.email }) - return { token, user: { id: user.id, email: user.email, name: user.name } } + return { token, user: { id: user.id, email: user.email, name: user.name, phone: user.phone } } }) fastify.post('/api/auth/register', async (request, reply) => { @@ -42,7 +42,7 @@ export async function registerAuthRoutes(fastify) { const passwordHash = await hashPassword(password) const user = await prisma.user.create({ data: { email, passwordHash } }) const token = fastify.jwt.sign({ sub: user.id, email: user.email }) - return reply.code(201).send({ token, user: { id: user.id, email: user.email, name: user.name } }) + return reply.code(201).send({ token, user: { id: user.id, email: user.email, name: user.name, phone: user.phone } }) }) fastify.post('/api/auth/login', async (request, reply) => { @@ -58,7 +58,7 @@ export async function registerAuthRoutes(fastify) { if (!ok) return reply.code(401).send({ error: 'Неверные данные' }) const token = fastify.jwt.sign({ sub: user.id, email: user.email }) - return { token, user: { id: user.id, email: user.email, name: user.name } } + return { token, user: { id: user.id, email: user.email, name: user.name, phone: user.phone } } }) fastify.get( @@ -68,7 +68,7 @@ export async function registerAuthRoutes(fastify) { const userId = request.user.sub const user = await prisma.user.findUnique({ where: { id: userId } }) if (!user) return { user: null } - return { user: { id: user.id, email: user.email, name: user.name } } + return { user: { id: user.id, email: user.email, name: user.name, phone: user.phone } } }, ) @@ -108,7 +108,7 @@ export async function registerAuthRoutes(fastify) { where: { id: userId }, data: { email: newEmail }, }) - return { user: { id: user.id, email: user.email, name: user.name } } + return { user: { id: user.id, email: user.email, name: user.name, phone: user.phone } } }, ) @@ -133,7 +133,7 @@ export async function registerAuthRoutes(fastify) { const passwordHash = await hashPassword(newPassword) const updated = await prisma.user.update({ where: { id: userId }, data: { passwordHash } }) - return { user: { id: updated.id, email: updated.email, name: updated.name } } + return { user: { id: updated.id, email: updated.email, name: updated.name, phone: updated.phone } } }, ) @@ -144,14 +144,215 @@ export async function registerAuthRoutes(fastify) { const userId = request.user.sub const nameRaw = request.body?.name const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim() + const phoneRaw = request.body?.phone + const phone = phoneRaw === null || phoneRaw === undefined ? null : String(phoneRaw).trim() if (name !== null && name.length > 40) return reply.code(400).send({ error: 'Имя/ник максимум 40 символов' }) + if (phone !== null) { + const compact = phone.replace(/[\s()-]/g, '') + if (compact.length > 20) return reply.code(400).send({ error: 'Телефон слишком длинный' }) + if (compact.length && !/^\+?\d{7,20}$/.test(compact)) { + return reply.code(400).send({ error: 'Некорректный телефон' }) + } + } const updated = await prisma.user.update({ where: { id: userId }, - data: { name: name && name.length ? name : null }, + data: { name: name && name.length ? name : null, phone: phone && phone.length ? phone : null }, }) - return { user: { id: updated.id, email: updated.email, name: updated.name } } + return { user: { id: updated.id, email: updated.email, name: updated.name, phone: updated.phone } } + }, + ) + + // ---- Адреса доставки ---- + + function normalizePhoneLite(input) { + const s = String(input || '').trim() + if (!s) return '' + return s.replace(/[\s()-]/g, '') + } + + function validateAddressPayload(body, reply) { + const labelRaw = body?.label + const label = labelRaw === null || labelRaw === undefined ? null : String(labelRaw).trim() + if (label !== null && label.length > 40) return reply.code(400).send({ error: 'Метка адреса максимум 40 символов' }) + + const recipientName = String(body?.recipientName || '').trim() + if (!recipientName) return reply.code(400).send({ error: 'Укажите ФИО получателя' }) + if (recipientName.length > 80) return reply.code(400).send({ error: 'ФИО получателя максимум 80 символов' }) + + const recipientPhone = normalizePhoneLite(body?.recipientPhone) + if (!recipientPhone) return reply.code(400).send({ error: 'Укажите телефон получателя' }) + if (!/^\+?\d{7,20}$/.test(recipientPhone)) return reply.code(400).send({ error: 'Некорректный телефон получателя' }) + + const addressLine = String(body?.addressLine || '').trim() + if (!addressLine) return reply.code(400).send({ error: 'Укажите адрес' }) + if (addressLine.length > 200) return reply.code(400).send({ error: 'Адрес максимум 200 символов' }) + + const commentRaw = body?.comment + const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim() + if (comment !== null && comment.length > 200) return reply.code(400).send({ error: 'Комментарий максимум 200 символов' }) + + const lat = Number(body?.lat) + const lng = Number(body?.lng) + if (!Number.isFinite(lat) || lat < -90 || lat > 90) return reply.code(400).send({ error: 'Некорректная широта' }) + if (!Number.isFinite(lng) || lng < -180 || lng > 180) return reply.code(400).send({ error: 'Некорректная долгота' }) + + return { + label, + recipientName, + recipientPhone, + addressLine, + comment, + lat, + lng, + } + } + + fastify.get( + '/api/me/addresses', + { preHandler: [fastify.authenticate] }, + async (request) => { + const userId = request.user.sub + const items = await prisma.shippingAddress.findMany({ + where: { userId }, + orderBy: [{ isDefault: 'desc' }, { updatedAt: 'desc' }], + }) + return { items } + }, + ) + + fastify.post( + '/api/me/addresses', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + const userId = request.user.sub + const validated = validateAddressPayload(request.body, reply) + if (!validated) return + + const isDefault = Boolean(request.body?.isDefault) + const created = await prisma.$transaction(async (tx) => { + if (isDefault) { + await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } }) + } + return tx.shippingAddress.create({ + data: { + userId, + ...validated, + isDefault, + }, + }) + }) + return reply.code(201).send({ item: created }) + }, + ) + + fastify.patch( + '/api/me/addresses/:id', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + const userId = request.user.sub + const { id } = request.params + const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } }) + if (!existing) return reply.code(404).send({ error: 'Адрес не найден' }) + + const body = request.body ?? {} + const data = {} + + if (body.label !== undefined) { + const labelRaw = body.label + const label = labelRaw === null || labelRaw === undefined ? null : String(labelRaw).trim() + if (label !== null && label.length > 40) return reply.code(400).send({ error: 'Метка адреса максимум 40 символов' }) + data.label = label && label.length ? label : null + } + + if (body.recipientName !== undefined) { + const v = String(body.recipientName || '').trim() + if (!v) return reply.code(400).send({ error: 'Укажите ФИО получателя' }) + if (v.length > 80) return reply.code(400).send({ error: 'ФИО получателя максимум 80 символов' }) + data.recipientName = v + } + + if (body.recipientPhone !== undefined) { + const v = normalizePhoneLite(body.recipientPhone) + if (!v) return reply.code(400).send({ error: 'Укажите телефон получателя' }) + if (!/^\+?\d{7,20}$/.test(v)) return reply.code(400).send({ error: 'Некорректный телефон получателя' }) + data.recipientPhone = v + } + + if (body.addressLine !== undefined) { + const v = String(body.addressLine || '').trim() + if (!v) return reply.code(400).send({ error: 'Укажите адрес' }) + if (v.length > 200) return reply.code(400).send({ error: 'Адрес максимум 200 символов' }) + data.addressLine = v + } + + if (body.comment !== undefined) { + const commentRaw = body.comment + const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim() + if (comment !== null && comment.length > 200) return reply.code(400).send({ error: 'Комментарий максимум 200 символов' }) + data.comment = comment && comment.length ? comment : null + } + + if (body.lat !== undefined) { + const lat = Number(body.lat) + if (!Number.isFinite(lat) || lat < -90 || lat > 90) return reply.code(400).send({ error: 'Некорректная широта' }) + data.lat = lat + } + + if (body.lng !== undefined) { + const lng = Number(body.lng) + if (!Number.isFinite(lng) || lng < -180 || lng > 180) return reply.code(400).send({ error: 'Некорректная долгота' }) + data.lng = lng + } + + const setDefault = body.isDefault === true + const updated = await prisma.$transaction(async (tx) => { + if (setDefault) { + await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } }) + } + return tx.shippingAddress.update({ + where: { id }, + data: { + ...data, + ...(setDefault ? { isDefault: true } : {}), + }, + }) + }) + + return { item: updated } + }, + ) + + fastify.delete( + '/api/me/addresses/:id', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + const userId = request.user.sub + const { id } = request.params + const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } }) + if (!existing) return reply.code(404).send({ error: 'Адрес не найден' }) + + await prisma.shippingAddress.delete({ where: { id } }) + return reply.code(204).send() + }, + ) + + fastify.post( + '/api/me/addresses/:id/default', + { preHandler: [fastify.authenticate] }, + async (request, reply) => { + const userId = request.user.sub + const { id } = request.params + const existing = await prisma.shippingAddress.findFirst({ where: { id, userId } }) + if (!existing) return reply.code(404).send({ error: 'Адрес не найден' }) + + const updated = await prisma.$transaction(async (tx) => { + await tx.shippingAddress.updateMany({ where: { userId, isDefault: true }, data: { isDefault: false } }) + return tx.shippingAddress.update({ where: { id }, data: { isDefault: true } }) + }) + + return { item: updated } }, ) }