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 c72d838..8a10718 100644 Binary files a/server/prisma/prisma/dev.db and b/server/prisma/prisma/dev.db differ diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 60d79a7..3bc4946 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -82,7 +82,8 @@ model User { lastName String? gender String? avatar String? - phone String? + avatarType String? + avatarStyle String? passwordHash String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/server/src/routes/api/admin-orders.js b/server/src/routes/api/admin-orders.js index 8db819f..3092e76 100644 --- a/server/src/routes/api/admin-orders.js +++ b/server/src/routes/api/admin-orders.js @@ -73,7 +73,7 @@ export async function registerAdminOrderRoutes(fastify) { const order = await prisma.order.findUnique({ where: { id }, include: { - user: { select: { id: true, email: true, displayName: true, phone: true } }, + user: { select: { id: true, email: true, displayName: true } }, items: true, messages: { orderBy: { createdAt: 'asc' } }, }, diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index 385371f..3e25fe5 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -13,7 +13,8 @@ function mapUserForClient(user) { lastName: user.lastName, gender: user.gender, avatar: user.avatar, - phone: user.phone, + avatarType: user.avatarType, + avatarStyle: user.avatarStyle, isAdmin: Boolean(adminEmail) && userEmail === adminEmail, } } @@ -119,25 +120,41 @@ export async function registerAuthRoutes(fastify) { const userId = request.user.sub const nameRaw = request.body?.displayName const displayName = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim() - const phoneRaw = request.body?.phone - const phone = phoneRaw === null || phoneRaw === undefined ? null : String(phoneRaw).trim() + 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 avatarStyleRaw = request.body?.avatarStyle + const avatarStyle = + avatarStyleRaw === null || avatarStyleRaw === undefined ? undefined : String(avatarStyleRaw).trim() if (displayName !== null && displayName.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: 'Некорректный телефон' }) - } + 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