Merge branch 'fixes'
This commit is contained in:
@@ -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)',
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{m.authorType === 'admin' ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()}
|
||||
</Typography>
|
||||
<OrderMessageBody text={m.text} attachmentUrl={m.attachmentUrl} imageAlt="Чек или вложение" />
|
||||
</ChatMessageBubble>
|
||||
))}
|
||||
{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">
|
||||
{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'}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{m.authorType === 'admin' ? 'Админ (вы)' : 'Пользователь'} · {new Date(m.createdAt).toLocaleString()}
|
||||
</Typography>
|
||||
<OrderMessageBody text={m.text} attachmentUrl={m.attachmentUrl} />
|
||||
</ChatMessageBubble>
|
||||
))}
|
||||
{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">
|
||||
{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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
<Box
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{
|
||||
p: 1.25,
|
||||
borderRadius: 2,
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
alignSelf: authorType === 'admin' ? 'flex-start' : 'flex-end',
|
||||
width: 'fit-content',
|
||||
alignSelf: isAdmin ? 'flex-start' : 'flex-end',
|
||||
maxWidth: '85%',
|
||||
color: 'text.primary',
|
||||
bgcolor: (theme) =>
|
||||
authorType === 'admin'
|
||||
? 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),
|
||||
alignItems: 'flex-end',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
{isAdmin && avatar && <Box sx={{ flexShrink: 0, pb: 0.5 }}>{avatar}</Box>}
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.25,
|
||||
borderRadius: 2,
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
width: 'fit-content',
|
||||
color: 'text.primary',
|
||||
bgcolor: (theme) =>
|
||||
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,20 +122,26 @@ export function ReviewsBlock() {
|
||||
{formatReviewDate(r.createdAt)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography
|
||||
variant="caption"
|
||||
component={RouterLink}
|
||||
to={`/products/${r.productId}`}
|
||||
sx={{
|
||||
display: 'block',
|
||||
mt: 0.25,
|
||||
color: 'primary.main',
|
||||
textDecoration: 'none',
|
||||
'&:hover': { textDecoration: 'underline' },
|
||||
}}
|
||||
>
|
||||
{r.productTitle}
|
||||
</Typography>
|
||||
{r.product.published ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
component={RouterLink}
|
||||
to={`/products/${r.product.slug || r.product.id}`}
|
||||
sx={{
|
||||
display: 'block',
|
||||
mt: 0.25,
|
||||
color: 'primary.main',
|
||||
textDecoration: 'none',
|
||||
'&:hover': { textDecoration: 'underline' },
|
||||
}}
|
||||
>
|
||||
{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 товаров
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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' } },
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user