test commit

This commit is contained in:
Kirill
2026-05-21 13:39:45 +05:00
parent a176955521
commit 058fa26e12
18 changed files with 563 additions and 45 deletions
+222 -1
View File
@@ -8,6 +8,23 @@
"name": "client", "name": "client",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "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/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.0.0", "@mui/icons-material": "^9.0.0",
@@ -465,6 +482,211 @@
"node": ">=18" "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": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@@ -3050,7 +3272,6 @@
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
+17
View File
@@ -15,6 +15,23 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "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/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.0.0", "@mui/icons-material": "^9.0.0",
@@ -37,7 +37,7 @@ export type AdminOrderDetailResponse = {
comment: string | null comment: string | null
createdAt: string createdAt: string
updatedAt: string updatedAt: string
user: { id: string; email: string; displayName: string | null; phone: string | null } user: { id: string; email: string; displayName: string | null }
items: Array<{ items: Array<{
id: string id: string
productId: string productId: string
@@ -11,14 +11,18 @@ import type { PublicProductReviewItem } from '@/entities/review/api/reviews-api'
import { reviewsCountRu } from '@/shared/lib/reviews-count-ru' import { reviewsCountRu } from '@/shared/lib/reviews-count-ru'
import { OptimizedImage } from '@/shared/ui/OptimizedImage' import { OptimizedImage } from '@/shared/ui/OptimizedImage'
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent' import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
import { UserAvatar } from '@/shared/ui/UserAvatar'
function ReviewItem({ rv }: { rv: PublicProductReviewItem }) { function ReviewItem({ rv }: { rv: PublicProductReviewItem }) {
const body = typeof rv.text === 'string' && rv.text.trim() ? rv.text.trim() : null const body = typeof rv.text === 'string' && rv.text.trim() ? rv.text.trim() : null
return ( return (
<Paper variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}> <Paper variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
<Stack spacing={0.75}> <Stack spacing={0.75}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ justifyContent: 'space-between' }}> <Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
<Typography sx={{ fontWeight: 700 }}>{rv.authorDisplay}</Typography> <UserAvatar userId={rv.authorDisplay} avatarUrl={null} avatarType={null} avatarStyle={null} size={32} />
<Box sx={{ flexGrow: 1 }}>
<Typography sx={{ fontWeight: 700 }}>{rv.authorDisplay}</Typography>
</Box>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
{new Date(rv.createdAt).toLocaleString('ru-RU')} {new Date(rv.createdAt).toLocaleString('ru-RU')}
</Typography> </Typography>
@@ -4,8 +4,8 @@ import IconButton from '@mui/material/IconButton'
import ListItemText from '@mui/material/ListItemText' import ListItemText from '@mui/material/ListItemText'
import Menu from '@mui/material/Menu' import Menu from '@mui/material/Menu'
import MenuItem from '@mui/material/MenuItem' import MenuItem from '@mui/material/MenuItem'
import { User } from 'lucide-react'
import type { AuthUser } from '@/shared/model/auth' import type { AuthUser } from '@/shared/model/auth'
import { UserAvatar } from '@/shared/ui/UserAvatar'
type Props = { type Props = {
user: AuthUser | null user: AuthUser | null
@@ -40,7 +40,17 @@ export function UserMenu({ user, onNavigate, onLogout }: Props) {
invisible={!user} invisible={!user}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
> >
<User /> {user ? (
<UserAvatar
userId={user.id}
avatarUrl={user.avatar}
avatarType={user.avatarType}
avatarStyle={user.avatarStyle}
size={28}
/>
) : (
<UserAvatar userId="guest" avatarUrl={null} avatarType={null} avatarStyle={null} size={28} />
)}
</Badge> </Badge>
</IconButton> </IconButton>
+8 -1
View File
@@ -16,7 +16,14 @@ import { $user, tokenSet } from '@/shared/model/auth'
type AuthResponse = { type AuthResponse = {
token: string 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 { function getApiErrorMessage(err: unknown): string | null {
+126 -10
View File
@@ -1,12 +1,19 @@
import { useState } from 'react'
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Divider from '@mui/material/Divider' 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 Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField' import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { createAvatar } from '@dicebear/core'
import { useUnit } from 'effector-react' import { useUnit } from 'effector-react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { AVATAR_STYLES, DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles'
import { import {
$requestEmailChangeCodeError, $requestEmailChangeCodeError,
$updateProfileError, $updateProfileError,
@@ -16,6 +23,7 @@ import {
updateProfileFx, updateProfileFx,
verifyEmailChangeFx, verifyEmailChangeFx,
} from '@/shared/model/auth' } from '@/shared/model/auth'
import { UserAvatar } from '@/shared/ui/UserAvatar'
import type { AxiosError } from 'axios' import type { AxiosError } from 'axios'
function getApiErrorMessage(error: unknown): string | null { function getApiErrorMessage(error: unknown): string | null {
@@ -38,10 +46,9 @@ export function SettingsPage() {
mode: 'onChange', mode: 'onChange',
}) })
const profileForm = useForm<{ displayName: string; phone: string }>({ const profileForm = useForm<{ displayName: string }>({
defaultValues: { defaultValues: {
displayName: user?.displayName ? String(user.displayName) : '', displayName: user?.displayName ? String(user.displayName) : '',
phone: user?.phone ? String(user.phone) : '',
}, },
mode: 'onChange', mode: 'onChange',
}) })
@@ -49,6 +56,16 @@ export function SettingsPage() {
const emailErrorMsg = getApiErrorMessage(errorEmailReq) ?? getApiErrorMessage(errorEmailVerify) const emailErrorMsg = getApiErrorMessage(errorEmailReq) ?? getApiErrorMessage(errorEmailVerify)
const profileErrorMsg = getApiErrorMessage(errorProfile) 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<string | null>(null)
const [previewStyle, setPreviewStyle] = useState<string>(DEFAULT_STYLE_ID)
const hasUnsavedPreview = previewSrc !== null
if (!user) { if (!user) {
return <Alert severity="info">Нужно войти. Перейдите на страницу «Вход».</Alert> return <Alert severity="info">Нужно войти. Перейдите на страницу «Вход».</Alert>
} }
@@ -85,20 +102,13 @@ export function SettingsPage() {
slotProps={{ htmlInput: { maxLength: 40 } }} slotProps={{ htmlInput: { maxLength: 40 } }}
{...profileForm.register('displayName')} {...profileForm.register('displayName')}
/> />
<TextField
label="Телефон"
helperText="Можно указать для связи по заказам"
{...profileForm.register('phone')}
/>
<Button <Button
variant="contained" variant="contained"
disabled={pendingProfile} disabled={pendingProfile}
onClick={() => { onClick={() => {
const raw = profileForm.getValues('displayName') const raw = profileForm.getValues('displayName')
const name = raw.trim() const name = raw.trim()
const phoneRaw = profileForm.getValues('phone') updateProfileFx({ displayName: name.length ? name : null })
const phone = phoneRaw.trim()
updateProfileFx({ displayName: name.length ? name : null, phone: phone.length ? phone : null })
}} }}
> >
Сохранить Сохранить
@@ -108,6 +118,112 @@ export function SettingsPage() {
<Divider /> <Divider />
<Box>
<Typography variant="h6" gutterBottom>
Аватар
</Typography>
<Stack direction="row" spacing={3} sx={{ alignItems: 'flex-start', mb: 2 }}>
<Box sx={{ textAlign: 'center' }}>
<UserAvatar
userId={user.id}
avatarUrl={hasUnsavedPreview ? previewSrc : user.avatar}
avatarType={hasUnsavedPreview ? 'generated' : user.avatarType}
avatarStyle={hasUnsavedPreview ? previewStyle : user.avatarStyle}
size={80}
sx={{
border: 2,
borderColor: hasUnsavedPreview ? 'warning.main' : 'primary.main',
}}
/>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
{hasUnsavedPreview ? 'Предпросмотр' : useOAuth ? 'Сохранён' : useGenerated ? 'Сохранён' : 'Авто'}
</Typography>
</Box>
{hasUnsavedPreview && (
<Box sx={{ textAlign: 'center' }}>
<UserAvatar
userId={user.id}
avatarUrl={user.avatar}
avatarType={user.avatarType}
avatarStyle={user.avatarStyle}
size={80}
sx={{ border: 2, borderColor: 'divider', opacity: 0.6 }}
/>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
Текущий
</Typography>
</Box>
)}
</Stack>
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 1, mb: 1 }}>
<FormControl size="small" sx={{ minWidth: 140 }}>
<InputLabel>Стиль</InputLabel>
<Select value={selectedStyle} label="Стиль" onChange={(e) => setSelectedStyle(e.target.value)}>
{AVATAR_STYLES.map((s) => (
<MenuItem key={s.id} value={s.id}>
{s.label}
</MenuItem>
))}
</Select>
</FormControl>
<Button
variant="outlined"
onClick={() => {
const seed = `${user.id}_${Date.now()}`
const styleDef = getStyleById(selectedStyle)
const avatar = createAvatar(styleDef.style, { seed })
setPreviewSrc(avatar.toDataUri())
setPreviewStyle(selectedStyle)
}}
>
Сгенерировать
</Button>
</Stack>
{hasUnsavedPreview && (
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 1, mb: 1 }}>
<Button
variant="contained"
disabled={pendingProfile}
onClick={() => {
updateProfileFx({
displayName: user.displayName?.trim() || null,
avatar: previewSrc,
avatarType: 'generated',
avatarStyle: previewStyle,
})
setPreviewSrc(null)
}}
>
Сохранить
</Button>
<Button variant="text" onClick={() => setPreviewSrc(null)}>
Отмена
</Button>
</Stack>
)}
{hasOAuthAvatar && !hasUnsavedPreview && (
<Button
variant="outlined"
disabled={pendingProfile || useOAuth}
onClick={() => {
updateProfileFx({
displayName: user.displayName?.trim() || null,
avatarType: 'oauth',
})
}}
sx={{ mt: 0.5 }}
>
Использовать OAuth
</Button>
)}
</Box>
<Divider />
<Box> <Box>
<Typography variant="h6" gutterBottom> <Typography variant="h6" gutterBottom>
Смена почты Смена почты
+88
View File
@@ -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<any>
}
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]
}
+8 -2
View File
@@ -11,7 +11,8 @@ export type AuthUser = {
lastName?: string | null lastName?: string | null
gender?: string | null gender?: string | null
avatar?: string | null avatar?: string | null
phone?: string | null avatarType?: string | null
avatarStyle?: string | null
isAdmin?: boolean isAdmin?: boolean
} }
@@ -68,7 +69,12 @@ export const verifyEmailChangeFx = createEffect(async (params: { newEmail: strin
// ----- Profile update ----- // ----- 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) => { export const updateProfileFx = createEffect(async (params: UpdateProfileParams) => {
const { data } = await apiClient.patch<{ user: AuthUser }>('me/profile', params) const { data } = await apiClient.patch<{ user: AuthUser }>('me/profile', params)
+30
View File
@@ -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<Theme>
}
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 (
<Avatar src={src} sx={{ width: size, height: size, bgcolor: 'primary.main', ...sx }}>
?
</Avatar>
)
}
@@ -1,6 +1,5 @@
import StarRoundedIcon from '@mui/icons-material/StarRounded' import StarRoundedIcon from '@mui/icons-material/StarRounded'
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import Avatar from '@mui/material/Avatar'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Paper from '@mui/material/Paper' import Paper from '@mui/material/Paper'
import Rating from '@mui/material/Rating' 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 { fetchLatestApprovedReviews } from '@/entities/review/api/reviews-api'
import { OptimizedImage } from '@/shared/ui/OptimizedImage' import { OptimizedImage } from '@/shared/ui/OptimizedImage'
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent' import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
import { UserAvatar } from '@/shared/ui/UserAvatar'
function initials(display: string) {
const s = display.trim()
if (!s) return '?'
return s.slice(0, 1).toUpperCase()
}
function formatReviewDate(iso: string): string { function formatReviewDate(iso: string): string {
try { try {
@@ -107,9 +101,13 @@ export function ReviewsBlock() {
</Box> </Box>
)} )}
<Stack direction="row" spacing={1.5} sx={{ minWidth: { sm: 200 }, alignItems: 'center' }}> <Stack direction="row" spacing={1.5} sx={{ minWidth: { sm: 200 }, alignItems: 'center' }}>
<Avatar sx={{ bgcolor: 'primary.main', color: 'primary.contrastText', fontWeight: 800 }}> <UserAvatar
{initials(r.authorDisplay)} userId={r.authorDisplay}
</Avatar> avatarUrl={null}
avatarType={null}
avatarStyle={null}
size={40}
/>
<Box> <Box>
<Typography sx={{ fontWeight: 800, lineHeight: 1.15 }}>{r.authorDisplay}</Typography> <Typography sx={{ fontWeight: 800, lineHeight: 1.15 }}>{r.authorDisplay}</Typography>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', flexWrap: 'wrap' }}> <Stack direction="row" spacing={1} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
@@ -0,0 +1,2 @@
ALTER TABLE User DROP COLUMN phone;
ALTER TABLE User ADD COLUMN "avatarType" TEXT;
@@ -0,0 +1 @@
ALTER TABLE User ADD COLUMN "avatarStyle" TEXT;
Binary file not shown.
+2 -1
View File
@@ -82,7 +82,8 @@ model User {
lastName String? lastName String?
gender String? gender String?
avatar String? avatar String?
phone String? avatarType String?
avatarStyle String?
passwordHash String? passwordHash String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
+1 -1
View File
@@ -73,7 +73,7 @@ export async function registerAdminOrderRoutes(fastify) {
const order = await prisma.order.findUnique({ const order = await prisma.order.findUnique({
where: { id }, where: { id },
include: { include: {
user: { select: { id: true, email: true, displayName: true, phone: true } }, user: { select: { id: true, email: true, displayName: true } },
items: true, items: true,
messages: { orderBy: { createdAt: 'asc' } }, messages: { orderBy: { createdAt: 'asc' } },
}, },
+30 -13
View File
@@ -13,7 +13,8 @@ function mapUserForClient(user) {
lastName: user.lastName, lastName: user.lastName,
gender: user.gender, gender: user.gender,
avatar: user.avatar, avatar: user.avatar,
phone: user.phone, avatarType: user.avatarType,
avatarStyle: user.avatarStyle,
isAdmin: Boolean(adminEmail) && userEmail === adminEmail, isAdmin: Boolean(adminEmail) && userEmail === adminEmail,
} }
} }
@@ -119,25 +120,41 @@ export async function registerAuthRoutes(fastify) {
const userId = request.user.sub const userId = request.user.sub
const nameRaw = request.body?.displayName const nameRaw = request.body?.displayName
const displayName = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim() const displayName = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
const phoneRaw = request.body?.phone const avatarRaw = request.body?.avatar
const phone = phoneRaw === null || phoneRaw === undefined ? null : String(phoneRaw).trim() 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) if (displayName !== null && displayName.length > 40)
return reply.code(400).send({ error: 'Имя/ник максимум 40 символов' }) return reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
if (phone !== null) { if (avatarType !== undefined && avatarType !== 'oauth' && avatarType !== 'generated' && avatarType !== '') {
const compact = phone.replace(/[\s()-]/g, '') return reply.code(400).send({ error: 'avatarType должен быть oauth | generated | пустая строка' })
if (compact.length > 20) return reply.code(400).send({ error: 'Телефон слишком длинный' }) }
if (compact.length && !/^\+?\d{7,20}$/.test(compact)) { if (avatar !== undefined && avatar.length > 200000) return reply.code(400).send({ error: 'Аватар слишком большой' })
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({ const updated = await prisma.user.update({
where: { id: userId }, where: { id: userId },
data: { data,
displayName: displayName && displayName.length ? displayName : null,
phone: phone && phone.length ? phone : null,
},
}) })
return { user: mapUserForClient(updated) } return { user: mapUserForClient(updated) }
}) })
+1 -1
View File
@@ -60,7 +60,7 @@ export async function registerUserPaymentRoutes(fastify) {
const receipt = buildReceipt({ const receipt = buildReceipt({
orderItems: order.items, orderItems: order.items,
deliveryFeeCents: order.deliveryFeeCents, deliveryFeeCents: order.deliveryFeeCents,
userEmail: userEmail, userEmail: userEmail,
}) })
let result let result