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 && (
|
{isAdmin && user && !isMobile && (
|
||||||
<Button color="inherit" onClick={onLogout} sx={{ ml: 1 }}>
|
<UserMenu user={user} isAdmin={true} onNavigate={navigate} onLogout={onLogout} />
|
||||||
Выход
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
|
|||||||
@@ -37,7 +37,14 @@ export type AdminOrderDetailResponse = {
|
|||||||
comment: string | null
|
comment: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
user: { id: string; email: string; displayName: string | null }
|
user: {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
displayName: string | null
|
||||||
|
avatar?: string | null
|
||||||
|
avatarType?: string | null
|
||||||
|
avatarStyle?: string | null
|
||||||
|
}
|
||||||
items: Array<{
|
items: Array<{
|
||||||
id: string
|
id: string
|
||||||
productId: string
|
productId: string
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ export function ProductCard({ product, mediaHeight = 200, actions }: Props) {
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 8,
|
top: 8,
|
||||||
left: 8,
|
left: 8,
|
||||||
|
zIndex: 2,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: '0.7rem',
|
fontSize: '0.7rem',
|
||||||
backdropFilter: 'blur(4px)',
|
backdropFilter: 'blur(4px)',
|
||||||
|
|||||||
@@ -26,8 +26,15 @@ export type PublicReviewFeedItem = {
|
|||||||
imageUrl: string | null
|
imageUrl: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
authorDisplay: string
|
authorDisplay: string
|
||||||
productId: string
|
authorAvatar?: string | null
|
||||||
productTitle: string
|
authorAvatarType?: string | null
|
||||||
|
authorAvatarStyle?: string | null
|
||||||
|
product: {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
published: boolean
|
||||||
|
slug: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PublicReviewsLatestResponse = {
|
export type PublicReviewsLatestResponse = {
|
||||||
@@ -48,6 +55,9 @@ export type PublicProductReviewItem = {
|
|||||||
imageUrl: string | null
|
imageUrl: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
authorDisplay: string
|
authorDisplay: string
|
||||||
|
authorAvatar?: string | null
|
||||||
|
authorAvatarType?: string | null
|
||||||
|
authorAvatarStyle?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PublicProductReviewsResponse = {
|
export type PublicProductReviewsResponse = {
|
||||||
|
|||||||
@@ -17,14 +17,14 @@ export async function fetchAdminUsers(params?: {
|
|||||||
return data
|
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)
|
const { data } = await apiClient.post<AdminUser>('admin/users', body)
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateAdminUser(
|
export async function updateAdminUser(
|
||||||
id: string,
|
id: string,
|
||||||
body: Partial<{ email: string; name: string | null }>,
|
body: Partial<{ email: string; displayName: string | null }>,
|
||||||
): Promise<AdminUser> {
|
): Promise<AdminUser> {
|
||||||
const { data } = await apiClient.patch<AdminUser>(`admin/users/${id}`, body)
|
const { data } = await apiClient.patch<AdminUser>(`admin/users/${id}`, body)
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
export type AdminUser = {
|
export type AdminUser = {
|
||||||
id: string
|
id: string
|
||||||
email: string
|
email: string
|
||||||
name: string | null
|
displayName: string | null
|
||||||
|
avatar?: string | null
|
||||||
|
avatarType?: string | null
|
||||||
|
avatarStyle?: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import Box from '@mui/material/Box'
|
|||||||
import Button from '@mui/material/Button'
|
import Button from '@mui/material/Button'
|
||||||
import Stack from '@mui/material/Stack'
|
import Stack from '@mui/material/Stack'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
|
import { useUnit } from 'effector-react'
|
||||||
|
import { $user } from '@/shared/model/auth'
|
||||||
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
|
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
|
||||||
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
|
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
|
||||||
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
|
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
|
||||||
|
import { UserAvatar } from '@/shared/ui/UserAvatar'
|
||||||
|
|
||||||
type Message = {
|
type Message = {
|
||||||
id: string
|
id: string
|
||||||
@@ -24,6 +27,7 @@ type Props = {
|
|||||||
export function OrderChat({ messages, isPending, onSend }: Props) {
|
export function OrderChat({ messages, isPending, onSend }: Props) {
|
||||||
const [text, setText] = useState('')
|
const [text, setText] = useState('')
|
||||||
const canSend = text.replace(/<[^>]*>/g, ' ').trim().length > 0
|
const canSend = text.replace(/<[^>]*>/g, ' ').trim().length > 0
|
||||||
|
const currentUser = useUnit($user)
|
||||||
|
|
||||||
const handleSend = () => {
|
const handleSend = () => {
|
||||||
if (!canSend || isPending) return
|
if (!canSend || isPending) return
|
||||||
@@ -37,14 +41,28 @@ export function OrderChat({ messages, isPending, onSend }: Props) {
|
|||||||
Чат по заказу
|
Чат по заказу
|
||||||
</Typography>
|
</Typography>
|
||||||
<Stack spacing={1} sx={{ mb: 2 }}>
|
<Stack spacing={1} sx={{ mb: 2 }}>
|
||||||
{messages.map((m) => (
|
{messages.map((m) => {
|
||||||
<ChatMessageBubble key={m.id} authorType={m.authorType === 'admin' ? 'admin' : 'user'}>
|
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">
|
<Typography variant="caption" color="text.secondary">
|
||||||
{m.authorType === 'admin' ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()}
|
{isAdminMsg ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()}
|
||||||
</Typography>
|
</Typography>
|
||||||
<OrderMessageBody text={m.text} attachmentUrl={m.attachmentUrl} imageAlt="Чек или вложение" />
|
<OrderMessageBody text={m.text} attachmentUrl={m.attachmentUrl} imageAlt="Чек или вложение" />
|
||||||
</ChatMessageBubble>
|
</ChatMessageBubble>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
{messages.length === 0 && <Typography color="text.secondary">Пока сообщений нет.</Typography>}
|
{messages.length === 0 && <Typography color="text.secondary">Пока сообщений нет.</Typography>}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import Select from '@mui/material/Select'
|
|||||||
import Stack from '@mui/material/Stack'
|
import Stack from '@mui/material/Stack'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useUnit } from 'effector-react'
|
||||||
import { postAdminOrderMessage, setAdminOrderStatus } from '@/entities/order/api/admin-order-api'
|
import { postAdminOrderMessage, setAdminOrderStatus } from '@/entities/order/api/admin-order-api'
|
||||||
import type { AdminOrderDetailResponse } from '@/entities/order/api/admin-order-api'
|
import type { AdminOrderDetailResponse } from '@/entities/order/api/admin-order-api'
|
||||||
import { deliveryCarrierLabelRu } from '@/shared/constants/delivery-carrier'
|
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 { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||||
import { parseOrderAddressSnapshot } from '@/shared/lib/order-address-snapshot'
|
import { parseOrderAddressSnapshot } from '@/shared/lib/order-address-snapshot'
|
||||||
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
||||||
|
import { $user } from '@/shared/model/auth'
|
||||||
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
|
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
|
||||||
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
|
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
|
||||||
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
|
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
|
||||||
|
import { UserAvatar } from '@/shared/ui/UserAvatar'
|
||||||
import { DeliveryFeeAdjustmentForm } from './DeliveryFeeAdjustmentForm'
|
import { DeliveryFeeAdjustmentForm } from './DeliveryFeeAdjustmentForm'
|
||||||
|
|
||||||
export function OrderDetailContent({ detail, orderId }: { detail: AdminOrderDetailResponse['item']; orderId: string }) {
|
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 canSendMessage = msg.replace(/<[^>]*>/g, ' ').trim().length > 0
|
||||||
|
const currentUser = useUnit($user)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
@@ -164,14 +168,36 @@ export function OrderDetailContent({ detail, orderId }: { detail: AdminOrderDeta
|
|||||||
Сообщения
|
Сообщения
|
||||||
</Typography>
|
</Typography>
|
||||||
<Stack spacing={1} sx={{ mb: 1 }}>
|
<Stack spacing={1} sx={{ mb: 1 }}>
|
||||||
{detail.messages.map((m) => (
|
{detail.messages.map((m) => {
|
||||||
<ChatMessageBubble key={m.id} authorType={m.authorType === 'admin' ? 'user' : 'admin'}>
|
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">
|
<Typography variant="caption" color="text.secondary">
|
||||||
{m.authorType === 'admin' ? 'Админ (вы)' : 'Пользователь'} · {new Date(m.createdAt).toLocaleString()}
|
{isAdminMsg ? 'Админ (вы)' : 'Пользователь'} · {new Date(m.createdAt).toLocaleString()}
|
||||||
</Typography>
|
</Typography>
|
||||||
<OrderMessageBody text={m.text} attachmentUrl={m.attachmentUrl} />
|
<OrderMessageBody text={m.text} attachmentUrl={m.attachmentUrl} />
|
||||||
</ChatMessageBubble>
|
</ChatMessageBubble>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
{detail.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
|
{detail.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,13 @@ function ReviewItem({ rv }: { rv: PublicProductReviewItem }) {
|
|||||||
<Paper variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
|
<Paper variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
|
||||||
<Stack spacing={0.75}>
|
<Stack spacing={0.75}>
|
||||||
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
|
<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 }}>
|
<Box sx={{ flexGrow: 1 }}>
|
||||||
<Typography sx={{ fontWeight: 700 }}>{rv.authorDisplay}</Typography>
|
<Typography sx={{ fontWeight: 700 }}>{rv.authorDisplay}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import PersonIcon from '@mui/icons-material/Person'
|
||||||
import Badge from '@mui/material/Badge'
|
import Badge from '@mui/material/Badge'
|
||||||
import IconButton from '@mui/material/IconButton'
|
import IconButton from '@mui/material/IconButton'
|
||||||
import ListItemText from '@mui/material/ListItemText'
|
import ListItemText from '@mui/material/ListItemText'
|
||||||
@@ -9,11 +10,12 @@ import { UserAvatar } from '@/shared/ui/UserAvatar'
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
user: AuthUser | null
|
user: AuthUser | null
|
||||||
|
isAdmin?: boolean
|
||||||
onNavigate: (to: string) => void
|
onNavigate: (to: string) => void
|
||||||
onLogout: () => 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 [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
|
||||||
const open = Boolean(anchorEl)
|
const open = Boolean(anchorEl)
|
||||||
|
|
||||||
@@ -49,7 +51,7 @@ export function UserMenu({ user, onNavigate, onLogout }: Props) {
|
|||||||
size={28}
|
size={28}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<UserAvatar userId="guest" avatarUrl={null} avatarType={null} avatarStyle={null} size={28} />
|
<PersonIcon sx={{ fontSize: 28 }} />
|
||||||
)}
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -63,8 +65,11 @@ export function UserMenu({ user, onNavigate, onLogout }: Props) {
|
|||||||
>
|
>
|
||||||
{user ? (
|
{user ? (
|
||||||
<>
|
<>
|
||||||
<MenuItem onClick={() => go('/me')}>
|
<MenuItem onClick={() => go(isAdmin ? '/admin/settings' : '/me')}>
|
||||||
<ListItemText primary={(user.displayName && user.displayName.trim()) || user.email} secondary="Профиль" />
|
<ListItemText
|
||||||
|
primary={(user.displayName && user.displayName.trim()) || user.email}
|
||||||
|
secondary={isAdmin ? 'Настройки' : 'Профиль'}
|
||||||
|
/>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem onClick={handleLogout}>Выход</MenuItem>
|
<MenuItem onClick={handleLogout}>Выход</MenuItem>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import Typography from '@mui/material/Typography'
|
|||||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useUnit } from 'effector-react'
|
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 { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { fetchAdminOrdersSummary } from '@/entities/order/api/admin-order-api'
|
import { fetchAdminOrdersSummary } from '@/entities/order/api/admin-order-api'
|
||||||
import { AdminCategoriesPage } from '@/pages/admin-categories'
|
import { AdminCategoriesPage } from '@/pages/admin-categories'
|
||||||
@@ -23,6 +23,7 @@ import { AdminGalleryPage } from '@/pages/admin-gallery'
|
|||||||
import { AdminOrdersPage } from '@/pages/admin-orders'
|
import { AdminOrdersPage } from '@/pages/admin-orders'
|
||||||
import { AdminProductsPage } from '@/pages/admin-products'
|
import { AdminProductsPage } from '@/pages/admin-products'
|
||||||
import { AdminReviewsPage } from '@/pages/admin-reviews'
|
import { AdminReviewsPage } from '@/pages/admin-reviews'
|
||||||
|
import { AdminSettingsPage } from '@/pages/admin-settings'
|
||||||
import { AdminUsersPage } from '@/pages/admin-users'
|
import { AdminUsersPage } from '@/pages/admin-users'
|
||||||
import { $user } from '@/shared/model/auth'
|
import { $user } from '@/shared/model/auth'
|
||||||
import { ScrollOnNavigate } from '@/shared/ui/ScrollOnNavigate'
|
import { ScrollOnNavigate } from '@/shared/ui/ScrollOnNavigate'
|
||||||
@@ -63,6 +64,7 @@ export function AdminLayoutPage() {
|
|||||||
{ to: '/admin/reviews', label: 'Отзывы', icon: <MessageSquare /> },
|
{ to: '/admin/reviews', label: 'Отзывы', icon: <MessageSquare /> },
|
||||||
{ to: '/admin/users', label: 'Пользователи', icon: <Users /> },
|
{ to: '/admin/users', label: 'Пользователи', icon: <Users /> },
|
||||||
{ to: '/admin/notifications', label: 'Уведомления', icon: <Bell /> },
|
{ 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="reviews" element={<AdminReviewsPage />} />
|
||||||
<Route path="users" element={<AdminUsersPage />} />
|
<Route path="users" element={<AdminUsersPage />} />
|
||||||
<Route path="notifications" element={<AdminNotificationsPage />} />
|
<Route path="notifications" element={<AdminNotificationsPage />} />
|
||||||
|
<Route path="settings" element={<AdminSettingsPage />} />
|
||||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Box>
|
</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 { AdminDialog } from '@/shared/ui/AdminDialog/AdminDialog'
|
||||||
import { AdminTable } from '@/shared/ui/AdminTable'
|
import { AdminTable } from '@/shared/ui/AdminTable'
|
||||||
import { EntityRowActions } from '@/shared/ui/EntityRowActions'
|
import { EntityRowActions } from '@/shared/ui/EntityRowActions'
|
||||||
|
import { UserAvatar } from '@/shared/ui/UserAvatar'
|
||||||
|
|
||||||
type UserFormState = {
|
type UserFormState = {
|
||||||
email: string
|
email: string
|
||||||
name: string
|
displayName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyUserForm = (): UserFormState => ({ email: '', name: '' })
|
const emptyUserForm = (): UserFormState => ({ email: '', displayName: '' })
|
||||||
|
|
||||||
function formatDt(v: string) {
|
function formatDt(v: string) {
|
||||||
try {
|
try {
|
||||||
@@ -77,7 +78,7 @@ export function AdminUsersPage() {
|
|||||||
const v = userForm.getValues()
|
const v = userForm.getValues()
|
||||||
await createAdminUser({
|
await createAdminUser({
|
||||||
email: v.email.trim(),
|
email: v.email.trim(),
|
||||||
name: v.name.trim() || null,
|
displayName: v.displayName.trim() || null,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -91,7 +92,7 @@ export function AdminUsersPage() {
|
|||||||
const v = userForm.getValues()
|
const v = userForm.getValues()
|
||||||
await updateAdminUser(editing!.id, {
|
await updateAdminUser(editing!.id, {
|
||||||
email: v.email.trim(),
|
email: v.email.trim(),
|
||||||
name: v.name.trim() || null,
|
displayName: v.displayName.trim() || null,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -118,7 +119,7 @@ export function AdminUsersPage() {
|
|||||||
openEditDialog(u)
|
openEditDialog(u)
|
||||||
userForm.reset({
|
userForm.reset({
|
||||||
email: u.email,
|
email: u.email,
|
||||||
name: u.name ?? '',
|
displayName: u.displayName ?? '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +172,7 @@ export function AdminUsersPage() {
|
|||||||
|
|
||||||
<AdminTable
|
<AdminTable
|
||||||
columns={[
|
columns={[
|
||||||
|
{ key: 'avatar', label: 'Аватар' },
|
||||||
{ key: 'email', label: 'Почта' },
|
{ key: 'email', label: 'Почта' },
|
||||||
{ key: 'name', label: 'Имя' },
|
{ key: 'name', label: 'Имя' },
|
||||||
{ key: 'createdAt', label: 'Создан' },
|
{ key: 'createdAt', label: 'Создан' },
|
||||||
@@ -182,15 +184,24 @@ export function AdminUsersPage() {
|
|||||||
>
|
>
|
||||||
{users.length === 0 && !usersQuery.isLoading ? (
|
{users.length === 0 && !usersQuery.isLoading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5} sx={{ color: 'text.secondary' }}>
|
<TableCell colSpan={6} sx={{ color: 'text.secondary' }}>
|
||||||
Пользователей пока нет.
|
Пользователей пока нет.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
users.map((u) => (
|
users.map((u) => (
|
||||||
<TableRow key={u.id} hover>
|
<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.email}</TableCell>
|
||||||
<TableCell>{u.name ?? '—'}</TableCell>
|
<TableCell>{u.displayName ?? '—'}</TableCell>
|
||||||
<TableCell>{formatDt(u.createdAt)}</TableCell>
|
<TableCell>{formatDt(u.createdAt)}</TableCell>
|
||||||
<TableCell>{formatDt(u.updatedAt)}</TableCell>
|
<TableCell>{formatDt(u.updatedAt)}</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
@@ -253,7 +264,7 @@ export function AdminUsersPage() {
|
|||||||
/>
|
/>
|
||||||
<Controller
|
<Controller
|
||||||
control={userForm.control}
|
control={userForm.control}
|
||||||
name="name"
|
name="displayName"
|
||||||
render={({ field }) => <TextField label="Имя/ник" fullWidth {...field} />}
|
render={({ field }) => <TextField label="Имя/ник" fullWidth {...field} />}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,29 +1,46 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
|
import Stack from '@mui/material/Stack'
|
||||||
import { alpha } from '@mui/material/styles'
|
import { alpha } from '@mui/material/styles'
|
||||||
|
|
||||||
type Author = 'admin' | 'user'
|
type Author = 'admin' | 'user'
|
||||||
|
|
||||||
export function ChatMessageBubble(props: { authorType: Author; children: ReactNode }) {
|
type Props = {
|
||||||
const { authorType, children } = props
|
authorType: Author
|
||||||
|
avatar?: ReactNode
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatMessageBubble({ authorType, avatar, children }: Props) {
|
||||||
|
const isAdmin = authorType === 'admin'
|
||||||
return (
|
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
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
p: 1.25,
|
p: 1.25,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
border: 1,
|
border: 1,
|
||||||
borderColor: 'divider',
|
borderColor: 'divider',
|
||||||
alignSelf: authorType === 'admin' ? 'flex-start' : 'flex-end',
|
|
||||||
width: 'fit-content',
|
width: 'fit-content',
|
||||||
maxWidth: '85%',
|
|
||||||
color: 'text.primary',
|
color: 'text.primary',
|
||||||
bgcolor: (theme) =>
|
bgcolor: (theme) =>
|
||||||
authorType === 'admin'
|
isAdmin
|
||||||
? alpha(theme.palette.grey[500], theme.palette.mode === 'dark' ? 0.28 : 0.14)
|
? 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),
|
: alpha(theme.palette.primary.main, theme.palette.mode === 'dark' ? 0.28 : 0.1),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Box>
|
</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' }}>
|
<Stack direction="row" spacing={1.5} sx={{ minWidth: { sm: 200 }, alignItems: 'center' }}>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
userId={r.authorDisplay}
|
userId={r.authorDisplay}
|
||||||
avatarUrl={null}
|
avatarUrl={r.authorAvatar}
|
||||||
avatarType={null}
|
avatarType={r.authorAvatarType}
|
||||||
avatarStyle={null}
|
avatarStyle={r.authorAvatarStyle}
|
||||||
size={40}
|
size={40}
|
||||||
/>
|
/>
|
||||||
<Box>
|
<Box>
|
||||||
@@ -122,10 +122,11 @@ export function ReviewsBlock() {
|
|||||||
{formatReviewDate(r.createdAt)}
|
{formatReviewDate(r.createdAt)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
{r.product.published ? (
|
||||||
<Typography
|
<Typography
|
||||||
variant="caption"
|
variant="caption"
|
||||||
component={RouterLink}
|
component={RouterLink}
|
||||||
to={`/products/${r.productId}`}
|
to={`/products/${r.product.slug || r.product.id}`}
|
||||||
sx={{
|
sx={{
|
||||||
display: 'block',
|
display: 'block',
|
||||||
mt: 0.25,
|
mt: 0.25,
|
||||||
@@ -134,8 +135,13 @@ export function ReviewsBlock() {
|
|||||||
'&:hover': { textDecoration: 'underline' },
|
'&:hover': { textDecoration: 'underline' },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{r.productTitle}
|
{r.product.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.25 }}>
|
||||||
|
{r.product.title}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</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 { mapProductForApi, parseMaterialsInput, slugify } from './api/_product-helpers.js'
|
||||||
import { registerAdminNotificationRoutes } from './api/admin/notifications.js'
|
import { registerAdminNotificationRoutes } from './api/admin/notifications.js'
|
||||||
|
import { registerAdminProfileRoutes } from './api/admin-profile.js'
|
||||||
import { registerAdminCategoryRoutes } from './api/admin-categories.js'
|
import { registerAdminCategoryRoutes } from './api/admin-categories.js'
|
||||||
import { registerAdminGalleryRoutes } from './api/admin-gallery.js'
|
import { registerAdminGalleryRoutes } from './api/admin-gallery.js'
|
||||||
import { registerAdminOrderRoutes } from './api/admin-orders.js'
|
import { registerAdminOrderRoutes } from './api/admin-orders.js'
|
||||||
@@ -26,4 +27,5 @@ export async function registerApiRoutes(fastify) {
|
|||||||
await registerAdminReviewRoutes(fastify)
|
await registerAdminReviewRoutes(fastify)
|
||||||
await registerAdminUserRoutes(fastify)
|
await registerAdminUserRoutes(fastify)
|
||||||
await registerAdminNotificationRoutes(fastify)
|
await registerAdminNotificationRoutes(fastify)
|
||||||
|
await registerAdminProfileRoutes(fastify)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export async function registerAdminOrderRoutes(fastify) {
|
|||||||
const order = await prisma.order.findUnique({
|
const order = await prisma.order.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
user: { select: { id: true, email: true, displayName: true } },
|
user: { select: { id: true, email: true, displayName: true, avatar: true, avatarType: true, avatarStyle: true } },
|
||||||
items: true,
|
items: true,
|
||||||
messages: { orderBy: { createdAt: 'asc' } },
|
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,
|
id: true,
|
||||||
email: true,
|
email: true,
|
||||||
displayName: true,
|
displayName: true,
|
||||||
|
avatar: true,
|
||||||
|
avatarType: true,
|
||||||
|
avatarStyle: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
},
|
},
|
||||||
@@ -44,6 +47,9 @@ export async function registerAdminUserRoutes(fastify) {
|
|||||||
id: u.id,
|
id: u.id,
|
||||||
email: u.email,
|
email: u.email,
|
||||||
displayName: u.displayName,
|
displayName: u.displayName,
|
||||||
|
avatar: u.avatar,
|
||||||
|
avatarType: u.avatarType,
|
||||||
|
avatarStyle: u.avatarStyle,
|
||||||
createdAt: u.createdAt,
|
createdAt: u.createdAt,
|
||||||
updatedAt: u.updatedAt,
|
updatedAt: u.updatedAt,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ export async function registerPublicReviewRoutes(fastify) {
|
|||||||
const rows = await prisma.review.findMany({
|
const rows = await prisma.review.findMany({
|
||||||
where: { status: 'approved', product: { published: true } },
|
where: { status: 'approved', product: { published: true } },
|
||||||
include: {
|
include: {
|
||||||
user: { select: { email: true, displayName: true } },
|
user: { select: { email: true, displayName: true, avatar: true, avatarType: true, avatarStyle: true } },
|
||||||
product: { select: { id: true, title: true } },
|
product: { select: { id: true, title: true, published: true, slug: true } },
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take,
|
take,
|
||||||
@@ -54,8 +54,15 @@ export async function registerPublicReviewRoutes(fastify) {
|
|||||||
imageUrl: r.imageUrl,
|
imageUrl: r.imageUrl,
|
||||||
createdAt: r.createdAt,
|
createdAt: r.createdAt,
|
||||||
authorDisplay: publicReviewAuthorDisplay(r.user),
|
authorDisplay: publicReviewAuthorDisplay(r.user),
|
||||||
productId: r.productId,
|
authorAvatar: r.user?.avatar ?? null,
|
||||||
productTitle: r.product?.title ?? '',
|
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 }
|
return { items }
|
||||||
@@ -80,7 +87,7 @@ export async function registerPublicReviewRoutes(fastify) {
|
|||||||
const total = await prisma.review.count({ where })
|
const total = await prisma.review.count({ where })
|
||||||
const rawItems = await prisma.review.findMany({
|
const rawItems = await prisma.review.findMany({
|
||||||
where,
|
where,
|
||||||
include: { user: { select: { email: true, displayName: true } } },
|
include: { user: { select: { email: true, displayName: true, avatar: true, avatarType: true, avatarStyle: true } } },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
@@ -93,6 +100,9 @@ export async function registerPublicReviewRoutes(fastify) {
|
|||||||
imageUrl: r.imageUrl,
|
imageUrl: r.imageUrl,
|
||||||
createdAt: r.createdAt,
|
createdAt: r.createdAt,
|
||||||
authorDisplay: publicReviewAuthorDisplay(r.user),
|
authorDisplay: publicReviewAuthorDisplay(r.user),
|
||||||
|
authorAvatar: r.user?.avatar ?? null,
|
||||||
|
authorAvatarType: r.user?.avatarType ?? null,
|
||||||
|
authorAvatarStyle: r.user?.avatarStyle ?? null,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return { items, total, page, pageSize }
|
return { items, total, page, pageSize }
|
||||||
|
|||||||
Reference in New Issue
Block a user