Merge branch 'fixes'

This commit is contained in:
Kirill
2026-05-21 21:17:29 +05:00
23 changed files with 2008 additions and 79 deletions
+2 -4
View File
@@ -146,12 +146,10 @@ export function AppHeader() {
</>
)}
{!isAdmin && <UserMenu user={user} onNavigate={navigate} onLogout={onLogout} />}
{!isAdmin && <UserMenu user={user} isAdmin={false} onNavigate={navigate} onLogout={onLogout} />}
{isAdmin && user && !isMobile && (
<Button color="inherit" onClick={onLogout} sx={{ ml: 1 }}>
Выход
</Button>
<UserMenu user={user} isAdmin={true} onNavigate={navigate} onLogout={onLogout} />
)}
{!isMobile && (
@@ -37,7 +37,14 @@ export type AdminOrderDetailResponse = {
comment: string | null
createdAt: string
updatedAt: string
user: { id: string; email: string; displayName: string | null }
user: {
id: string
email: string
displayName: string | null
avatar?: string | null
avatarType?: string | null
avatarStyle?: string | null
}
items: Array<{
id: string
productId: string
@@ -139,6 +139,7 @@ export function ProductCard({ product, mediaHeight = 200, actions }: Props) {
position: 'absolute',
top: 8,
left: 8,
zIndex: 2,
fontWeight: 600,
fontSize: '0.7rem',
backdropFilter: 'blur(4px)',
+12 -2
View File
@@ -26,8 +26,15 @@ export type PublicReviewFeedItem = {
imageUrl: string | null
createdAt: string
authorDisplay: string
productId: string
productTitle: string
authorAvatar?: string | null
authorAvatarType?: string | null
authorAvatarStyle?: string | null
product: {
id: string
title: string
published: boolean
slug: string
}
}
export type PublicReviewsLatestResponse = {
@@ -48,6 +55,9 @@ export type PublicProductReviewItem = {
imageUrl: string | null
createdAt: string
authorDisplay: string
authorAvatar?: string | null
authorAvatarType?: string | null
authorAvatarStyle?: string | null
}
export type PublicProductReviewsResponse = {
+2 -2
View File
@@ -17,14 +17,14 @@ export async function fetchAdminUsers(params?: {
return data
}
export async function createAdminUser(body: { email: string; name?: string | null }): Promise<AdminUser> {
export async function createAdminUser(body: { email: string; displayName?: string | null }): Promise<AdminUser> {
const { data } = await apiClient.post<AdminUser>('admin/users', body)
return data
}
export async function updateAdminUser(
id: string,
body: Partial<{ email: string; name: string | null }>,
body: Partial<{ email: string; displayName: string | null }>,
): Promise<AdminUser> {
const { data } = await apiClient.patch<AdminUser>(`admin/users/${id}`, body)
return data
+4 -1
View File
@@ -1,7 +1,10 @@
export type AdminUser = {
id: string
email: string
name: string | null
displayName: string | null
avatar?: string | null
avatarType?: string | null
avatarStyle?: string | null
createdAt: string
updatedAt: string
}
@@ -3,9 +3,12 @@ import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { useUnit } from 'effector-react'
import { $user } from '@/shared/model/auth'
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
import { UserAvatar } from '@/shared/ui/UserAvatar'
type Message = {
id: string
@@ -24,6 +27,7 @@ type Props = {
export function OrderChat({ messages, isPending, onSend }: Props) {
const [text, setText] = useState('')
const canSend = text.replace(/<[^>]*>/g, ' ').trim().length > 0
const currentUser = useUnit($user)
const handleSend = () => {
if (!canSend || isPending) return
@@ -37,14 +41,28 @@ export function OrderChat({ messages, isPending, onSend }: Props) {
Чат по заказу
</Typography>
<Stack spacing={1} sx={{ mb: 2 }}>
{messages.map((m) => (
<ChatMessageBubble key={m.id} authorType={m.authorType === 'admin' ? 'admin' : 'user'}>
{messages.map((m) => {
const isAdminMsg = m.authorType === 'admin'
const avatarNode = isAdminMsg ? (
<UserAvatar userId="admin" avatarUrl={null} avatarType={null} avatarStyle={null} size={24} />
) : currentUser ? (
<UserAvatar
userId={currentUser.id}
avatarUrl={currentUser.avatar}
avatarType={currentUser.avatarType}
avatarStyle={currentUser.avatarStyle}
size={24}
/>
) : null
return (
<ChatMessageBubble key={m.id} authorType={isAdminMsg ? 'admin' : 'user'} avatar={avatarNode}>
<Typography variant="caption" color="text.secondary">
{m.authorType === 'admin' ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()}
{isAdminMsg ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()}
</Typography>
<OrderMessageBody text={m.text} attachmentUrl={m.attachmentUrl} imageAlt="Чек или вложение" />
</ChatMessageBubble>
))}
)
})}
{messages.length === 0 && <Typography color="text.secondary">Пока сообщений нет.</Typography>}
</Stack>
@@ -9,6 +9,7 @@ import Select from '@mui/material/Select'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { postAdminOrderMessage, setAdminOrderStatus } from '@/entities/order/api/admin-order-api'
import type { AdminOrderDetailResponse } from '@/entities/order/api/admin-order-api'
import { deliveryCarrierLabelRu } from '@/shared/constants/delivery-carrier'
@@ -17,9 +18,11 @@ import { formatPriceRub } from '@/shared/lib/format-price'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
import { parseOrderAddressSnapshot } from '@/shared/lib/order-address-snapshot'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
import { $user } from '@/shared/model/auth'
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
import { UserAvatar } from '@/shared/ui/UserAvatar'
import { DeliveryFeeAdjustmentForm } from './DeliveryFeeAdjustmentForm'
export function OrderDetailContent({ detail, orderId }: { detail: AdminOrderDetailResponse['item']; orderId: string }) {
@@ -56,6 +59,7 @@ export function OrderDetailContent({ detail, orderId }: { detail: AdminOrderDeta
)
const canSendMessage = msg.replace(/<[^>]*>/g, ' ').trim().length > 0
const currentUser = useUnit($user)
return (
<Stack spacing={2} sx={{ mt: 1 }}>
@@ -164,14 +168,36 @@ export function OrderDetailContent({ detail, orderId }: { detail: AdminOrderDeta
Сообщения
</Typography>
<Stack spacing={1} sx={{ mb: 1 }}>
{detail.messages.map((m) => (
<ChatMessageBubble key={m.id} authorType={m.authorType === 'admin' ? 'user' : 'admin'}>
{detail.messages.map((m) => {
const isAdminMsg = m.authorType === 'admin'
const avatarNode = isAdminMsg ? (
currentUser && (
<UserAvatar
userId={currentUser.id}
avatarUrl={currentUser.avatar}
avatarType={currentUser.avatarType}
avatarStyle={currentUser.avatarStyle}
size={24}
/>
)
) : (
<UserAvatar
userId={detail.user.id}
avatarUrl={detail.user.avatar}
avatarType={detail.user.avatarType}
avatarStyle={detail.user.avatarStyle}
size={24}
/>
)
return (
<ChatMessageBubble key={m.id} authorType={isAdminMsg ? 'user' : 'admin'} avatar={avatarNode}>
<Typography variant="caption" color="text.secondary">
{m.authorType === 'admin' ? 'Админ (вы)' : 'Пользователь'} · {new Date(m.createdAt).toLocaleString()}
{isAdminMsg ? 'Админ (вы)' : 'Пользователь'} · {new Date(m.createdAt).toLocaleString()}
</Typography>
<OrderMessageBody text={m.text} attachmentUrl={m.attachmentUrl} />
</ChatMessageBubble>
))}
)
})}
{detail.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
</Stack>
@@ -19,7 +19,13 @@ function ReviewItem({ rv }: { rv: PublicProductReviewItem }) {
<Paper variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
<Stack spacing={0.75}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
<UserAvatar userId={rv.authorDisplay} avatarUrl={null} avatarType={null} avatarStyle={null} size={32} />
<UserAvatar
userId={rv.authorDisplay}
avatarUrl={rv.authorAvatar}
avatarType={rv.authorAvatarType}
avatarStyle={rv.authorAvatarStyle}
size={32}
/>
<Box sx={{ flexGrow: 1 }}>
<Typography sx={{ fontWeight: 700 }}>{rv.authorDisplay}</Typography>
</Box>
@@ -1,4 +1,5 @@
import { useState } from 'react'
import PersonIcon from '@mui/icons-material/Person'
import Badge from '@mui/material/Badge'
import IconButton from '@mui/material/IconButton'
import ListItemText from '@mui/material/ListItemText'
@@ -9,11 +10,12 @@ import { UserAvatar } from '@/shared/ui/UserAvatar'
type Props = {
user: AuthUser | null
isAdmin?: boolean
onNavigate: (to: string) => void
onLogout: () => void
}
export function UserMenu({ user, onNavigate, onLogout }: Props) {
export function UserMenu({ user, isAdmin = false, onNavigate, onLogout }: Props) {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const open = Boolean(anchorEl)
@@ -49,7 +51,7 @@ export function UserMenu({ user, onNavigate, onLogout }: Props) {
size={28}
/>
) : (
<UserAvatar userId="guest" avatarUrl={null} avatarType={null} avatarStyle={null} size={28} />
<PersonIcon sx={{ fontSize: 28 }} />
)}
</Badge>
</IconButton>
@@ -63,8 +65,11 @@ export function UserMenu({ user, onNavigate, onLogout }: Props) {
>
{user ? (
<>
<MenuItem onClick={() => go('/me')}>
<ListItemText primary={(user.displayName && user.displayName.trim()) || user.email} secondary="Профиль" />
<MenuItem onClick={() => go(isAdmin ? '/admin/settings' : '/me')}>
<ListItemText
primary={(user.displayName && user.displayName.trim()) || user.email}
secondary={isAdmin ? 'Настройки' : 'Профиль'}
/>
</MenuItem>
<MenuItem onClick={handleLogout}>Выход</MenuItem>
</>
@@ -15,7 +15,7 @@ import Typography from '@mui/material/Typography'
import useMediaQuery from '@mui/material/useMediaQuery'
import { useQuery } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { Bell, Image, LayoutGrid, ListOrdered, MessageSquare, Store, Users } from 'lucide-react'
import { Bell, Image, LayoutGrid, ListOrdered, MessageSquare, Settings, Store, Users } from 'lucide-react'
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
import { fetchAdminOrdersSummary } from '@/entities/order/api/admin-order-api'
import { AdminCategoriesPage } from '@/pages/admin-categories'
@@ -23,6 +23,7 @@ import { AdminGalleryPage } from '@/pages/admin-gallery'
import { AdminOrdersPage } from '@/pages/admin-orders'
import { AdminProductsPage } from '@/pages/admin-products'
import { AdminReviewsPage } from '@/pages/admin-reviews'
import { AdminSettingsPage } from '@/pages/admin-settings'
import { AdminUsersPage } from '@/pages/admin-users'
import { $user } from '@/shared/model/auth'
import { ScrollOnNavigate } from '@/shared/ui/ScrollOnNavigate'
@@ -63,6 +64,7 @@ export function AdminLayoutPage() {
{ to: '/admin/reviews', label: 'Отзывы', icon: <MessageSquare /> },
{ to: '/admin/users', label: 'Пользователи', icon: <Users /> },
{ to: '/admin/notifications', label: 'Уведомления', icon: <Bell /> },
{ to: '/admin/settings', label: 'Настройки', icon: <Settings /> },
],
[],
)
@@ -192,6 +194,7 @@ export function AdminLayoutPage() {
<Route path="reviews" element={<AdminReviewsPage />} />
<Route path="users" element={<AdminUsersPage />} />
<Route path="notifications" element={<AdminNotificationsPage />} />
<Route path="settings" element={<AdminSettingsPage />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
</Routes>
</Box>
+1
View File
@@ -0,0 +1 @@
export { AdminSettingsPage } from './ui/AdminSettingsPage'
@@ -0,0 +1,247 @@
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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { useForm } from 'react-hook-form'
import { apiClient } from '@/shared/api/client'
import { AVATAR_STYLES, DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles'
import { $user, updateProfileFx } from '@/shared/model/auth'
import type { UpdateProfileParams } from '@/shared/model/auth'
import { UserAvatar } from '@/shared/ui/UserAvatar'
function getApiErrorMessage(error: unknown): string | null {
const e = error as { response?: { data?: { error?: string } } }
const msg = e?.response?.data?.error
return msg ? String(msg) : null
}
export function AdminSettingsPage() {
const user = useUnit($user)
const qc = useQueryClient()
const pendingProfile = useUnit(updateProfileFx.pending)
const {
data: profile,
isLoading,
isError,
} = useQuery({
queryKey: ['admin', 'profile'],
queryFn: async () => {
const { data } = await apiClient.get<{
id: string
email: string
displayName: string | null
avatar: string | null
avatarType: string | null
avatarStyle: string | null
}>('admin/profile')
return data
},
})
const profileForm = useForm<{ displayName: string }>({
defaultValues: { displayName: profile?.displayName ?? '' },
values: { displayName: profile?.displayName ?? '' },
mode: 'onChange',
})
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
const profileSaveMut = useMutation({
mutationFn: (params: {
displayName: string | null
avatar?: string | null
avatarType?: string | null
avatarStyle?: string | null
}) => apiClient.patch('admin/profile', params),
onSuccess: (_data, variables) => {
const p: UpdateProfileParams = { displayName: variables.displayName ?? null }
if (variables.avatar !== undefined) {
p.avatar = variables.avatar
p.avatarType = variables.avatarType ?? null
p.avatarStyle = variables.avatarStyle ?? null
}
updateProfileFx(p)
void qc.invalidateQueries({ queryKey: ['admin', 'profile'] })
},
})
const profileErrorMsg = getApiErrorMessage(profileSaveMut.error)
if (isLoading) return <Typography>Загрузка настроек</Typography>
if (isError) return <Alert severity="error">Не удалось загрузить настройки.</Alert>
if (!user) return <Alert severity="info">Нужно войти.</Alert>
return (
<Box>
<Typography variant="h4" gutterBottom>
Настройки
</Typography>
<Typography color="text.secondary" sx={{ mb: 3 }}>
Текущая почта: <b>{String(user.email)}</b>
</Typography>
{profileErrorMsg && (
<Alert severity="error" sx={{ mb: 2 }}>
{profileErrorMsg}
</Alert>
)}
<Stack spacing={3} sx={{ maxWidth: 560 }}>
<Box>
<Typography variant="h6" gutterBottom>
Профиль
</Typography>
<Stack spacing={2}>
<TextField
label="Имя или ник"
helperText="До 40 символов"
slotProps={{ htmlInput: { maxLength: 40 } }}
{...profileForm.register('displayName')}
/>
<Button
variant="contained"
disabled={pendingProfile || profileSaveMut.isPending}
onClick={() => {
const raw = profileForm.getValues('displayName')
const name = raw.trim()
profileSaveMut.mutate({ displayName: name.length ? name : null })
}}
>
Сохранить
</Button>
</Stack>
</Box>
<Divider />
<Box>
<Typography variant="h6" gutterBottom>
Аватар
</Typography>
<Stack direction="row" spacing={3} sx={{ alignItems: 'flex-start', mb: 2 }}>
<Box sx={{ textAlign: 'center' }}>
<UserAvatar
userId={String(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={String(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 = `${String(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 || profileSaveMut.isPending}
onClick={() => {
const raw = profileForm.getValues('displayName')
const name = raw.trim()
profileSaveMut.mutate({
displayName: name.length ? name : 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 || profileSaveMut.isPending || useOAuth}
onClick={() => {
const raw = profileForm.getValues('displayName')
const name = raw.trim()
profileSaveMut.mutate({
displayName: name.length ? name : null,
avatarType: 'oauth',
})
}}
sx={{ mt: 0.5 }}
>
Использовать OAuth
</Button>
)}
</Box>
</Stack>
</Box>
)
}
@@ -21,13 +21,14 @@ import { $user } from '@/shared/model/auth'
import { AdminDialog } from '@/shared/ui/AdminDialog/AdminDialog'
import { AdminTable } from '@/shared/ui/AdminTable'
import { EntityRowActions } from '@/shared/ui/EntityRowActions'
import { UserAvatar } from '@/shared/ui/UserAvatar'
type UserFormState = {
email: string
name: string
displayName: string
}
const emptyUserForm = (): UserFormState => ({ email: '', name: '' })
const emptyUserForm = (): UserFormState => ({ email: '', displayName: '' })
function formatDt(v: string) {
try {
@@ -77,7 +78,7 @@ export function AdminUsersPage() {
const v = userForm.getValues()
await createAdminUser({
email: v.email.trim(),
name: v.name.trim() || null,
displayName: v.displayName.trim() || null,
})
},
onSuccess: () => {
@@ -91,7 +92,7 @@ export function AdminUsersPage() {
const v = userForm.getValues()
await updateAdminUser(editing!.id, {
email: v.email.trim(),
name: v.name.trim() || null,
displayName: v.displayName.trim() || null,
})
},
onSuccess: () => {
@@ -118,7 +119,7 @@ export function AdminUsersPage() {
openEditDialog(u)
userForm.reset({
email: u.email,
name: u.name ?? '',
displayName: u.displayName ?? '',
})
}
@@ -171,6 +172,7 @@ export function AdminUsersPage() {
<AdminTable
columns={[
{ key: 'avatar', label: 'Аватар' },
{ key: 'email', label: 'Почта' },
{ key: 'name', label: 'Имя' },
{ key: 'createdAt', label: 'Создан' },
@@ -182,15 +184,24 @@ export function AdminUsersPage() {
>
{users.length === 0 && !usersQuery.isLoading ? (
<TableRow>
<TableCell colSpan={5} sx={{ color: 'text.secondary' }}>
<TableCell colSpan={6} sx={{ color: 'text.secondary' }}>
Пользователей пока нет.
</TableCell>
</TableRow>
) : (
users.map((u) => (
<TableRow key={u.id} hover>
<TableCell>
<UserAvatar
userId={u.id}
avatarUrl={u.avatar}
avatarType={u.avatarType}
avatarStyle={u.avatarStyle}
size={28}
/>
</TableCell>
<TableCell>{u.email}</TableCell>
<TableCell>{u.name ?? '—'}</TableCell>
<TableCell>{u.displayName ?? '—'}</TableCell>
<TableCell>{formatDt(u.createdAt)}</TableCell>
<TableCell>{formatDt(u.updatedAt)}</TableCell>
<TableCell align="right">
@@ -253,7 +264,7 @@ export function AdminUsersPage() {
/>
<Controller
control={userForm.control}
name="name"
name="displayName"
render={({ field }) => <TextField label="Имя/ник" fullWidth {...field} />}
/>
</Stack>
+22 -5
View File
@@ -1,29 +1,46 @@
import type { ReactNode } from 'react'
import Box from '@mui/material/Box'
import Stack from '@mui/material/Stack'
import { alpha } from '@mui/material/styles'
type Author = 'admin' | 'user'
export function ChatMessageBubble(props: { authorType: Author; children: ReactNode }) {
const { authorType, children } = props
type Props = {
authorType: Author
avatar?: ReactNode
children: ReactNode
}
export function ChatMessageBubble({ authorType, avatar, children }: Props) {
const isAdmin = authorType === 'admin'
return (
<Stack
direction="row"
spacing={1}
sx={{
alignSelf: isAdmin ? 'flex-start' : 'flex-end',
maxWidth: '85%',
alignItems: 'flex-end',
}}
>
{isAdmin && avatar && <Box sx={{ flexShrink: 0, pb: 0.5 }}>{avatar}</Box>}
<Box
sx={{
p: 1.25,
borderRadius: 2,
border: 1,
borderColor: 'divider',
alignSelf: authorType === 'admin' ? 'flex-start' : 'flex-end',
width: 'fit-content',
maxWidth: '85%',
color: 'text.primary',
bgcolor: (theme) =>
authorType === 'admin'
isAdmin
? alpha(theme.palette.grey[500], theme.palette.mode === 'dark' ? 0.28 : 0.14)
: alpha(theme.palette.primary.main, theme.palette.mode === 'dark' ? 0.28 : 0.1),
}}
>
{children}
</Box>
{!isAdmin && avatar && <Box sx={{ flexShrink: 0, pb: 0.5 }}>{avatar}</Box>}
</Stack>
)
}
@@ -103,9 +103,9 @@ export function ReviewsBlock() {
<Stack direction="row" spacing={1.5} sx={{ minWidth: { sm: 200 }, alignItems: 'center' }}>
<UserAvatar
userId={r.authorDisplay}
avatarUrl={null}
avatarType={null}
avatarStyle={null}
avatarUrl={r.authorAvatar}
avatarType={r.authorAvatarType}
avatarStyle={r.authorAvatarStyle}
size={40}
/>
<Box>
@@ -122,10 +122,11 @@ export function ReviewsBlock() {
{formatReviewDate(r.createdAt)}
</Typography>
</Stack>
{r.product.published ? (
<Typography
variant="caption"
component={RouterLink}
to={`/products/${r.productId}`}
to={`/products/${r.product.slug || r.product.id}`}
sx={{
display: 'block',
mt: 0.25,
@@ -134,8 +135,13 @@ export function ReviewsBlock() {
'&:hover': { textDecoration: 'underline' },
}}
>
{r.productTitle}
{r.product.title}
</Typography>
) : (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25 }}>
{r.product.title}
</Typography>
)}
</Box>
</Stack>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,174 @@
# 2026-05-21 — Avatar & Display Name Fixes
## Overview
8 замечаний по отображению аватаров, имён, ссылок и stock-статусов в админке и на клиенте.
## Approach
Локальные изолированные правки (Подход 1). Каждый пункт правится в своём контексте без переиспользования общих компонентов с `/me` — минимизирует риск регрессии.
---
## 1. Admin settings page (`/admin/settings`)
**Проблема:** Админ не может настроить displayName/avatar. Страница `/me/settings` существует, но админ редиректится с `/me` на `/admin`.
**Решение:**
- Новая FSD-страница `client/src/pages/admin-settings/`
- Пункт «Настройки» в сайдбаре `AdminLayoutPage` (после «Уведомления»)
- Форма: редактирование `displayName`, выбор/генерация аватара (DiceBear, 16 стилей), загрузка своего аватара. Копирует UI с `/me/settings` (SettingsPage), но как отдельный компонент, не шаринг.
- API: `GET /api/admin/profile` и `PATCH /api/admin/profile` (новый роут в `server/src/routes/api/`)
- Роут защищён `verifyAdmin`, работает с полями: `displayName`, `avatar`, `avatarType`, `avatarStyle`
- После сохранения — инвалидация `$user` стора на клиенте, чтобы хедер подхватил новый аватар
**Файлы:**
- `client/src/pages/admin-settings/ui/AdminSettingsPage.tsx` (новый)
- `client/src/pages/admin-layout/ui/AdminLayoutPage.tsx` (добавить пункт меню)
- `server/src/routes/api/admin-profile.js` (новый)
- `server/src/index.js` (зарегистрировать роут)
---
## 2. Admin avatar in header
**Проблема:** В хедере админ видит только кнопку «Выход», без аватара.
**Решение:**
- В `AppHeader` для админа: `IconButton` с `UserAvatar` + выпадающее меню с пунктами «Настройки» (`/admin/settings`) и «Выход»
- Аватар из `AuthUser.avatar/avatarType/avatarStyle`, при отсутствии — DiceBear fallback
- Компонент по аналогии с `UserMenu`, но упрощённый: только 2 пункта, без профиля покупателя. Можно сделать как `AdminUserMenu` в `features/user/user-menu/` или прямо в `AppHeader`
**Файлы:**
- `client/src/app/layout/AppHeader.tsx` (заменить кнопку «Выход» на меню с аватаром)
---
## 3. Avatar column in admin users table
**Проблема:** В таблице пользователей (`/admin/users`) нет колонки с аватарами.
**Решение:**
- `AdminUser` тип (`entities/user/model/types.ts`): добавить `avatar`, `avatarType`, `avatarStyle` (опциональные)
- Серверный `GET /api/admin/users`: добавить эти поля в SELECT
- `AdminUsersPage`: колонка «Аватар» первой (перед email), рендер через `<UserAvatar size={28} />`
**Файлы:**
- `client/src/entities/user/model/types.ts`
- `client/src/pages/admin-users/ui/AdminUsersPage.tsx`
- `server/src/routes/api/admin-users.js`
---
## 4. Avatars in order messages
**Проблема:** `ChatMessageBubble` показывает только текст «Админ»/«Вы»/«Пользователь», без аватаров.
**Решение:**
- `ChatMessageBubble`: добавить опциональный проп `avatar?: ReactNode` — рендерится слева от сообщения для `authorType='admin'`, справа для `'user'`
- `OrderChat` (пользователь): для админских сообщений — DiceBear по `'admin'` seed, для своих — аватар из `AuthUser`
- `OrderDetailContent` (админ): для пользователя — аватар из `order.user.avatar/avatarType/avatarStyle`, для админа — из `AuthUser`
- API `GET /api/orders/:id` и `GET /api/admin/orders/:id`: добавить `user { avatar, avatarType, avatarStyle }` в ответ
- Клиентский тип заказа: добавить эти поля в `user`
**Файлы:**
- `client/src/shared/ui/ChatMessageBubble.tsx`
- `client/src/features/order-chat/ui/OrderChat.tsx`
- `client/src/features/order-detail/ui/OrderDetailContent.tsx`
- `server/src/routes/user-orders.js` (GET /:id)
- `server/src/routes/api/admin-orders.js` (GET /:id)
- Типы заказа на клиенте
---
## 5. Actual user avatars in reviews
**Проблема:** В отзывах всегда генерируется DiceBear по строке `authorDisplay`, а не используется реальный аватар пользователя.
**Решение:**
- API `public-reviews`: добавить `authorAvatar`, `authorAvatarType`, `authorAvatarStyle` в ответ (из `user.avatar/avatarType/avatarStyle`)
- Тип `PublicProductReviewItem` и `PublicReviewFeedItem`: добавить эти поля
- `ReviewsBlock` и `ProductReviewsList`: передавать реальные значения в `UserAvatar` вместо `null`
**Файлы:**
- `server/src/routes/api/public-reviews.js`
- `client/src/entities/review/api/reviews-api.ts` (типы)
- `client/src/widgets/reviews-block/ui/ReviewsBlock.tsx`
- `client/src/features/product-review/ui/ProductReviewsList.tsx`
- `server/src/routes/api/admin-reviews.js` (тоже может использовать)
---
## 6. Product link in reviews only if published
**Проблема:** В `ReviewsBlock` ссылка на товар показывается всегда, даже если товар скрыт из каталога.
**Решение:**
- API `public-reviews`: добавить объект `product: { id, title, published, slug }` в каждый элемент фида
- Тип `PublicReviewFeedItem`: обновить поле с `productId`/`productTitle` на `product: { id, title, published, slug }`
- `ReviewsBlock`: если `product.published === true` — ссылка `<RouterLink>`, иначе — просто текст `<Typography>`
**Файлы:**
- `server/src/routes/api/public-reviews.js`
- `client/src/entities/review/api/reviews-api.ts`
- `client/src/widgets/reviews-block/ui/ReviewsBlock.tsx`
---
## 7. "Out of stock" chip visibility in catalog
**Проблема:** Чип «Нет в наличии» существует в DOM, но визуально не виден в каталоге.
**Решение:**
- Проверить `z-index` чипа в `ProductCard` — поднять выше (например `zIndex: 2`), чтобы не перекрывался `CardMedia` или другими элементами
- Предположительно проблема в том, что чип рендерится до изображения в DOM, и изображение перекрывает его по z-order
**Файлы:**
- `client/src/entities/product/ui/ProductCard.tsx`
---
## 8. Person icon for unauthenticated users
**Проблема:** До авторизации в хедере нет иконки пользователя.
**Решение:**
- В `AppHeader`: когда `user === null` и `!loading`, показывать `IconButton` с `PersonIcon`, ведущую на `/auth`
- Сейчас `UserMenu` не рендерится без `user` — добавить условие `user ? <UserMenu ...> : <IconButton href="/auth"><PersonIcon /></IconButton>`
**Файлы:**
- `client/src/app/layout/AppHeader.tsx`
---
## Data flow summary
```
┌─ Admin settings ─────────────────────────────────────┐
│ PATCH /api/admin/profile → DB → invalidate $user │
│ → AppHeader reads $user.avatar → UserAvatar │
└───────────────────────────────────────────────────────┘
┌─ Admin users table ───────────────────────────────────┐
│ GET /api/admin/users → { ..., avatar, avatarType, │
│ avatarStyle } → AdminUsersPage → <UserAvatar /> │
└───────────────────────────────────────────────────────┘
┌─ Order chat ──────────────────────────────────────────┐
│ GET /api/orders/:id → { user: { avatar, ... } } │
│ → OrderChat → ChatMessageBubble(avatar={<UserAvatar/>})│
│ Admin avatar: from AuthUser store │
└───────────────────────────────────────────────────────┘
┌─ Reviews ─────────────────────────────────────────────┐
│ GET /api/public-reviews → { authorAvatar, ..., │
│ product: { published, ... } } │
│ → ReviewsBlock/ProductReviewsList → UserAvatar + link│
└───────────────────────────────────────────────────────┘
```
## Testing
- **Client unit tests:** Проверить рендер аватаров в `ProductReviewsList`, `ReviewsBlock`, `AdminUsersPage`, `ChatMessageBubble`, `AppHeader` для разных состояний (авторизован/неавторизован/админ)
- **Server tests:** Проверить новые поля в ответах API
- **Manual:** Проверить видимость чипа «Нет в наличии», отображение ссылки в отзывах для published/unpublished товаров
+2
View File
@@ -1,5 +1,6 @@
import { mapProductForApi, parseMaterialsInput, slugify } from './api/_product-helpers.js'
import { registerAdminNotificationRoutes } from './api/admin/notifications.js'
import { registerAdminProfileRoutes } from './api/admin-profile.js'
import { registerAdminCategoryRoutes } from './api/admin-categories.js'
import { registerAdminGalleryRoutes } from './api/admin-gallery.js'
import { registerAdminOrderRoutes } from './api/admin-orders.js'
@@ -26,4 +27,5 @@ export async function registerApiRoutes(fastify) {
await registerAdminReviewRoutes(fastify)
await registerAdminUserRoutes(fastify)
await registerAdminNotificationRoutes(fastify)
await registerAdminProfileRoutes(fastify)
}
+1 -1
View File
@@ -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 } },
user: { select: { id: true, email: true, displayName: true, avatar: true, avatarType: true, avatarStyle: true } },
items: true,
messages: { orderBy: { createdAt: 'asc' } },
},
+65
View File
@@ -0,0 +1,65 @@
import { prisma } from '../../lib/prisma.js'
export async function registerAdminProfileRoutes(fastify) {
fastify.get('/api/admin/profile', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
const userId = request.user.sub
const user = await prisma.user.findUnique({ where: { id: userId } })
if (!user) return reply.code(404).send({ error: 'Пользователь не найден' })
return {
id: user.id,
email: user.email,
displayName: user.displayName,
avatar: user.avatar,
avatarType: user.avatarType,
avatarStyle: user.avatarStyle,
}
})
fastify.patch('/api/admin/profile', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
const userId = request.user.sub
const nameRaw = request.body?.displayName
const displayName =
nameRaw === undefined ? undefined : nameRaw === null ? null : nameRaw === '' ? null : String(nameRaw).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 !== undefined && displayName !== null && displayName.length > 40)
return reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
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 = {}
if (displayName !== undefined) {
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 })
return {
id: updated.id,
email: updated.email,
displayName: updated.displayName,
avatar: updated.avatar,
avatarType: updated.avatarType,
avatarStyle: updated.avatarStyle,
}
})
}
+6
View File
@@ -33,6 +33,9 @@ export async function registerAdminUserRoutes(fastify) {
id: true,
email: true,
displayName: true,
avatar: true,
avatarType: true,
avatarStyle: true,
createdAt: true,
updatedAt: true,
},
@@ -44,6 +47,9 @@ export async function registerAdminUserRoutes(fastify) {
id: u.id,
email: u.email,
displayName: u.displayName,
avatar: u.avatar,
avatarType: u.avatarType,
avatarStyle: u.avatarStyle,
createdAt: u.createdAt,
updatedAt: u.updatedAt,
}))
+15 -5
View File
@@ -40,8 +40,8 @@ export async function registerPublicReviewRoutes(fastify) {
const rows = await prisma.review.findMany({
where: { status: 'approved', product: { published: true } },
include: {
user: { select: { email: true, displayName: true } },
product: { select: { id: true, title: true } },
user: { select: { email: true, displayName: true, avatar: true, avatarType: true, avatarStyle: true } },
product: { select: { id: true, title: true, published: true, slug: true } },
},
orderBy: { createdAt: 'desc' },
take,
@@ -54,8 +54,15 @@ export async function registerPublicReviewRoutes(fastify) {
imageUrl: r.imageUrl,
createdAt: r.createdAt,
authorDisplay: publicReviewAuthorDisplay(r.user),
productId: r.productId,
productTitle: r.product?.title ?? '',
authorAvatar: r.user?.avatar ?? null,
authorAvatarType: r.user?.avatarType ?? null,
authorAvatarStyle: r.user?.avatarStyle ?? null,
product: {
id: r.product?.id ?? r.productId,
title: r.product?.title ?? '',
published: r.product?.published ?? false,
slug: r.product?.slug ?? '',
},
}))
return { items }
@@ -80,7 +87,7 @@ export async function registerPublicReviewRoutes(fastify) {
const total = await prisma.review.count({ where })
const rawItems = await prisma.review.findMany({
where,
include: { user: { select: { email: true, displayName: true } } },
include: { user: { select: { email: true, displayName: true, avatar: true, avatarType: true, avatarStyle: true } } },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
@@ -93,6 +100,9 @@ export async function registerPublicReviewRoutes(fastify) {
imageUrl: r.imageUrl,
createdAt: r.createdAt,
authorDisplay: publicReviewAuthorDisplay(r.user),
authorAvatar: r.user?.avatar ?? null,
authorAvatarType: r.user?.avatarType ?? null,
authorAvatarStyle: r.user?.avatarStyle ?? null,
}))
return { items, total, page, pageSize }