From 058fa26e126cf8f6fe2d14f065fc44b860787b37 Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 21 May 2026 13:39:45 +0500 Subject: [PATCH 1/3] test commit --- client/package-lock.json | 223 +++++++++++++++++- client/package.json | 17 ++ .../src/entities/order/api/admin-order-api.ts | 2 +- .../product-review/ui/ProductReviewsList.tsx | 8 +- .../features/user/user-menu/ui/UserMenu.tsx | 14 +- client/src/pages/auth/ui/AuthPage.tsx | 9 +- .../src/pages/me/ui/sections/SettingsPage.tsx | 136 ++++++++++- client/src/shared/lib/avatar-styles.ts | 88 +++++++ client/src/shared/model/auth.ts | 10 +- client/src/shared/ui/UserAvatar.tsx | 30 +++ .../widgets/reviews-block/ui/ReviewsBlock.tsx | 18 +- .../migration.sql | 2 + .../migration.sql | 1 + server/prisma/prisma/dev.db | Bin 315392 -> 364544 bytes server/prisma/schema.prisma | 3 +- server/src/routes/api/admin-orders.js | 2 +- server/src/routes/auth.js | 43 +++- server/src/routes/user-payments.js | 2 +- 18 files changed, 563 insertions(+), 45 deletions(-) create mode 100644 client/src/shared/lib/avatar-styles.ts create mode 100644 client/src/shared/ui/UserAvatar.tsx create mode 100644 server/prisma/migrations/20260521100000_drop_phone_add_avatar_type/migration.sql create mode 100644 server/prisma/migrations/20260521103000_add_avatar_style/migration.sql diff --git a/client/package-lock.json b/client/package-lock.json index 375b76d..fcd74ee 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8,6 +8,23 @@ "name": "client", "version": "0.0.0", "dependencies": { + "@dicebear/adventurer": "^9.4.2", + "@dicebear/avataaars": "^9.4.2", + "@dicebear/big-ears": "^9.4.2", + "@dicebear/big-smile": "^9.4.2", + "@dicebear/bottts": "^9.4.2", + "@dicebear/core": "^9.4.2", + "@dicebear/croodles": "^9.4.2", + "@dicebear/fun-emoji": "^9.4.2", + "@dicebear/identicon": "^9.4.2", + "@dicebear/initials": "^9.4.2", + "@dicebear/lorelei": "^9.4.2", + "@dicebear/micah": "^9.4.2", + "@dicebear/notionists": "^9.4.2", + "@dicebear/pixel-art": "^9.4.2", + "@dicebear/rings": "^9.4.2", + "@dicebear/shapes": "^9.4.2", + "@dicebear/thumbs": "^9.4.2", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@mui/icons-material": "^9.0.0", @@ -465,6 +482,211 @@ "node": ">=18" } }, + "node_modules/@dicebear/adventurer": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/adventurer/-/adventurer-9.4.2.tgz", + "integrity": "sha512-jqYp834ZmGDA9HBBDQAdgF1O2UTCwHF4vVrktXWa2Dppp1JczPL5HnVOWsjtrLmXNn61Wd6OLmBb2e6rhzp3ig==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/avataaars": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/avataaars/-/avataaars-9.4.2.tgz", + "integrity": "sha512-3x9jKFkOkFSPmpTbt9xvhiU2E1GX7beCSsX0tXRUShj8x6+5Ks9yBRT1VlkySbnXrZ/GglADGg7vJ/D2uIx1Yw==", + "license": "See LICENSE file", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/big-ears": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/big-ears/-/big-ears-9.4.2.tgz", + "integrity": "sha512-mNfz3ppNA7UBq0IO3nXCiV5pFPG7c1DfzRB0foNU2Wo1XXT8FIcSY2BvDlYqorZTOUOz7dHb0vx06hqvG0HP5w==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/big-smile": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/big-smile/-/big-smile-9.4.2.tgz", + "integrity": "sha512-hmT5i7rcPPhStjZyg28pbIhdTnnMBzK3RObI0vKCpY30EFrzaPkkdDL6Ck5fAFBdvDIW1EpOJkenyR0XPmhgbQ==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/bottts": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/bottts/-/bottts-9.4.2.tgz", + "integrity": "sha512-tsx+dII7EFUCVA8URj66G1GqORCCVduCAx4dY2prEY2IeFianVpkntXuFsWZ9BBGx1NZFndvDith5oTwKMQPbQ==", + "license": "See LICENSE file", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/core": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/core/-/core-9.4.2.tgz", + "integrity": "sha512-MF0042+Z3s8PGZKZLySfhft28bUa3B1iq0e5NSjCvY8gfMi5aIH/iRJGRJa1N9Jz1BNkxYb4yvJ/N9KO8Z6Y+w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@dicebear/croodles": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/croodles/-/croodles-9.4.2.tgz", + "integrity": "sha512-6VoO0JviIf7dKKMBTL/SMXxWhnXHaZuzufX90G0nXxS77ELG1YkGNMaZzawizN4C09Gbya2gJkozqrWiJN/aGw==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/fun-emoji": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/fun-emoji/-/fun-emoji-9.4.2.tgz", + "integrity": "sha512-kqB6LPkdYCdEU/mwbyz34xLzoNUKL6ARcoo3fr5ASq9D6ZE07qIKybC3xv5+CPz7VmspJ1Q3c/VVWVMDRP7Twg==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/identicon": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/identicon/-/identicon-9.4.2.tgz", + "integrity": "sha512-JVDSmZsv11mSWqwAktK5x9Bslht2xY3TFUn8xzu6slAYe1Z7hEXZ76eb+UJ6F4qEzdwZ7xPWzAS6Nb0Y3A0pww==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/initials": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/initials/-/initials-9.4.2.tgz", + "integrity": "sha512-yePuIUasmwtl9IrtB6rEzE/zb5fImKP/neW0CdcTC2MwLgMuP1GLHEGRgg1zI8exIh+PMv1YdLGyyUuRTE2Qpw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/lorelei": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/lorelei/-/lorelei-9.4.2.tgz", + "integrity": "sha512-YMv6vnriW6VLFDsreKuOnUFFno6SRe7+7X7R7zPY0rZ+MaHX9V3jcioIG+1PSjIHEDfOLUHpr5vd1JBWv8y7UA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/micah": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/micah/-/micah-9.4.2.tgz", + "integrity": "sha512-e4D3W/OlChSsLo7Llwsy0J18vk0azJqF/uFoY+EKACCNHBc1HGNsqVvu2CTf+OWOA8wTyAK6UkjBN5p01r7D+g==", + "license": "(MIT AND CC-BY-4.0)", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/notionists": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/notionists/-/notionists-9.4.2.tgz", + "integrity": "sha512-ZCySq+nxcD/x4xyYgytcj2N9uY3gxrL+qpnmOdp2BdA221KacVrxlsUPpIgEMqxS2rMmBQXfxg129Pzn4ycIpA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/pixel-art": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/pixel-art/-/pixel-art-9.4.2.tgz", + "integrity": "sha512-peHf7oKICDgBZ8dUyj+txPnS7VZEWgvKE+xW4mNQqBt6dYZIjmva2shOVHn0b1JU+FDxMx3uIkWVixKdUq4WGg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/rings": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/rings/-/rings-9.4.2.tgz", + "integrity": "sha512-Pc3ymWrRDQPJFNrbbLt7RJrzGvUuuxUiDkrfLhoVE+B6mZWEL1PC78DPbS1yUWYLErJOpJuM2GSwXmTbVjWf+g==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/shapes": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/shapes/-/shapes-9.4.2.tgz", + "integrity": "sha512-AFL6jAaiLztvcqyq+ds+lWZu6Vbp3PlGWhJeJRm842jxtiluJpl6r4f6nUXP2fdMz7MNpDzXfLooQK9E04NbUQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/thumbs": { + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/@dicebear/thumbs/-/thumbs-9.4.2.tgz", + "integrity": "sha512-ccWvDBqbkWS5uzHbsg5L6uML6vBfX7jT3J3jHCQksvz8haHItxTK02w+6e1UavZUsvza4lG5X/XY3eji3siJ4Q==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -3050,7 +3272,6 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, "license": "MIT" }, "node_modules/@types/node": { diff --git a/client/package.json b/client/package.json index 54fb1cc..d031569 100644 --- a/client/package.json +++ b/client/package.json @@ -15,6 +15,23 @@ "test:watch": "vitest" }, "dependencies": { + "@dicebear/adventurer": "^9.4.2", + "@dicebear/avataaars": "^9.4.2", + "@dicebear/big-ears": "^9.4.2", + "@dicebear/big-smile": "^9.4.2", + "@dicebear/bottts": "^9.4.2", + "@dicebear/core": "^9.4.2", + "@dicebear/croodles": "^9.4.2", + "@dicebear/fun-emoji": "^9.4.2", + "@dicebear/identicon": "^9.4.2", + "@dicebear/initials": "^9.4.2", + "@dicebear/lorelei": "^9.4.2", + "@dicebear/micah": "^9.4.2", + "@dicebear/notionists": "^9.4.2", + "@dicebear/pixel-art": "^9.4.2", + "@dicebear/rings": "^9.4.2", + "@dicebear/shapes": "^9.4.2", + "@dicebear/thumbs": "^9.4.2", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@mui/icons-material": "^9.0.0", diff --git a/client/src/entities/order/api/admin-order-api.ts b/client/src/entities/order/api/admin-order-api.ts index 43df1d6..2c38e16 100644 --- a/client/src/entities/order/api/admin-order-api.ts +++ b/client/src/entities/order/api/admin-order-api.ts @@ -37,7 +37,7 @@ export type AdminOrderDetailResponse = { comment: string | null createdAt: string updatedAt: string - user: { id: string; email: string; displayName: string | null; phone: string | null } + user: { id: string; email: string; displayName: string | null } items: Array<{ id: string productId: string diff --git a/client/src/features/product-review/ui/ProductReviewsList.tsx b/client/src/features/product-review/ui/ProductReviewsList.tsx index fd922bb..8a0ac36 100644 --- a/client/src/features/product-review/ui/ProductReviewsList.tsx +++ b/client/src/features/product-review/ui/ProductReviewsList.tsx @@ -11,14 +11,18 @@ import type { PublicProductReviewItem } from '@/entities/review/api/reviews-api' import { reviewsCountRu } from '@/shared/lib/reviews-count-ru' import { OptimizedImage } from '@/shared/ui/OptimizedImage' import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent' +import { UserAvatar } from '@/shared/ui/UserAvatar' function ReviewItem({ rv }: { rv: PublicProductReviewItem }) { const body = typeof rv.text === 'string' && rv.text.trim() ? rv.text.trim() : null return ( - - {rv.authorDisplay} + + + + {rv.authorDisplay} + {new Date(rv.createdAt).toLocaleString('ru-RU')} diff --git a/client/src/features/user/user-menu/ui/UserMenu.tsx b/client/src/features/user/user-menu/ui/UserMenu.tsx index ed93c19..fe72a6a 100644 --- a/client/src/features/user/user-menu/ui/UserMenu.tsx +++ b/client/src/features/user/user-menu/ui/UserMenu.tsx @@ -4,8 +4,8 @@ import IconButton from '@mui/material/IconButton' import ListItemText from '@mui/material/ListItemText' import Menu from '@mui/material/Menu' import MenuItem from '@mui/material/MenuItem' -import { User } from 'lucide-react' import type { AuthUser } from '@/shared/model/auth' +import { UserAvatar } from '@/shared/ui/UserAvatar' type Props = { user: AuthUser | null @@ -40,7 +40,17 @@ export function UserMenu({ user, onNavigate, onLogout }: Props) { invisible={!user} anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} > - + {user ? ( + + ) : ( + + )} diff --git a/client/src/pages/auth/ui/AuthPage.tsx b/client/src/pages/auth/ui/AuthPage.tsx index 49edb29..6105a6c 100644 --- a/client/src/pages/auth/ui/AuthPage.tsx +++ b/client/src/pages/auth/ui/AuthPage.tsx @@ -16,7 +16,14 @@ import { $user, tokenSet } from '@/shared/model/auth' type AuthResponse = { token: string - user: { id: string; email: string; displayName?: string | null; phone?: string | null } + user: { + id: string + email: string + displayName?: string | null + avatar?: string | null + avatarType?: string | null + avatarStyle?: string | null + } } function getApiErrorMessage(err: unknown): string | null { diff --git a/client/src/pages/me/ui/sections/SettingsPage.tsx b/client/src/pages/me/ui/sections/SettingsPage.tsx index 338ba1f..2b0e403 100644 --- a/client/src/pages/me/ui/sections/SettingsPage.tsx +++ b/client/src/pages/me/ui/sections/SettingsPage.tsx @@ -1,12 +1,19 @@ +import { useState } 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 FormControl from '@mui/material/FormControl' +import InputLabel from '@mui/material/InputLabel' +import MenuItem from '@mui/material/MenuItem' +import Select from '@mui/material/Select' import Stack from '@mui/material/Stack' import TextField from '@mui/material/TextField' import Typography from '@mui/material/Typography' +import { createAvatar } from '@dicebear/core' import { useUnit } from 'effector-react' import { useForm } from 'react-hook-form' +import { AVATAR_STYLES, DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles' import { $requestEmailChangeCodeError, $updateProfileError, @@ -16,6 +23,7 @@ import { updateProfileFx, verifyEmailChangeFx, } from '@/shared/model/auth' +import { UserAvatar } from '@/shared/ui/UserAvatar' import type { AxiosError } from 'axios' function getApiErrorMessage(error: unknown): string | null { @@ -38,10 +46,9 @@ export function SettingsPage() { mode: 'onChange', }) - const profileForm = useForm<{ displayName: string; phone: string }>({ + const profileForm = useForm<{ displayName: string }>({ defaultValues: { displayName: user?.displayName ? String(user.displayName) : '', - phone: user?.phone ? String(user.phone) : '', }, mode: 'onChange', }) @@ -49,6 +56,16 @@ export function SettingsPage() { const emailErrorMsg = getApiErrorMessage(errorEmailReq) ?? getApiErrorMessage(errorEmailVerify) const profileErrorMsg = getApiErrorMessage(errorProfile) + const hasOAuthAvatar = Boolean(user?.avatar && user.avatarType !== 'generated') + const useOAuth = user?.avatarType === 'oauth' + const useGenerated = user?.avatarType === 'generated' + + const [selectedStyle, setSelectedStyle] = useState(user?.avatarStyle || DEFAULT_STYLE_ID) + const [previewSrc, setPreviewSrc] = useState(null) + const [previewStyle, setPreviewStyle] = useState(DEFAULT_STYLE_ID) + + const hasUnsavedPreview = previewSrc !== null + if (!user) { return Нужно войти. Перейдите на страницу «Вход». } @@ -85,20 +102,13 @@ export function SettingsPage() { slotProps={{ htmlInput: { maxLength: 40 } }} {...profileForm.register('displayName')} /> - + + + {hasUnsavedPreview && ( + + + + + )} + + {hasOAuthAvatar && !hasUnsavedPreview && ( + + )} + + + + Смена почты diff --git a/client/src/shared/lib/avatar-styles.ts b/client/src/shared/lib/avatar-styles.ts new file mode 100644 index 0000000..a3e3bec --- /dev/null +++ b/client/src/shared/lib/avatar-styles.ts @@ -0,0 +1,88 @@ +import { create as adventurerCreate, meta as adventurerMeta, schema as adventurerSchema } from '@dicebear/adventurer' +import { create as avataaarsCreate, meta as avataaarsMeta, schema as avataaarsSchema } from '@dicebear/avataaars' +import { create as bigEarsCreate, meta as bigEarsMeta, schema as bigEarsSchema } from '@dicebear/big-ears' +import { create as bigSmileCreate, meta as bigSmileMeta, schema as bigSmileSchema } from '@dicebear/big-smile' +import { create as botttsCreate, meta as botttsMeta, schema as botttsSchema } from '@dicebear/bottts' +import { create as croodlesCreate, meta as croodlesMeta, schema as croodlesSchema } from '@dicebear/croodles' +import { create as funEmojiCreate, meta as funEmojiMeta, schema as funEmojiSchema } from '@dicebear/fun-emoji' +import { create as identiconCreate, meta as identiconMeta, schema as identiconSchema } from '@dicebear/identicon' +import { create as initialsCreate, meta as initialsMeta, schema as initialsSchema } from '@dicebear/initials' +import { create as loreleiCreate, meta as loreleiMeta, schema as loreleiSchema } from '@dicebear/lorelei' +import { create as micahCreate, meta as micahMeta, schema as micahSchema } from '@dicebear/micah' +import { create as notionistsCreate, meta as notionistsMeta, schema as notionistsSchema } from '@dicebear/notionists' +import { create as pixelArtCreate, meta as pixelArtMeta, schema as pixelArtSchema } from '@dicebear/pixel-art' +import { create as ringsCreate, meta as ringsMeta, schema as ringsSchema } from '@dicebear/rings' +import { create as shapesCreate, meta as shapesMeta, schema as shapesSchema } from '@dicebear/shapes' +import { create as thumbsCreate, meta as thumbsMeta, schema as thumbsSchema } from '@dicebear/thumbs' +import type { Style } from '@dicebear/core' + +type StyleDef = { + id: string + label: string + style: Style +} + +export const AVATAR_STYLES: StyleDef[] = [ + { id: 'bottts', label: 'Роботы', style: { create: botttsCreate, meta: botttsMeta, schema: botttsSchema } }, + { + id: 'identicon', + label: 'Узоры', + style: { create: identiconCreate, meta: identiconMeta, schema: identiconSchema }, + }, + { + id: 'avataaars', + label: 'Персонажи', + style: { create: avataaarsCreate, meta: avataaarsMeta, schema: avataaarsSchema }, + }, + { + id: 'notionists', + label: 'Notion', + style: { create: notionistsCreate, meta: notionistsMeta, schema: notionistsSchema }, + }, + { id: 'thumbs', label: 'Thumbs', style: { create: thumbsCreate, meta: thumbsMeta, schema: thumbsSchema } }, + { id: 'lorelei', label: 'Lorelei', style: { create: loreleiCreate, meta: loreleiMeta, schema: loreleiSchema } }, + { id: 'micah', label: 'Micah', style: { create: micahCreate, meta: micahMeta, schema: micahSchema } }, + { + id: 'pixel-art', + label: 'Пиксели', + style: { create: pixelArtCreate, meta: pixelArtMeta, schema: pixelArtSchema }, + }, + { id: 'rings', label: 'Кольца', style: { create: ringsCreate, meta: ringsMeta, schema: ringsSchema } }, + { id: 'shapes', label: 'Фигуры', style: { create: shapesCreate, meta: shapesMeta, schema: shapesSchema } }, + { + id: 'initials', + label: 'Инициалы', + style: { create: initialsCreate, meta: initialsMeta, schema: initialsSchema }, + }, + { + id: 'adventurer', + label: 'Adventurer', + style: { create: adventurerCreate, meta: adventurerMeta, schema: adventurerSchema }, + }, + { + id: 'big-ears', + label: 'Big Ears', + style: { create: bigEarsCreate, meta: bigEarsMeta, schema: bigEarsSchema }, + }, + { + id: 'big-smile', + label: 'Big Smile', + style: { create: bigSmileCreate, meta: bigSmileMeta, schema: bigSmileSchema }, + }, + { + id: 'croodles', + label: 'Croodles', + style: { create: croodlesCreate, meta: croodlesMeta, schema: croodlesSchema }, + }, + { + id: 'fun-emoji', + label: 'Fun Emoji', + style: { create: funEmojiCreate, meta: funEmojiMeta, schema: funEmojiSchema }, + }, +] + +export const DEFAULT_STYLE_ID = 'bottts' + +export function getStyleById(id: string | null | undefined): StyleDef { + return AVATAR_STYLES.find((s) => s.id === id) ?? AVATAR_STYLES[0] +} diff --git a/client/src/shared/model/auth.ts b/client/src/shared/model/auth.ts index 3c4540f..6761582 100644 --- a/client/src/shared/model/auth.ts +++ b/client/src/shared/model/auth.ts @@ -11,7 +11,8 @@ export type AuthUser = { lastName?: string | null gender?: string | null avatar?: string | null - phone?: string | null + avatarType?: string | null + avatarStyle?: string | null isAdmin?: boolean } @@ -68,7 +69,12 @@ export const verifyEmailChangeFx = createEffect(async (params: { newEmail: strin // ----- Profile update ----- -export type UpdateProfileParams = { displayName: string | null; phone?: string | null } +export type UpdateProfileParams = { + displayName: string | null + avatar?: string | null + avatarType?: string | null + avatarStyle?: string | null +} export const updateProfileFx = createEffect(async (params: UpdateProfileParams) => { const { data } = await apiClient.patch<{ user: AuthUser }>('me/profile', params) diff --git a/client/src/shared/ui/UserAvatar.tsx b/client/src/shared/ui/UserAvatar.tsx new file mode 100644 index 0000000..bc3eecf --- /dev/null +++ b/client/src/shared/ui/UserAvatar.tsx @@ -0,0 +1,30 @@ +import { useMemo } from 'react' +import Avatar from '@mui/material/Avatar' +import type { SxProps, Theme } from '@mui/material/styles' +import { createAvatar } from '@dicebear/core' +import { DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles' + +type UserAvatarProps = { + userId: string + avatarUrl?: string | null + avatarType?: string | null + avatarStyle?: string | null + size?: number + sx?: SxProps +} + +export function UserAvatar({ userId, avatarUrl, avatarType, avatarStyle, size = 40, sx }: UserAvatarProps) { + const generatedSrc = useMemo(() => { + const styleDef = getStyleById(avatarStyle || DEFAULT_STYLE_ID) + const avatar = createAvatar(styleDef.style, { seed: userId }) + return avatar.toDataUri() + }, [userId, avatarStyle]) + + const src = avatarType && avatarUrl ? avatarUrl : generatedSrc + + return ( + + ? + + ) +} diff --git a/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx b/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx index d5a463b..f392849 100644 --- a/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx +++ b/client/src/widgets/reviews-block/ui/ReviewsBlock.tsx @@ -1,6 +1,5 @@ import StarRoundedIcon from '@mui/icons-material/StarRounded' import Alert from '@mui/material/Alert' -import Avatar from '@mui/material/Avatar' import Box from '@mui/material/Box' import Paper from '@mui/material/Paper' import Rating from '@mui/material/Rating' @@ -12,12 +11,7 @@ import { Link as RouterLink } from 'react-router-dom' import { fetchLatestApprovedReviews } from '@/entities/review/api/reviews-api' import { OptimizedImage } from '@/shared/ui/OptimizedImage' import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent' - -function initials(display: string) { - const s = display.trim() - if (!s) return '?' - return s.slice(0, 1).toUpperCase() -} +import { UserAvatar } from '@/shared/ui/UserAvatar' function formatReviewDate(iso: string): string { try { @@ -107,9 +101,13 @@ export function ReviewsBlock() { )} - - {initials(r.authorDisplay)} - + {r.authorDisplay} diff --git a/server/prisma/migrations/20260521100000_drop_phone_add_avatar_type/migration.sql b/server/prisma/migrations/20260521100000_drop_phone_add_avatar_type/migration.sql new file mode 100644 index 0000000..384c607 --- /dev/null +++ b/server/prisma/migrations/20260521100000_drop_phone_add_avatar_type/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE User DROP COLUMN phone; +ALTER TABLE User ADD COLUMN "avatarType" TEXT; diff --git a/server/prisma/migrations/20260521103000_add_avatar_style/migration.sql b/server/prisma/migrations/20260521103000_add_avatar_style/migration.sql new file mode 100644 index 0000000..acccfeb --- /dev/null +++ b/server/prisma/migrations/20260521103000_add_avatar_style/migration.sql @@ -0,0 +1 @@ +ALTER TABLE User ADD COLUMN "avatarStyle" TEXT; diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index c72d8385c25bef9469f566ac93628e51a0d7cea5..8a10718afbb537e2244aae9e4785d6db96cef25c 100644 GIT binary patch literal 364544 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 T|NkFm$zPr^A$ImO^Z)-J!72XB delta 1124 zcmaJ>U1%It6ux)nPO`HgS zHAP?w-6w%h-K4`c_YE4-9hI*qgb;!(6IjNCt^i*?EbPUD83Hrt#(~egEM!Cqx2^i5 za1Uw$D#8bqVEjbtbsO|m8}-y1>1;F?EAbEi61wH%^OunYN}IR(kuEUDaobiN7nj!I zzC5huC&Y-K|C$g~i|OK@VXsX`fPDlOVIS5!2FlxlHxcXDxhse_lH?%qWKw+Yhy#7e zkxLyEjnKeel9VZwPi$GNLOC_k9*@TKp+YK`e>_tvp!>1ByeTj0EB{yN&8Dv6=wJ<3 zz*>D@+#Pj`icH|3GN;Ix?p>cHuZVYdU};YiIE^P+cx2o|{fQP?C8{`1Pz^e&OKeDB zstJCrhpCPBK$LTtk(1?8F`p|A(z%au_dfsZTl|N^tf^kb3~^fisk0YbMR4oLw+_>} z0sMAF*kLh0`zCzGkXobElaO6RmSxw!^=QL3#_Tqx)2*fWV5Z&S4V%(#&^DZ~`-~QJ z#3?sXK;KLPm{F(Sb`<_t@-ew2Sypk%nrL!mhvkQp#;$CA@E{w4{ z?a>Y5tNL`$PWR99W3Kn4Irk^>@63?D6aSz;!Zq4UHb}90MC+6*#ro|B9-`yB_~ls^ z_U8sp8L2l5gT>c+I(obI_wnCmS+j_zT>XqSTgEI)iSBp$hw8TngLERyf0}03-Puxc kFrxK!9qp@X(x~ 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: 'Некорректный телефон' }) - } + if (avatarType !== undefined && avatarType !== 'oauth' && avatarType !== 'generated' && avatarType !== '') { + return reply.code(400).send({ error: 'avatarType должен быть oauth | generated | пустая строка' }) + } + if (avatar !== undefined && avatar.length > 200000) return reply.code(400).send({ error: 'Аватар слишком большой' }) + if (avatarStyle !== undefined && avatarStyle !== '' && avatarStyle.length > 30) { + return reply.code(400).send({ error: 'Стиль аватара слишком длинный' }) } + const data = { + displayName: displayName && displayName.length ? displayName : null, + } + + if (avatarType !== undefined) { + data.avatarType = avatarType === '' ? null : avatarType + } + if (avatar !== undefined) { + data.avatar = avatar === '' ? null : avatar + } + if (avatarStyle !== undefined) { + data.avatarStyle = avatarStyle === '' ? null : avatarStyle + } const updated = await prisma.user.update({ where: { id: userId }, - data: { - displayName: displayName && displayName.length ? displayName : null, - phone: phone && phone.length ? phone : null, - }, + data, }) return { user: mapUserForClient(updated) } }) diff --git a/server/src/routes/user-payments.js b/server/src/routes/user-payments.js index 8b8e99b..9d89bc4 100644 --- a/server/src/routes/user-payments.js +++ b/server/src/routes/user-payments.js @@ -60,7 +60,7 @@ export async function registerUserPaymentRoutes(fastify) { const receipt = buildReceipt({ orderItems: order.items, deliveryFeeCents: order.deliveryFeeCents, - userEmail: userEmail, + userEmail: userEmail, }) let result From 47124a01a72ace5da03128d4035d56d1b2efb5df Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 21 May 2026 14:22:03 +0500 Subject: [PATCH 2/3] test commit --- client/src/app/routes/index.tsx | 2 - client/src/entities/cart/index.ts | 3 + client/src/entities/notification/index.ts | 7 + client/src/entities/order/index.ts | 9 + client/src/entities/product/index.ts | 2 + client/src/entities/review/index.ts | 12 + client/src/entities/user/index.ts | 10 + .../pages/admin-layout/ui/AdminLayoutPage.tsx | 2 +- .../ui/AdminNotificationsPage.tsx | 4 +- client/src/pages/me/ui/MeLayoutPage.tsx | 2 +- client/src/pages/me/ui/MePage.tsx | 134 ---------- .../me/ui/sections/NotificationsPage.tsx | 4 +- client/src/pages/terms/ui/TermsPage.tsx | 6 +- server/prisma/prisma/dev.db | Bin 364544 -> 364544 bytes server/src/routes/api/admin-categories.js | 204 ++++++++------- server/src/routes/api/catalog-slider.js | 159 ++++++------ server/src/routes/auth.js | 3 +- server/src/routes/user-addresses.js | 240 ++++++++++-------- server/src/routes/user-cart.js | 128 ++++++---- server/src/routes/user-messages.js | 44 ++-- server/src/routes/user-orders.js | 87 ++++--- 21 files changed, 535 insertions(+), 527 deletions(-) create mode 100644 client/src/entities/cart/index.ts create mode 100644 client/src/entities/notification/index.ts create mode 100644 client/src/entities/order/index.ts create mode 100644 client/src/entities/product/index.ts create mode 100644 client/src/entities/review/index.ts create mode 100644 client/src/entities/user/index.ts delete mode 100644 client/src/pages/me/ui/MePage.tsx diff --git a/client/src/app/routes/index.tsx b/client/src/app/routes/index.tsx index a2b1a20..5e9177e 100644 --- a/client/src/app/routes/index.tsx +++ b/client/src/app/routes/index.tsx @@ -2,13 +2,11 @@ import { lazy, Suspense } from 'react' import { Route, Routes } from 'react-router-dom' import { MainLayout } from '@/app/layout/MainLayout' import { AboutPage } from '@/pages/about' -// import { AdminLayoutPage } from '@/pages/admin-layout' 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 { NotFoundPage } from '@/pages/not-found' import { PrivacyPolicyPage } from '@/pages/privacy-policy' import { ProductPage } from '@/pages/product' diff --git a/client/src/entities/cart/index.ts b/client/src/entities/cart/index.ts new file mode 100644 index 0000000..6870839 --- /dev/null +++ b/client/src/entities/cart/index.ts @@ -0,0 +1,3 @@ +export type { CartItem } from './model/types' +export { fetchMyCart, addToCart, setCartQty, removeCartItem } from './api/cart-api' +export type { CartResponse } from './api/cart-api' diff --git a/client/src/entities/notification/index.ts b/client/src/entities/notification/index.ts new file mode 100644 index 0000000..2394f9c --- /dev/null +++ b/client/src/entities/notification/index.ts @@ -0,0 +1,7 @@ +export { + fetchUserNotificationSettings, + updateUserNotificationSettings, + fetchAdminNotificationSettings, + updateAdminNotificationSettings, +} from './api/notifications-api' +export type { UserNotificationSettings, AdminNotificationSettings } from './api/notifications-api' diff --git a/client/src/entities/order/index.ts b/client/src/entities/order/index.ts new file mode 100644 index 0000000..50d153d --- /dev/null +++ b/client/src/entities/order/index.ts @@ -0,0 +1,9 @@ +export { + fetchMyOrders, + createOrder, + confirmOrderReceived, + fetchMyOrder, + fetchOrderReviewEligibility, +} from './api/order-api' +export { createOrderPayment, getOrderPaymentStatus, postOrderMessage } from './api/order-api' +export type { OrderListResponse, OrderDetailResponse } from './api/order-api' diff --git a/client/src/entities/product/index.ts b/client/src/entities/product/index.ts new file mode 100644 index 0000000..98bfb2e --- /dev/null +++ b/client/src/entities/product/index.ts @@ -0,0 +1,2 @@ +export { fetchPublicProducts, fetchPublicProduct, fetchCategories } from './api/product-api' +export type { PublicProductsResponse } from './api/product-api' diff --git a/client/src/entities/review/index.ts b/client/src/entities/review/index.ts new file mode 100644 index 0000000..48f31fd --- /dev/null +++ b/client/src/entities/review/index.ts @@ -0,0 +1,12 @@ +export { + postProductReview, + uploadReviewImage, + fetchLatestApprovedReviews, + fetchPublicProductReviews, +} from './api/reviews-api' +export type { + PublicReviewFeedItem, + PublicReviewsLatestResponse, + PublicProductReviewItem, + PublicProductReviewsResponse, +} from './api/reviews-api' diff --git a/client/src/entities/user/index.ts b/client/src/entities/user/index.ts new file mode 100644 index 0000000..cfb7e7f --- /dev/null +++ b/client/src/entities/user/index.ts @@ -0,0 +1,10 @@ +export type { AdminUser, ShippingAddress } from './model/types' +export { fetchAdminUsers, createAdminUser, updateAdminUser, deleteAdminUser } from './api/user-api' +export type { AdminUsersListResponse } from './api/user-api' +export { + fetchMyAddresses, + createMyAddress, + updateMyAddress, + deleteMyAddress, + setMyAddressDefault, +} from './api/address-api' diff --git a/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx b/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx index 69333d8..90c5cb1 100644 --- a/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx +++ b/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx @@ -60,7 +60,7 @@ export function AdminLayoutPage() { { to: '/admin/orders', label: 'Заказы', icon: }, { to: '/admin/reviews', label: 'Отзывы', icon: }, { to: '/admin/users', label: 'Пользователи', icon: }, - { to: '/admin/notifications', label: 'Оповещения', icon: }, + { to: '/admin/notifications', label: 'Уведомления', icon: }, ], [], ) diff --git a/client/src/pages/admin-layout/ui/AdminNotificationsPage.tsx b/client/src/pages/admin-layout/ui/AdminNotificationsPage.tsx index 3508376..71645b2 100644 --- a/client/src/pages/admin-layout/ui/AdminNotificationsPage.tsx +++ b/client/src/pages/admin-layout/ui/AdminNotificationsPage.tsx @@ -47,10 +47,10 @@ export function AdminNotificationsPage() { return ( - Оповещения + Уведомления - Настройка оповещений администратора. + Настройка уведомлений администратора. {error && ( diff --git a/client/src/pages/me/ui/MeLayoutPage.tsx b/client/src/pages/me/ui/MeLayoutPage.tsx index 0c24ad1..385a927 100644 --- a/client/src/pages/me/ui/MeLayoutPage.tsx +++ b/client/src/pages/me/ui/MeLayoutPage.tsx @@ -57,7 +57,7 @@ export function MeLayoutPage() { { to: '/me/messages', label: 'Сообщения', icon: }, { to: '/me/settings', label: 'Настройки', icon: }, { to: '/me/addresses', label: 'Адреса доставки', icon: }, - { to: '/me/notifications', label: 'Оповещения', icon: }, + { to: '/me/notifications', label: 'Уведомления', icon: }, ], [], ) diff --git a/client/src/pages/me/ui/MePage.tsx b/client/src/pages/me/ui/MePage.tsx deleted file mode 100644 index 107da67..0000000 --- a/client/src/pages/me/ui/MePage.tsx +++ /dev/null @@ -1,134 +0,0 @@ -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 { - $requestEmailChangeCodeError, - $updateProfileError, - $user, - $verifyEmailChangeError, - 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 MePage() { - const user = useUnit($user) - const pendingEmailReq = useUnit(requestEmailChangeCodeFx.pending) - const pendingEmailVerify = useUnit(verifyEmailChangeFx.pending) - const pendingProfile = useUnit(updateProfileFx.pending) - const errorEmailReq = useUnit($requestEmailChangeCodeError) - const errorProfile = useUnit($updateProfileError) - const errorEmailVerify = useUnit($verifyEmailChangeError) - - const emailForm = useForm<{ newEmail: string; code: string }>({ - defaultValues: { newEmail: '', code: '' }, - mode: 'onChange', - }) - - const profileForm = useForm<{ displayName: string }>({ - defaultValues: { displayName: user?.displayName ? String(user.displayName) : '' }, - mode: 'onChange', - }) - - const emailErrorMsg = getApiErrorMessage(errorEmailReq) ?? getApiErrorMessage(errorEmailVerify) - const profileErrorMsg = getApiErrorMessage(errorProfile) - - if (!user) { - return Нужно войти. Перейдите на страницу «Вход». - } - - return ( - - - Профиль - - - Текущая почта: {user.email} - - - {emailErrorMsg && ( - - {emailErrorMsg} - - )} - {profileErrorMsg && ( - - {profileErrorMsg} - - )} - - - - - Имя / ник - - - - - - - - - - - - Смена почты - - - - - - - - - - - - - ) -} diff --git a/client/src/pages/me/ui/sections/NotificationsPage.tsx b/client/src/pages/me/ui/sections/NotificationsPage.tsx index 85524c3..f1b07be 100644 --- a/client/src/pages/me/ui/sections/NotificationsPage.tsx +++ b/client/src/pages/me/ui/sections/NotificationsPage.tsx @@ -57,7 +57,7 @@ export function NotificationsPage() { return ( - Оповещения + Уведомления Настройте, какие уведомления вы хотите получать на почту. @@ -78,7 +78,7 @@ export function NotificationsPage() { onChange={(e) => handleToggle('globalEnabled', e.target.checked)} /> } - label={Получать оповещения} + label={Получать уведомления} /> Включите, чтобы получать уведомления о заказах на почту. diff --git a/client/src/pages/terms/ui/TermsPage.tsx b/client/src/pages/terms/ui/TermsPage.tsx index e5340bf..7699ea3 100644 --- a/client/src/pages/terms/ui/TermsPage.tsx +++ b/client/src/pages/terms/ui/TermsPage.tsx @@ -1,7 +1,7 @@ import Box from '@mui/material/Box' import Paper from '@mui/material/Paper' import Typography from '@mui/material/Typography' -import { STORE_EMAIL, STORE_PUBLIC_SITE_URL } from '@/shared/config' +import { STORE_EMAIL, STORE_PHONE, STORE_PUBLIC_SITE_URL } from '@/shared/config' const SITE_URL = STORE_PUBLIC_SITE_URL || (typeof window !== 'undefined' ? window.location.origin : '') @@ -138,8 +138,8 @@ const sections = [ `ИНН: ${OP_INN}`, `ОГРН: ${OP_OGRN}`, `Адрес: ${OP_ADDR}`, - `Телефон: +7 (900) 000-00-00`, // TODO: заменить на реальный номер телефона - `Email: ${STORE_EMAIL}`, // TODO: заменить на реальный email при настройке STORE_EMAIL + `Телефон: ${STORE_PHONE}`, + `Email: ${STORE_EMAIL}`, ], }, ] diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index 8a10718afbb537e2244aae9e4785d6db96cef25c..01bce42cf6c134bcfbb8aa874ddefdab047c0dc2 100644 GIT binary patch delta 2678 zcmeHJO>7fK7~S>m8e76FQE?!G)DB@uP?UJ~$Mzbe+T@2eY7>$cq#{t0jlK5Tv17;H zII+_zKJ^-CRL!L#kx;o+Rh#0_UP=#@Dz%WbsNx7M;sghz9;+(-W^F>EIM?=qrTNCQ z^Y(pj#xw78Wu>{Y(tP!(>j_0sm!O@8=DOAyq@R2>{>37Fd$lR@Ilr{J+`P|^f~ukh zRyUTPFGn;fZIls0a%e6y71GMnvqm;ZUmv``e#O?#hB;?6lZyi=SpZOgfU>2aT*w=S z1u9h|^q>6e1l_`Ran6x};r_wlH^)ZKUK$!0zEH=E&}_~@^N%V1G5_Og)5y2{(&VBO z-&?0!-I`J`rh!|ED#?7JJgp_rROsUky1HSj_^jC(m{rJZ5yeB2T*~54H*JH*e;?}w zdWe?7Ml4_7dCE6ts3O}l&)PE|vsnY|5^yuZcn`Q>3vK)vivNy3!~eZB>g`+$j5ANU zg?Yvm4td;e_aMb_6lDnz4Lt=yQLz{eg`~7B#?yXNFPPm~ZN@Yr`ka=@rS+a@Hgf@< zdM_6AJo!TUjF#37)65l4`+VDQKCP&kTD~WhOOSnJFV`YP@y>~`*e9HF-w+|crv*Qc zGV2ZzAwfbSTKhG@d`8>fGqjvuz^*bUIQo-Oy;s-rI9%o~?~lP~0!Bm&{<+NM59ekk z(#dEno6(X}f^@E|=d;j>czB*eyf7dMDiT5ibw+WL@d(>FPC?p<5`-;eJs)0~WO_JD z6^_huCo!7mTze*4`H)*YY%M|pE<;Zy3{#N%1xXSS{vpla-na!jM$x`!KQ#19>Zcz!s$WNetgxmgMTU-{60c$8i!`VOBBX*?uCsY}WU7YN#r|ePT z6$=WzHL&v4)3wVWh@_Lyj%-B5j%f)3wi6q&UL^6=cNVayw)A^CjRXc#x%k$sp0^;#;=IwW_Tl$6-&-K&rCR{7{ zY~36sRtS=tQx3{0lnRD2NSV##v`N*E#7F|pWSX2*^8TTpYO$JQzT`)>Mpuq$Wy18H zj$k{+glmr0C~39ApanNEI~nBoiP;5;DC#dvsrdFLb8-l54g^4~o8oTgoTd+kyPU05 zi^IXZM%EavLz8nrsS?V$NkhqJ;m8om!LZkNw~!`|iPhN51s1|;v|uO4I{ zdfGHtXbe(|*6tSL!EC9hS-W_*7V9S209B2!ECSoUMe(=z)M^v@fXAr?_R49^uP1;K z1&ApJ;>keC3?}y6bCTxXcr8{dHOgI;;CmJ-K_lAg9d@6W-j5xR*be7KI5tDj-d|H6 zvA--jZ1y(Fe%;;%*4(#tKF9s`KU?!BW4!IaD{Cg6{mPn&TmRSA+|kw@s#gzXTlEN$ zinFa!9XXn@Mq33;4fF)wLho1)@zn>c+a3tTBt1y#mKSgMxYnk>xP(5X=b`k)3f5nX In-%WhzuvbMrT_o{ delta 2278 zcmbtVO>9(E6rR`j9_^s;TD4%&((*I>guKhW_r3dmOrWh$l+Z$r8pIG~=5>a7?Q~|C zc50`aXB-v_r7z_ z`R+OA-Sgd7}!$)6ubExmcVE@rq8a6|UR&0s2#$v`_#&-*CLth!^ zN6*IP&sUYscEAqHfSqKtVvVH5@bpBf@Xj@*cCBeTYJ@ehWtBF+b{L1-RpIzj$+&a8GFsR zbN;37U5m~s^`-)IJ zeb;8f6;Myh=E4`d6P%_hAQJsN;n9TOKNtqdEGPz&`7m8toacI6*=RX5!8vHN>F{)R zB4HhbPs@=vM^!10Z`V85fG?+9cHh-DJbf;g@zJe1ruU2g!^ z0-YEFTj(FTWEr>Rx$D|f@58VZNfRRnCZYK&S2$w zU`0*g5GqX0h=`7K9nMIkbmFbj0l0loikFxx3}1i>>rOe_brAW#d+ z3H72iT%r^5>#JHH-l{>RR#ZPRHo?74SK#I{m+{J50WSr~5utXfJxX+6H#a@2_SBne z)UMzar{mL8<*7nAPP2ZtHlxxi_C~%4I|BPI!^ZMK&K{9p-cxrBLdVs+w9%idoA`{< z+OsF#8S7|iQJ;*(biHS}l|z8^5VDw?%$KvSh#77r55!qE&9%?joXhcv_%UfWTZjUIl4RjpUkXq`d6Fh&;I=o^L{scI*m zL-v>k%mCS=5`_7DChIlgUrj5R-p3!*Wo_m@CGE$vYn0|ptT_Wj(!Y4DsQ;q>f3A?g6(`%~jb$oYS_l8Is}pa})i@Up+((d$ zT}kzQW!r&Lp)j0FkAu>LaWMLVMWs+K!IVh`Y57A+O{|*?N0LZTlj+h}Qr@8Y^b@}0 zFyHq*?lH$hH#!M`Ra?2Q2E;#c9M~^3yhb zW7UMW1QYP0>M?|l#EKWdLeNoq7?XH7FsQoe)f4vNJwo@!yn { - const items = await prisma.category.findMany({ - orderBy: [{ sort: 'asc' }, { name: 'asc' }], - }) - return { items } + fastify.get('/api/admin/categories', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + try { + const items = await prisma.category.findMany({ + orderBy: [{ sort: 'asc' }, { name: 'asc' }], + }) + return { items } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось загрузить категории' }) + } }) fastify.post('/api/admin/categories', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { - const body = request.body ?? {} - const name = String(body.name ?? '').trim() - if (!name) { - reply.code(400).send({ error: 'Укажите название категории' }) - return + try { + const body = request.body ?? {} + const name = String(body.name ?? '').trim() + if (!name) { + reply.code(400).send({ error: 'Укажите название категории' }) + return + } + const slug = String(body.slug ?? '').trim() || request.server.slugify(name) || `cat-${Date.now()}` + if (isUnspecifiedCategorySlug(slug)) { + reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` }) + return + } + const sort = body.sort !== undefined && body.sort !== null && body.sort !== '' ? Number(body.sort) : undefined + const exists = await prisma.category.findUnique({ where: { slug } }) + if (exists) { + reply.code(409).send({ error: 'Такой slug уже занят' }) + return + } + const category = await prisma.category.create({ + data: { + name, + slug, + sort: Number.isFinite(sort) ? Math.round(sort) : 0, + }, + }) + reply.code(201).send(category) + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось создать категорию' }) } - const slug = String(body.slug ?? '').trim() || request.server.slugify(name) || `cat-${Date.now()}` - if (isUnspecifiedCategorySlug(slug)) { - reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` }) - return - } - const sort = body.sort !== undefined && body.sort !== null && body.sort !== '' ? Number(body.sort) : undefined - const exists = await prisma.category.findUnique({ where: { slug } }) - if (exists) { - reply.code(409).send({ error: 'Такой slug уже занят' }) - return - } - const category = await prisma.category.create({ - data: { - name, - slug, - sort: Number.isFinite(sort) ? Math.round(sort) : 0, - }, - }) - reply.code(201).send(category) }) fastify.patch('/api/admin/categories/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { - const { id } = request.params - const body = request.body ?? {} - const existing = await prisma.category.findUnique({ where: { id } }) - if (!existing) { - reply.code(404).send({ error: 'Категория не найдена' }) - return - } + try { + const { id } = request.params + const body = request.body ?? {} + const existing = await prisma.category.findUnique({ where: { id } }) + if (!existing) { + reply.code(404).send({ error: 'Категория не найдена' }) + return + } - const data = {} - if (body.name !== undefined) data.name = String(body.name ?? '').trim() - if (body.sort !== undefined) { - const s = Number(body.sort) - if (!Number.isFinite(s)) { - reply.code(400).send({ error: 'Некорректный sort' }) - return - } - data.sort = Math.round(s) - } - if (body.slug !== undefined) { - const s = String(body.slug ?? '').trim() - if (isUnspecifiedCategorySlug(existing.slug) && s !== UNSPECIFIED_CATEGORY_SLUG) { - reply.code(400).send({ error: 'Нельзя сменить slug служебной категории «Не указано»' }) - return - } - if (!s) { - reply.code(400).send({ error: 'Slug не может быть пустым' }) - return - } - if (s !== existing.slug) { - if (isUnspecifiedCategorySlug(s)) { - reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` }) + const data = {} + if (body.name !== undefined) data.name = String(body.name ?? '').trim() + if (body.sort !== undefined) { + const s = Number(body.sort) + if (!Number.isFinite(s)) { + reply.code(400).send({ error: 'Некорректный sort' }) return } - const clash = await prisma.category.findFirst({ where: { slug: s, NOT: { id } } }) - if (clash) { - reply.code(409).send({ error: 'Такой slug уже занят' }) + data.sort = Math.round(s) + } + if (body.slug !== undefined) { + const s = String(body.slug ?? '').trim() + if (isUnspecifiedCategorySlug(existing.slug) && s !== UNSPECIFIED_CATEGORY_SLUG) { + reply.code(400).send({ error: 'Нельзя сменить slug служебной категории «Не указано»' }) return } + if (!s) { + reply.code(400).send({ error: 'Slug не может быть пустым' }) + return + } + if (s !== existing.slug) { + if (isUnspecifiedCategorySlug(s)) { + reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` }) + return + } + const clash = await prisma.category.findFirst({ where: { slug: s, NOT: { id } } }) + if (clash) { + reply.code(409).send({ error: 'Такой slug уже занят' }) + return + } + } + data.slug = s } - data.slug = s - } - if (Object.keys(data).length === 0) { - return existing - } - if (data.name !== undefined && !data.name) { - reply.code(400).send({ error: 'Укажите название' }) - return - } + if (Object.keys(data).length === 0) { + return existing + } + if (data.name !== undefined && !data.name) { + reply.code(400).send({ error: 'Укажите название' }) + return + } - const updated = await prisma.category.update({ where: { id }, data }) - return updated + const updated = await prisma.category.update({ where: { id }, data }) + return updated + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось обновить категорию' }) + } }) fastify.delete('/api/admin/categories/:id', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { - const { id } = request.params - const existing = await prisma.category.findUnique({ where: { id } }) - if (!existing) { - reply.code(404).send({ error: 'Категория не найдена' }) - return - } - if (isUnspecifiedCategorySlug(existing.slug)) { - reply.code(409).send({ error: 'Служебную категорию «Не указано» нельзя удалить' }) - return - } + try { + const { id } = request.params + const existing = await prisma.category.findUnique({ where: { id } }) + if (!existing) { + reply.code(404).send({ error: 'Категория не найдена' }) + return + } + if (isUnspecifiedCategorySlug(existing.slug)) { + reply.code(409).send({ error: 'Служебную категорию «Не указано» нельзя удалить' }) + return + } - const fallback = await getOrCreateUnspecifiedCategory() - await prisma.$transaction([ - prisma.product.updateMany({ - where: { categoryId: id }, - data: { categoryId: fallback.id }, - }), - prisma.category.delete({ where: { id } }), - ]) - return reply.code(204).send() + const fallback = await getOrCreateUnspecifiedCategory() + await prisma.$transaction([ + prisma.product.updateMany({ + where: { categoryId: id }, + data: { categoryId: fallback.id }, + }), + prisma.category.delete({ where: { id } }), + ]) + return reply.code(204).send() + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось удалить категорию' }) + } }) } diff --git a/server/src/routes/api/catalog-slider.js b/server/src/routes/api/catalog-slider.js index 4e494c6..76c5e61 100644 --- a/server/src/routes/api/catalog-slider.js +++ b/server/src/routes/api/catalog-slider.js @@ -3,89 +3,104 @@ import { prisma } from '../../lib/prisma.js' const MAX_SLIDES = 20 export async function registerCatalogSliderRoutes(fastify) { - fastify.get('/api/catalog-slider', async () => { - const slides = await prisma.catalogSliderSlide.findMany({ - orderBy: { sortOrder: 'asc' }, - include: { galleryImage: true }, - }) - return { - slides: slides.map((s) => ({ - id: s.id, - url: s.galleryImage.url, - caption: s.caption, - })), + fastify.get('/api/catalog-slider', async (request, reply) => { + try { + const slides = await prisma.catalogSliderSlide.findMany({ + orderBy: { sortOrder: 'asc' }, + include: { galleryImage: true }, + }) + return { + slides: slides.map((s) => ({ + id: s.id, + url: s.galleryImage.url, + caption: s.caption, + })), + } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось загрузить слайдер' }) } }) - fastify.get('/api/admin/catalog-slider', { preHandler: [fastify.verifyAdmin] }, async () => { - const slides = await prisma.catalogSliderSlide.findMany({ - orderBy: { sortOrder: 'asc' }, - include: { galleryImage: true }, - }) - return { - slides: slides.map((s) => ({ - id: s.id, - galleryImageId: s.galleryImageId, - url: s.galleryImage.url, - caption: s.caption, - })), + fastify.get('/api/admin/catalog-slider', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { + try { + const slides = await prisma.catalogSliderSlide.findMany({ + orderBy: { sortOrder: 'asc' }, + include: { galleryImage: true }, + }) + return { + slides: slides.map((s) => ({ + id: s.id, + galleryImageId: s.galleryImageId, + url: s.galleryImage.url, + caption: s.caption, + })), + } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось загрузить слайдер' }) } }) fastify.put('/api/admin/catalog-slider', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => { - const body = request.body ?? {} - const rawSlides = body.slides - if (!Array.isArray(rawSlides)) { - return reply.code(400).send({ error: 'Ожидается slides: массив' }) - } - if (rawSlides.length > MAX_SLIDES) { - return reply.code(400).send({ error: `Не более ${MAX_SLIDES} слайдов` }) - } + try { + const body = request.body ?? {} + const rawSlides = body.slides + if (!Array.isArray(rawSlides)) { + return reply.code(400).send({ error: 'Ожидается slides: массив' }) + } + if (rawSlides.length > MAX_SLIDES) { + return reply.code(400).send({ error: `Не более ${MAX_SLIDES} слайдов` }) + } - const seenGalleryIds = new Set() - const normalized = [] - for (let i = 0; i < rawSlides.length; i++) { - const row = rawSlides[i] - const galleryImageId = String(row?.galleryImageId ?? '').trim() - if (!galleryImageId) { - return reply.code(400).send({ error: `Слайд ${i + 1}: укажите galleryImageId` }) + const seenGalleryIds = new Set() + const normalized = [] + for (let i = 0; i < rawSlides.length; i++) { + const row = rawSlides[i] + const galleryImageId = String(row?.galleryImageId ?? '').trim() + if (!galleryImageId) { + return reply.code(400).send({ error: `Слайд ${i + 1}: укажите galleryImageId` }) + } + if (seenGalleryIds.has(galleryImageId)) { + return reply.code(400).send({ error: 'Одно изображение нельзя добавить дважды' }) + } + seenGalleryIds.add(galleryImageId) + const img = await prisma.galleryImage.findUnique({ where: { id: galleryImageId } }) + if (!img) { + return reply.code(400).send({ error: `Изображение не найдено: ${galleryImageId}` }) + } + const caption = row?.caption == null ? '' : String(row.caption).slice(0, 500) + normalized.push({ galleryImageId, caption, sortOrder: i }) } - if (seenGalleryIds.has(galleryImageId)) { - return reply.code(400).send({ error: 'Одно изображение нельзя добавить дважды' }) - } - seenGalleryIds.add(galleryImageId) - const img = await prisma.galleryImage.findUnique({ where: { id: galleryImageId } }) - if (!img) { - return reply.code(400).send({ error: `Изображение не найдено: ${galleryImageId}` }) - } - const caption = row?.caption == null ? '' : String(row.caption).slice(0, 500) - normalized.push({ galleryImageId, caption, sortOrder: i }) - } - await prisma.$transaction(async (tx) => { - await tx.catalogSliderSlide.deleteMany({}) - for (const n of normalized) { - await tx.catalogSliderSlide.create({ - data: { - sortOrder: n.sortOrder, - caption: n.caption, - galleryImageId: n.galleryImageId, - }, - }) - } - }) + await prisma.$transaction(async (tx) => { + await tx.catalogSliderSlide.deleteMany({}) + for (const n of normalized) { + await tx.catalogSliderSlide.create({ + data: { + sortOrder: n.sortOrder, + caption: n.caption, + galleryImageId: n.galleryImageId, + }, + }) + } + }) - const slides = await prisma.catalogSliderSlide.findMany({ - orderBy: { sortOrder: 'asc' }, - include: { galleryImage: true }, - }) - return { - slides: slides.map((s) => ({ - id: s.id, - galleryImageId: s.galleryImageId, - url: s.galleryImage.url, - caption: s.caption, - })), + const slides = await prisma.catalogSliderSlide.findMany({ + orderBy: { sortOrder: 'asc' }, + include: { galleryImage: true }, + }) + return { + slides: slides.map((s) => ({ + id: s.id, + galleryImageId: s.galleryImageId, + url: s.galleryImage.url, + caption: s.caption, + })), + } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось обновить слайдер' }) } }) } diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index 3e25fe5..de8773f 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -123,8 +123,7 @@ export async function registerAuthRoutes(fastify) { const avatarRaw = request.body?.avatar const avatar = avatarRaw === null || avatarRaw === undefined ? undefined : String(avatarRaw).trim() const avatarTypeRaw = request.body?.avatarType - const avatarType = - avatarTypeRaw === null || avatarTypeRaw === undefined ? undefined : String(avatarTypeRaw).trim() + const avatarType = avatarTypeRaw === null || avatarTypeRaw === undefined ? undefined : String(avatarTypeRaw).trim() const avatarStyleRaw = request.body?.avatarStyle const avatarStyle = avatarStyleRaw === null || avatarStyleRaw === undefined ? undefined : String(avatarStyleRaw).trim() diff --git a/server/src/routes/user-addresses.js b/server/src/routes/user-addresses.js index 2440907..54d3659 100644 --- a/server/src/routes/user-addresses.js +++ b/server/src/routes/user-addresses.js @@ -45,133 +45,159 @@ function validateAddressPayload(body, reply) { } export async function registerUserAddressRoutes(fastify) { - 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.get('/api/me/addresses', { preHandler: [fastify.authenticate] }, async (request, reply) => { + try { + const userId = request.user.sub + const items = await prisma.shippingAddress.findMany({ + where: { userId }, + orderBy: [{ isDefault: 'desc' }, { updatedAt: 'desc' }], + }) + return { items } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось загрузить адреса' }) + } }) 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 + try { + 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, - }, + 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 }) + return reply.code(201).send({ item: created }) + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось создать адрес' }) + } }) 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: 'Адрес не найден' }) + try { + 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 = {} + 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 } }) + 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 } - return tx.shippingAddress.update({ - where: { id }, - data: { - ...data, - ...(setDefault ? { isDefault: true } : {}), - }, - }) - }) - return { item: updated } + 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 } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось обновить адрес' }) + } }) 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: 'Адрес не найден' }) + try { + 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() + await prisma.shippingAddress.delete({ where: { id } }) + return reply.code(204).send() + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось удалить адрес' }) + } }) 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: 'Адрес не найден' }) + try { + 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 } }) - }) + 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 } + return { item: updated } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось установить адрес по умолчанию' }) + } }) } diff --git a/server/src/routes/user-cart.js b/server/src/routes/user-cart.js index 136453b..8a5ba0e 100644 --- a/server/src/routes/user-cart.js +++ b/server/src/routes/user-cart.js @@ -1,76 +1,96 @@ import { prisma } from '../lib/prisma.js' export async function registerUserCartRoutes(fastify) { - fastify.get('/api/me/cart', { preHandler: [fastify.authenticate] }, async (request) => { - const userId = request.user.sub - const items = await prisma.cartItem.findMany({ - where: { userId }, - include: { product: { include: { category: true, images: { orderBy: { sort: 'asc' } } } } }, - orderBy: { createdAt: 'asc' }, - }) - return { - items: items.map((x) => ({ - id: x.id, - qty: x.qty, - product: x.product, - })), + fastify.get('/api/me/cart', { preHandler: [fastify.authenticate] }, async (request, reply) => { + try { + const userId = request.user.sub + const items = await prisma.cartItem.findMany({ + where: { userId }, + include: { product: { include: { category: true, images: { orderBy: { sort: 'asc' } } } } }, + orderBy: { createdAt: 'asc' }, + }) + return { + items: items.map((x) => ({ + id: x.id, + qty: x.qty, + product: x.product, + })), + } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось загрузить корзину' }) } }) fastify.post('/api/me/cart/items', { preHandler: [fastify.authenticate] }, async (request, reply) => { - const userId = request.user.sub - const productId = String(request.body?.productId || '').trim() - const qtyRaw = request.body?.qty - const qty = qtyRaw === undefined || qtyRaw === null || qtyRaw === '' ? 1 : Number(qtyRaw) + try { + const userId = request.user.sub + const productId = String(request.body?.productId || '').trim() + const qtyRaw = request.body?.qty + const qty = qtyRaw === undefined || qtyRaw === null || qtyRaw === '' ? 1 : Number(qtyRaw) - if (!productId) return reply.code(400).send({ error: 'productId обязателен' }) - if (!Number.isFinite(qty) || qty <= 0) return reply.code(400).send({ error: 'qty должен быть > 0' }) + if (!productId) return reply.code(400).send({ error: 'productId обязателен' }) + if (!Number.isFinite(qty) || qty <= 0) return reply.code(400).send({ error: 'qty должен быть > 0' }) - const product = await prisma.product.findFirst({ where: { id: productId, published: true } }) - if (!product) return reply.code(404).send({ error: 'Товар не найден' }) + const product = await prisma.product.findFirst({ where: { id: productId, published: true } }) + if (!product) return reply.code(404).send({ error: 'Товар не найден' }) - const available = product.quantity - const existing = await prisma.cartItem.findUnique({ where: { userId_productId: { userId, productId } } }) - const nextQty = (existing?.qty ?? 0) + Math.floor(qty) - if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` }) + const available = product.quantity + const existing = await prisma.cartItem.findUnique({ where: { userId_productId: { userId, productId } } }) + const nextQty = (existing?.qty ?? 0) + Math.floor(qty) + if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` }) - const item = await prisma.cartItem.upsert({ - where: { userId_productId: { userId, productId } }, - update: { qty: nextQty }, - create: { userId, productId, qty: nextQty }, - }) - return reply.code(201).send({ item }) + const item = await prisma.cartItem.upsert({ + where: { userId_productId: { userId, productId } }, + update: { qty: nextQty }, + create: { userId, productId, qty: nextQty }, + }) + return reply.code(201).send({ item }) + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось добавить в корзину' }) + } }) fastify.patch('/api/me/cart/items/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => { - const userId = request.user.sub - const { id } = request.params - const qtyRaw = request.body?.qty - const qty = Number(qtyRaw) - if (!Number.isFinite(qty) || qty < 0) return reply.code(400).send({ error: 'qty должен быть ≥ 0' }) + try { + const userId = request.user.sub + const { id } = request.params + const qtyRaw = request.body?.qty + const qty = Number(qtyRaw) + if (!Number.isFinite(qty) || qty < 0) return reply.code(400).send({ error: 'qty должен быть ≥ 0' }) - const existing = await prisma.cartItem.findFirst({ where: { id, userId }, include: { product: true } }) - if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' }) + const existing = await prisma.cartItem.findFirst({ where: { id, userId }, include: { product: true } }) + if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' }) - if (qty === 0) { - await prisma.cartItem.delete({ where: { id } }) - return reply.code(204).send() + if (qty === 0) { + await prisma.cartItem.delete({ where: { id } }) + return reply.code(204).send() + } + + const available = existing.product.quantity + const nextQty = Math.floor(qty) + if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` }) + + const updated = await prisma.cartItem.update({ where: { id }, data: { qty: nextQty } }) + return { item: updated } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось обновить количество' }) } - - const available = existing.product.quantity - const nextQty = Math.floor(qty) - if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` }) - - const updated = await prisma.cartItem.update({ where: { id }, data: { qty: nextQty } }) - return { item: updated } }) fastify.delete('/api/me/cart/items/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => { - const userId = request.user.sub - const { id } = request.params - const existing = await prisma.cartItem.findFirst({ where: { id, userId } }) - if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' }) - await prisma.cartItem.delete({ where: { id } }) - return reply.code(204).send() + try { + const userId = request.user.sub + const { id } = request.params + const existing = await prisma.cartItem.findFirst({ where: { id, userId } }) + if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' }) + await prisma.cartItem.delete({ where: { id } }) + return reply.code(204).send() + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось удалить из корзины' }) + } }) } diff --git a/server/src/routes/user-messages.js b/server/src/routes/user-messages.js index 6812f33..cd446ad 100644 --- a/server/src/routes/user-messages.js +++ b/server/src/routes/user-messages.js @@ -44,22 +44,21 @@ export async function registerUserMessageRoutes(fastify) { }) if (orders.length === 0) return { count: 0 } + const orderIds = orders.map((o) => o.id) const readStates = await prisma.userOrderMessageReadState.findMany({ where: { userId }, }) const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt])) + const adminMessages = await prisma.orderMessage.findMany({ + where: { orderId: { in: orderIds }, authorType: 'admin' }, + select: { orderId: true, createdAt: true }, + }) + let count = 0 - for (const o of orders) { - const lastRead = lastReadByOrder.get(o.id) ?? new Date(0) - const n = await prisma.orderMessage.count({ - where: { - orderId: o.id, - authorType: 'admin', - createdAt: { gt: lastRead }, - }, - }) - count += n + for (const msg of adminMessages) { + const lastRead = lastReadByOrder.get(msg.orderId) ?? new Date(0) + if (msg.createdAt > lastRead) count++ } return { count } }) @@ -86,25 +85,32 @@ export async function registerUserMessageRoutes(fastify) { }) const lastReadByOrder = new Map(readStates.map((r) => [r.orderId, r.lastReadAt])) + const orderIds = orders.map((o) => o.id) + const unreadCounts = new Map() + if (orderIds.length > 0) { + const adminMessages = await prisma.orderMessage.findMany({ + where: { orderId: { in: orderIds }, authorType: 'admin' }, + select: { orderId: true, createdAt: true }, + }) + for (const msg of adminMessages) { + const lastRead = lastReadByOrder.get(msg.orderId) ?? new Date(0) + if (msg.createdAt > lastRead) { + unreadCounts.set(msg.orderId, (unreadCounts.get(msg.orderId) ?? 0) + 1) + } + } + } + const items = [] for (const o of orders) { const lastMsg = o.messages[0] if (!lastMsg) continue - const lastRead = lastReadByOrder.get(o.id) ?? new Date(0) - const unreadCount = await prisma.orderMessage.count({ - where: { - orderId: o.id, - authorType: 'admin', - createdAt: { gt: lastRead }, - }, - }) items.push({ orderId: o.id, status: o.status, deliveryType: o.deliveryType, lastMessageAt: lastMsg.createdAt, preview: lastMsg.text.length > 280 ? `${lastMsg.text.slice(0, 277)}…` : lastMsg.text, - unreadCount, + unreadCount: unreadCounts.get(o.id) ?? 0, }) } return { items } diff --git a/server/src/routes/user-orders.js b/server/src/routes/user-orders.js index 8ced32d..6916bb1 100644 --- a/server/src/routes/user-orders.js +++ b/server/src/routes/user-orders.js @@ -176,35 +176,45 @@ export async function registerUserOrderRoutes(fastify) { return reply.code(201).send({ orderId: created.id }) }) - fastify.get('/api/me/orders', { preHandler: [fastify.authenticate] }, async (request) => { - const userId = request.user.sub - const orders = await prisma.order.findMany({ - where: { userId }, - include: { items: true }, - orderBy: { createdAt: 'desc' }, - }) - return { - items: orders.map((o) => ({ - id: o.id, - status: o.status, - totalCents: o.totalCents, - currency: o.currency, - createdAt: o.createdAt, - updatedAt: o.updatedAt, - itemsCount: o.items.reduce((s, i) => s + i.qty, 0), - })), + fastify.get('/api/me/orders', { preHandler: [fastify.authenticate] }, async (request, reply) => { + try { + const userId = request.user.sub + const orders = await prisma.order.findMany({ + where: { userId }, + include: { items: { select: { qty: true } } }, + orderBy: { createdAt: 'desc' }, + }) + return { + items: orders.map((o) => ({ + id: o.id, + status: o.status, + totalCents: o.totalCents, + currency: o.currency, + createdAt: o.createdAt, + updatedAt: o.updatedAt, + itemsCount: o.items.reduce((s, i) => s + i.qty, 0), + })), + } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось загрузить заказы' }) } }) fastify.get('/api/me/orders/:id', { preHandler: [fastify.authenticate] }, async (request, reply) => { - const userId = request.user.sub - const { id } = request.params - const order = await prisma.order.findFirst({ - where: { id, userId }, - include: { items: true, messages: { orderBy: { createdAt: 'asc' } } }, - }) - if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) - return { item: order } + try { + const userId = request.user.sub + const { id } = request.params + const order = await prisma.order.findFirst({ + where: { id, userId }, + include: { items: true, messages: { orderBy: { createdAt: 'asc' } } }, + }) + if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) + return { item: order } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось загрузить заказ' }) + } }) fastify.get( @@ -251,19 +261,24 @@ export async function registerUserOrderRoutes(fastify) { '/api/me/orders/:id/confirm-received', { preHandler: [fastify.authenticate] }, async (request, reply) => { - const userId = request.user.sub - const { id } = request.params - const order = await prisma.order.findFirst({ where: { id, userId } }) - if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) + try { + const userId = request.user.sub + const { id } = request.params + const order = await prisma.order.findFirst({ where: { id, userId } }) + if (!order) return reply.code(404).send({ error: 'Заказ не найден' }) - const okDelivery = order.deliveryType === 'delivery' && order.status === 'SHIPPED' - const okPickup = order.deliveryType === 'pickup' && order.status === 'READY_FOR_PICKUP' - if (!okDelivery && !okPickup) { - return reply.code(409).send({ error: 'Сейчас нельзя подтвердить получение заказа' }) + const okDelivery = order.deliveryType === 'delivery' && order.status === 'SHIPPED' + const okPickup = order.deliveryType === 'pickup' && order.status === 'READY_FOR_PICKUP' + if (!okDelivery && !okPickup) { + return reply.code(409).send({ error: 'Сейчас нельзя подтвердить получение заказа' }) + } + + await prisma.order.update({ where: { id }, data: { status: 'DONE' } }) + return { ok: true, status: 'DONE' } + } catch (err) { + request.log.error(err) + return reply.code(500).send({ error: 'Не удалось подтвердить получение' }) } - - await prisma.order.update({ where: { id }, data: { status: 'DONE' } }) - return { ok: true, status: 'DONE' } }, ) } From d056399b3b90fb87a5adf1ebab769304e2c0a357 Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 21 May 2026 14:32:45 +0500 Subject: [PATCH 3/3] test commit --- client/src/app/layout/MainLayout.tsx | 4 +++ .../pages/admin-layout/ui/AdminLayoutPage.tsx | 4 +++ client/src/pages/me/ui/MeLayoutPage.tsx | 4 +++ client/src/shared/lib/avatar-styles.ts | 2 +- client/src/shared/ui/ScrollOnNavigate.tsx | 12 +++++++ client/src/shared/ui/ScrollToTop.tsx | 31 ++++++++++++++++++ server/prisma/prisma/dev.db | Bin 364544 -> 364544 bytes 7 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 client/src/shared/ui/ScrollOnNavigate.tsx create mode 100644 client/src/shared/ui/ScrollToTop.tsx diff --git a/client/src/app/layout/MainLayout.tsx b/client/src/app/layout/MainLayout.tsx index 04900e2..00208a0 100644 --- a/client/src/app/layout/MainLayout.tsx +++ b/client/src/app/layout/MainLayout.tsx @@ -10,12 +10,16 @@ import Typography from '@mui/material/Typography' import { Link as RouterLink } from 'react-router-dom' import { AppHeader } from '@/app/layout/AppHeader' import { STORE_EMAIL, STORE_NAME, STORE_PHONE, VK_URL } from '@/shared/config' +import { ScrollOnNavigate } from '@/shared/ui/ScrollOnNavigate' +import { ScrollToTop } from '@/shared/ui/ScrollToTop' export function MainLayout({ children }: PropsWithChildren) { const year = new Date().getFullYear() return ( + + diff --git a/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx b/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx index 90c5cb1..29c1ff5 100644 --- a/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx +++ b/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx @@ -25,6 +25,8 @@ import { AdminProductsPage } from '@/pages/admin-products' import { AdminReviewsPage } from '@/pages/admin-reviews' import { AdminUsersPage } from '@/pages/admin-users' import { $user } from '@/shared/model/auth' +import { ScrollOnNavigate } from '@/shared/ui/ScrollOnNavigate' +import { ScrollToTop } from '@/shared/ui/ScrollToTop' import { AdminNotificationsPage } from './AdminNotificationsPage' type NavItem = { @@ -124,6 +126,8 @@ export function AdminLayoutPage() { return ( + + {isMobile ? ( <> diff --git a/client/src/pages/me/ui/MeLayoutPage.tsx b/client/src/pages/me/ui/MeLayoutPage.tsx index 385a927..9cff739 100644 --- a/client/src/pages/me/ui/MeLayoutPage.tsx +++ b/client/src/pages/me/ui/MeLayoutPage.tsx @@ -26,6 +26,8 @@ import { OrderDetailPage } from '@/pages/me/ui/sections/OrderDetailPage' import { OrdersPage } from '@/pages/me/ui/sections/OrdersPage' import { SettingsPage } from '@/pages/me/ui/sections/SettingsPage' import { $user } from '@/shared/model/auth' +import { ScrollOnNavigate } from '@/shared/ui/ScrollOnNavigate' +import { ScrollToTop } from '@/shared/ui/ScrollToTop' type NavItem = { to: string @@ -128,6 +130,8 @@ export function MeLayoutPage() { return ( + + {isMobile ? ( <> diff --git a/client/src/shared/lib/avatar-styles.ts b/client/src/shared/lib/avatar-styles.ts index a3e3bec..ddd0290 100644 --- a/client/src/shared/lib/avatar-styles.ts +++ b/client/src/shared/lib/avatar-styles.ts @@ -81,7 +81,7 @@ export const AVATAR_STYLES: StyleDef[] = [ }, ] -export const DEFAULT_STYLE_ID = 'bottts' +export const DEFAULT_STYLE_ID = 'avataaars' export function getStyleById(id: string | null | undefined): StyleDef { return AVATAR_STYLES.find((s) => s.id === id) ?? AVATAR_STYLES[0] diff --git a/client/src/shared/ui/ScrollOnNavigate.tsx b/client/src/shared/ui/ScrollOnNavigate.tsx new file mode 100644 index 0000000..3925696 --- /dev/null +++ b/client/src/shared/ui/ScrollOnNavigate.tsx @@ -0,0 +1,12 @@ +import { useEffect } from 'react' +import { useLocation } from 'react-router-dom' + +export function ScrollOnNavigate() { + const { pathname } = useLocation() + + useEffect(() => { + window.scrollTo(0, 0) + }, [pathname]) + + return null +} diff --git a/client/src/shared/ui/ScrollToTop.tsx b/client/src/shared/ui/ScrollToTop.tsx new file mode 100644 index 0000000..4d521fc --- /dev/null +++ b/client/src/shared/ui/ScrollToTop.tsx @@ -0,0 +1,31 @@ +import Fab from '@mui/material/Fab' +import useScrollTrigger from '@mui/material/useScrollTrigger' +import Zoom from '@mui/material/Zoom' +import { ArrowUp } from 'lucide-react' + +export function ScrollToTop() { + const trigger = useScrollTrigger({ threshold: 400, disableHysteresis: true }) + + const handleClick = () => { + window.scrollTo({ top: 0, behavior: 'smooth' }) + } + + return ( + + + + + + ) +} diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index 01bce42cf6c134bcfbb8aa874ddefdab047c0dc2..10de6af9e72246757d55f061e46b93998eae7136 100644 GIT binary patch delta 11273 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 qT`A5&dh$2(tKQ;5YdGFQ3KYO`b zF=kW7?8!cB%2=E|v)I(q+6TtsBWL~yx#<(LWmY&=w5FBk_CNTd_|rt!@Z!_c{Yw`>y8mi&ORp$e zdX-|ln=;-t{s2~fZ7d#rVzqdCqN`u;+NADWEzUi!3@$AzmnzLm!>W=-eLp#MkHWthcXabu|dvFBHty4LNa+04xY@0;RM zqzdSO>Q7nWyAF^MYWdtr9uyS2EA zFW8sEQe3FlXKJ@>-@Za_d-zaT53A(J%oI@Ywst6KQ>oRH34otcur)j=K8nSgz8oC`ziH1MrNF4 zd@B=-w{|WU)S+)4KYpC7l)o7)OwL2ok?!bzmFcEph_4 ze$jJ5`-XB?)4rfBH`el!Nj%^533bJ;1%2bsCmzu?@%ZnwwPJih_rptyO#;->?%R&Wo^fJfeDW;yh%G%2BY;C) z=xyRT(D@{R!y}W+0OrK;O^5g>11LGXbDC+uMtJdvlc3~?hY=U0;20M6uFN_(IrU7! z0$ed+ixjkcn350(qoGd%1lH7zK`i&!9vc4rF=vA%71f?3y>}M#BbC2@gd5h*$vIbqEC?aoI|cSU?D4 zCZI6kU|3MRERcve(T<&<1QKxMBqN~SiHMW1;v&8_D&PuDx?rgRz4UwN5S&Ze4jTYQ zdXKnSP%htXR|X6)LnSj(af=Guk6Q7B8i1?-${LXnp)^5cLWo%Gfux+u+AE$tudNcd zEa+>S|NN0|DBrRfR>}+?&+u$itVX36G(~esDQ0jy^nEnn8YjbQGP1>+A8YIPL6Fs>K`yNmwf5O5xZc=89}g3H>2fY_))k_qZ$ z8BVk`z`KcOIH~~t{g(eIPA7rj5g)@nsSjXQF7PDRlAucx9>WxMgG#qV&|?sL5JfaX zXCpI-F%Jrabhk~`VaW+#S_T{wAKkBS5D%Z#MjB-Y4vecDPJ+?_=rSP|+)(0&nRa+3 z=V4eyXaspJw*eSgsyrxJt-2N+5{31{M|^V$t!;jFfyQJD#&V!aibva%s{ zxgfVy- zey)29+VA!Rh0p(RzaHIU!k@ML1h7|p0?0q66`vd%hsts!Zvt2^eJF{yS$E1w@#J}J gpxX^{EcWmNWCU}1bNXYwlhQT(*lT{?(3SMR0W2c