feat: avatars in order messages
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
<Typography variant="caption" color="text.secondary">
|
const avatarNode = isAdminMsg ? (
|
||||||
{m.authorType === 'admin' ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()}
|
<UserAvatar userId="admin" avatarUrl={null} avatarType={null} avatarStyle={null} size={24} />
|
||||||
</Typography>
|
) : currentUser ? (
|
||||||
<OrderMessageBody text={m.text} attachmentUrl={m.attachmentUrl} imageAlt="Чек или вложение" />
|
<UserAvatar
|
||||||
</ChatMessageBubble>
|
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>}
|
{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'
|
||||||
<Typography variant="caption" color="text.secondary">
|
const avatarNode = isAdminMsg ? (
|
||||||
{m.authorType === 'admin' ? 'Админ (вы)' : 'Пользователь'} · {new Date(m.createdAt).toLocaleString()}
|
currentUser && (
|
||||||
</Typography>
|
<UserAvatar
|
||||||
<OrderMessageBody text={m.text} attachmentUrl={m.attachmentUrl} />
|
userId={currentUser.id}
|
||||||
</ChatMessageBubble>
|
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>}
|
{detail.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
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'
|
||||||
import Menu from '@mui/material/Menu'
|
import Menu from '@mui/material/Menu'
|
||||||
import MenuItem from '@mui/material/MenuItem'
|
import MenuItem from '@mui/material/MenuItem'
|
||||||
import PersonIcon from '@mui/icons-material/Person'
|
|
||||||
import type { AuthUser } from '@/shared/model/auth'
|
import type { AuthUser } from '@/shared/model/auth'
|
||||||
import { UserAvatar } from '@/shared/ui/UserAvatar'
|
import { UserAvatar } from '@/shared/ui/UserAvatar'
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
<Box
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
sx={{
|
sx={{
|
||||||
p: 1.25,
|
alignSelf: isAdmin ? 'flex-start' : 'flex-end',
|
||||||
borderRadius: 2,
|
|
||||||
border: 1,
|
|
||||||
borderColor: 'divider',
|
|
||||||
alignSelf: authorType === 'admin' ? 'flex-start' : 'flex-end',
|
|
||||||
width: 'fit-content',
|
|
||||||
maxWidth: '85%',
|
maxWidth: '85%',
|
||||||
color: 'text.primary',
|
alignItems: 'flex-end',
|
||||||
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),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{isAdmin && avatar && <Box sx={{ flexShrink: 0, pb: 0.5 }}>{avatar}</Box>}
|
||||||
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -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' } },
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user