test commit
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<Paper variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
|
||||
<Stack spacing={0.75}>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ justifyContent: 'space-between' }}>
|
||||
<Typography sx={{ fontWeight: 700 }}>{rv.authorDisplay}</Typography>
|
||||
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
|
||||
<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">
|
||||
{new Date(rv.createdAt).toLocaleString('ru-RU')}
|
||||
</Typography>
|
||||
|
||||
@@ -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 />
|
||||
{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>
|
||||
</IconButton>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
const [previewStyle, setPreviewStyle] = useState<string>(DEFAULT_STYLE_ID)
|
||||
|
||||
const hasUnsavedPreview = previewSrc !== null
|
||||
|
||||
if (!user) {
|
||||
return <Alert severity="info">Нужно войти. Перейдите на страницу «Вход».</Alert>
|
||||
}
|
||||
@@ -85,20 +102,13 @@ export function SettingsPage() {
|
||||
slotProps={{ htmlInput: { maxLength: 40 } }}
|
||||
{...profileForm.register('displayName')}
|
||||
/>
|
||||
<TextField
|
||||
label="Телефон"
|
||||
helperText="Можно указать для связи по заказам"
|
||||
{...profileForm.register('phone')}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={pendingProfile}
|
||||
onClick={() => {
|
||||
const raw = profileForm.getValues('displayName')
|
||||
const name = raw.trim()
|
||||
const phoneRaw = profileForm.getValues('phone')
|
||||
const phone = phoneRaw.trim()
|
||||
updateProfileFx({ displayName: name.length ? name : null, phone: phone.length ? phone : null })
|
||||
updateProfileFx({ displayName: name.length ? name : null })
|
||||
}}
|
||||
>
|
||||
Сохранить
|
||||
@@ -108,6 +118,112 @@ export function SettingsPage() {
|
||||
|
||||
<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>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Смена почты
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 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() {
|
||||
</Box>
|
||||
)}
|
||||
<Stack direction="row" spacing={1.5} sx={{ minWidth: { sm: 200 }, alignItems: 'center' }}>
|
||||
<Avatar sx={{ bgcolor: 'primary.main', color: 'primary.contrastText', fontWeight: 800 }}>
|
||||
{initials(r.authorDisplay)}
|
||||
</Avatar>
|
||||
<UserAvatar
|
||||
userId={r.authorDisplay}
|
||||
avatarUrl={null}
|
||||
avatarType={null}
|
||||
avatarStyle={null}
|
||||
size={40}
|
||||
/>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 800, lineHeight: 1.15 }}>{r.authorDisplay}</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
|
||||
Reference in New Issue
Block a user