227 lines
9.5 KiB
TypeScript
227 lines
9.5 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
||
import Alert from '@mui/material/Alert'
|
||
import Box from '@mui/material/Box'
|
||
import Button from '@mui/material/Button'
|
||
import Chip from '@mui/material/Chip'
|
||
import List from '@mui/material/List'
|
||
import ListItem from '@mui/material/ListItem'
|
||
import ListItemButton from '@mui/material/ListItemButton'
|
||
import ListItemText from '@mui/material/ListItemText'
|
||
import Stack from '@mui/material/Stack'
|
||
import Typography from '@mui/material/Typography'
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||
import { useUnit } from 'effector-react'
|
||
import { Link as RouterLink } from 'react-router-dom'
|
||
import { fetchMyOrder, postOrderMessage } from '@/entities/order/api/order-api'
|
||
import { fetchMyConversations, markOrderMessagesRead } from '@/entities/user/api/messages-api'
|
||
import { fetchAdminAvatar } from '@/entities/user/api/user-api'
|
||
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 { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
|
||
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
|
||
import { UserAvatar } from '@/shared/ui/UserAvatar'
|
||
|
||
export function MessagesPage() {
|
||
const qc = useQueryClient()
|
||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||
const [text, setText] = useState('')
|
||
const currentUser = useUnit($user)
|
||
|
||
const adminAvatarQuery = useQuery({
|
||
queryKey: ['admin', 'avatar'],
|
||
queryFn: fetchAdminAvatar,
|
||
staleTime: 5 * 60 * 1000,
|
||
})
|
||
|
||
const listQuery = useQuery({
|
||
queryKey: ['me', 'conversations'],
|
||
queryFn: fetchMyConversations,
|
||
})
|
||
|
||
const conversationsList = useMemo(() => listQuery.data?.items ?? [], [listQuery.data?.items])
|
||
|
||
const activeThreadId = useMemo(() => {
|
||
return selectedId ?? conversationsList[0]?.orderId ?? null
|
||
}, [selectedId, conversationsList])
|
||
|
||
const orderQuery = useQuery({
|
||
queryKey: ['me', 'orders', activeThreadId],
|
||
queryFn: () => fetchMyOrder(activeThreadId!),
|
||
enabled: Boolean(activeThreadId),
|
||
})
|
||
|
||
useEffect(() => {
|
||
if (!activeThreadId || orderQuery.status !== 'success') return
|
||
void (async () => {
|
||
await markOrderMessagesRead(activeThreadId).catch(() => undefined)
|
||
await qc.invalidateQueries({ queryKey: ['me', 'messages', 'unread-count'] })
|
||
await qc.invalidateQueries({ queryKey: ['me', 'conversations'] })
|
||
})()
|
||
}, [activeThreadId, orderQuery.status, qc])
|
||
|
||
const msgMut = useMutation({
|
||
mutationFn: () => postOrderMessage(activeThreadId!, text.trim()),
|
||
onSuccess: async () => {
|
||
setText('')
|
||
await qc.invalidateQueries({ queryKey: ['me', 'orders', activeThreadId] })
|
||
await qc.invalidateQueries({ queryKey: ['me', 'conversations'] })
|
||
},
|
||
})
|
||
|
||
const order = orderQuery.data?.item
|
||
const canSendMessage = text.replace(/<[^>]*>/g, ' ').trim().length > 0
|
||
|
||
return (
|
||
<Box>
|
||
<Typography variant="h4" gutterBottom>
|
||
Сообщения
|
||
</Typography>
|
||
|
||
<Typography color="text.secondary" variant="body2" sx={{ mb: 2 }}>
|
||
Переписка по всем заказам. Последнее сообщение в каждом заказе — в списке слева.
|
||
</Typography>
|
||
|
||
{listQuery.isError && <Alert severity="error">Не удалось загрузить переписки.</Alert>}
|
||
{listQuery.isSuccess && conversationsList.length === 0 && (
|
||
<Alert severity="info">
|
||
Пока нет сообщений в заказах. Их отправит администратор — или напишите сами на странице заказа.
|
||
</Alert>
|
||
)}
|
||
|
||
{conversationsList.length > 0 && (
|
||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} sx={{ alignItems: 'flex-start' }}>
|
||
<Box
|
||
sx={{
|
||
width: { xs: '100%', md: 320 },
|
||
flexShrink: 0,
|
||
border: 1,
|
||
borderColor: 'divider',
|
||
borderRadius: 2,
|
||
bgcolor: 'background.paper',
|
||
maxHeight: 520,
|
||
overflow: 'auto',
|
||
}}
|
||
>
|
||
<List disablePadding>
|
||
{conversationsList.map((c) => (
|
||
<ListItem
|
||
key={c.orderId}
|
||
disablePadding
|
||
secondaryAction={
|
||
c.unreadCount > 0 ? (
|
||
<Chip sx={{ mr: 1 }} size="small" color="error" label={String(c.unreadCount)} />
|
||
) : null
|
||
}
|
||
>
|
||
<ListItemButton selected={activeThreadId === c.orderId} onClick={() => setSelectedId(c.orderId)}>
|
||
<ListItemText
|
||
primary={
|
||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
|
||
<Typography component="span" sx={{ fontWeight: 700 }}>
|
||
№{c.orderId.slice(-6)}
|
||
</Typography>
|
||
<Typography component="span" variant="caption" color="text.secondary">
|
||
· {orderStatusLabelRu(c.status)}
|
||
</Typography>
|
||
</Stack>
|
||
}
|
||
slotProps={{
|
||
secondary: {
|
||
sx: {
|
||
mt: 0.5,
|
||
overflow: 'hidden',
|
||
display: '-webkit-box',
|
||
WebkitLineClamp: 2,
|
||
WebkitBoxOrient: 'vertical',
|
||
},
|
||
},
|
||
}}
|
||
secondary={<RichTextMessageContent value={c.preview} tone="chat" />}
|
||
/>
|
||
</ListItemButton>
|
||
</ListItem>
|
||
))}
|
||
</List>
|
||
</Box>
|
||
|
||
<Box
|
||
sx={{
|
||
flexGrow: 1,
|
||
minWidth: 0,
|
||
border: 1,
|
||
borderColor: 'divider',
|
||
borderRadius: 2,
|
||
p: 2,
|
||
bgcolor: 'background.paper',
|
||
}}
|
||
>
|
||
{!activeThreadId && <Typography color="text.secondary">Выберите заказ.</Typography>}
|
||
{activeThreadId && orderQuery.isLoading && <Typography>Загрузка чата…</Typography>}
|
||
{activeThreadId && orderQuery.isError && <Alert severity="error">Не удалось загрузить заказ.</Alert>}
|
||
{order && (
|
||
<>
|
||
<Stack direction="row" sx={{ mb: 2, justifyContent: 'space-between', flexWrap: 'wrap', gap: 1 }}>
|
||
<Typography variant="h6">
|
||
Чат заказа №{order.id.slice(-6)}{' '}
|
||
<Typography component="span" variant="body2" color="text.secondary">
|
||
({orderStatusLabelRu(order.status)})
|
||
</Typography>
|
||
</Typography>
|
||
<Button component={RouterLink} to={`/me/orders/${order.id}`} size="small" variant="outlined">
|
||
Открыть заказ
|
||
</Button>
|
||
</Stack>
|
||
<Stack spacing={1} sx={{ mb: 2, maxHeight: 360, overflow: 'auto' }}>
|
||
{order.messages.map((m) => {
|
||
const isAdminMsg = m.authorType === 'admin'
|
||
const adminAv = adminAvatarQuery.data
|
||
const avatarNode = isAdminMsg ? (
|
||
<UserAvatar
|
||
userId="admin"
|
||
avatarUrl={adminAv?.avatar}
|
||
avatarStyle={adminAv?.avatarStyle}
|
||
size={24}
|
||
/>
|
||
) : currentUser ? (
|
||
<UserAvatar
|
||
userId={currentUser.id}
|
||
avatarUrl={currentUser.avatar}
|
||
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} />
|
||
</ChatMessageBubble>
|
||
)
|
||
})}
|
||
{order.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
|
||
</Stack>
|
||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
|
||
<Box sx={{ flexGrow: 1, width: '100%' }}>
|
||
<RichTextMessageEditor value={text} onChange={setText} placeholder="Сообщение" />
|
||
</Box>
|
||
<Button
|
||
variant="contained"
|
||
sx={{ minWidth: 140 }}
|
||
onClick={() => msgMut.mutate()}
|
||
disabled={msgMut.isPending || !canSendMessage}
|
||
>
|
||
Отправить
|
||
</Button>
|
||
</Stack>
|
||
</>
|
||
)}
|
||
</Box>
|
||
</Stack>
|
||
)}
|
||
</Box>
|
||
)
|
||
}
|