460 lines
18 KiB
TypeScript
460 lines
18 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 Dialog from '@mui/material/Dialog'
|
||
import DialogActions from '@mui/material/DialogActions'
|
||
import DialogContent from '@mui/material/DialogContent'
|
||
import DialogTitle from '@mui/material/DialogTitle'
|
||
import Divider from '@mui/material/Divider'
|
||
import Rating from '@mui/material/Rating'
|
||
import Stack from '@mui/material/Stack'
|
||
import Typography from '@mui/material/Typography'
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||
import axios from 'axios'
|
||
import { Link as RouterLink, useParams } from 'react-router-dom'
|
||
import {
|
||
confirmOrderReceived,
|
||
fetchMyOrder,
|
||
fetchOrderReviewEligibility,
|
||
payOrderStub,
|
||
postOrderMessage,
|
||
} from '@/entities/order/api/order-api'
|
||
import { postProductReview, uploadReviewImage } from '@/entities/product/api/reviews-api'
|
||
import { markOrderMessagesRead } from '@/entities/user/api/messages-api'
|
||
import { formatPriceRub } from '@/shared/lib/format-price'
|
||
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
||
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
|
||
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
|
||
|
||
function reviewSubmitErrorMessage(err: unknown): string {
|
||
if (axios.isAxiosError(err)) {
|
||
const status = err.response?.status
|
||
const raw = err.response?.data
|
||
const apiMsg =
|
||
raw && typeof raw === 'object' && 'error' in raw && typeof (raw as { error: unknown }).error === 'string'
|
||
? (raw as { error: string }).error
|
||
: null
|
||
if (status === 409 || apiMsg?.toLowerCase().includes('уже')) {
|
||
return 'Вы уже оставляли отзыв на этот товар.'
|
||
}
|
||
return apiMsg || err.message || 'Не удалось отправить отзыв'
|
||
}
|
||
if (err instanceof Error) return err.message
|
||
return 'Не удалось отправить отзыв'
|
||
}
|
||
|
||
type AddressSnapshot = {
|
||
deliveryType?: 'delivery' | 'pickup'
|
||
label?: string | null
|
||
recipientName?: string
|
||
recipientPhone?: string
|
||
addressLine?: string
|
||
comment?: string | null
|
||
}
|
||
|
||
export function OrderDetailPage() {
|
||
const { id } = useParams()
|
||
const qc = useQueryClient()
|
||
const [text, setText] = useState('')
|
||
const [reviewTarget, setReviewTarget] = useState<{ productId: string; title: string } | null>(null)
|
||
const [reviewRating, setReviewRating] = useState<number>(5)
|
||
const [reviewText, setReviewText] = useState('')
|
||
const [reviewImageUrl, setReviewImageUrl] = useState<string | null>(null)
|
||
|
||
const orderQuery = useQuery({
|
||
queryKey: ['me', 'orders', id],
|
||
queryFn: () => fetchMyOrder(id!),
|
||
enabled: Boolean(id),
|
||
})
|
||
|
||
const payMut = useMutation({
|
||
mutationFn: () => payOrderStub(id!),
|
||
onSuccess: () =>
|
||
Promise.all([
|
||
qc.invalidateQueries({ queryKey: ['me', 'orders', id] }),
|
||
qc.invalidateQueries({ queryKey: ['me', 'orders'] }),
|
||
]),
|
||
})
|
||
|
||
const confirmMut = useMutation({
|
||
mutationFn: () => confirmOrderReceived(id!),
|
||
onSuccess: () =>
|
||
Promise.all([
|
||
qc.invalidateQueries({ queryKey: ['me', 'orders', id] }),
|
||
qc.invalidateQueries({ queryKey: ['me', 'orders'] }),
|
||
]),
|
||
})
|
||
|
||
const msgMut = useMutation({
|
||
mutationFn: () => postOrderMessage(id!, text.trim()),
|
||
onSuccess: async () => {
|
||
setText('')
|
||
await qc.invalidateQueries({ queryKey: ['me', 'orders', id] })
|
||
await qc.invalidateQueries({ queryKey: ['me', 'conversations'] })
|
||
},
|
||
})
|
||
|
||
const order = orderQuery.data?.item
|
||
const canSendMessage = text.replace(/<[^>]*>/g, ' ').trim().length > 0
|
||
|
||
const eligibilityQuery = useQuery({
|
||
queryKey: ['me', 'orders', id, 'review-eligibility'],
|
||
queryFn: () => fetchOrderReviewEligibility(id!),
|
||
enabled: Boolean(id && order?.status === 'DONE'),
|
||
})
|
||
|
||
const reviewMut = useMutation({
|
||
mutationFn: async () => {
|
||
if (!reviewTarget) return
|
||
const t = reviewText.trim()
|
||
await postProductReview(reviewTarget.productId, {
|
||
rating: reviewRating,
|
||
text: t.length ? t : null,
|
||
imageUrl: reviewImageUrl,
|
||
})
|
||
},
|
||
onSuccess: async () => {
|
||
setReviewTarget(null)
|
||
setReviewRating(5)
|
||
setReviewText('')
|
||
setReviewImageUrl(null)
|
||
await qc.invalidateQueries({ queryKey: ['me', 'orders', id, 'review-eligibility'] })
|
||
},
|
||
})
|
||
|
||
const uploadReviewImageMut = useMutation({
|
||
mutationFn: (file: File) => uploadReviewImage(file),
|
||
onSuccess: ({ url }) => setReviewImageUrl(url),
|
||
})
|
||
|
||
useEffect(() => {
|
||
if (!id || orderQuery.status !== 'success' || !order) return
|
||
void (async () => {
|
||
await markOrderMessagesRead(id).catch(() => undefined)
|
||
await qc.invalidateQueries({ queryKey: ['me', 'messages', 'unread-count'] })
|
||
})()
|
||
}, [id, order, orderQuery.status, qc])
|
||
|
||
const address = useMemo((): AddressSnapshot | null => {
|
||
if (!order?.addressSnapshotJson) return null
|
||
try {
|
||
return JSON.parse(order.addressSnapshotJson) as AddressSnapshot
|
||
} catch {
|
||
return null
|
||
}
|
||
}, [order])
|
||
|
||
if (!id) return <Alert severity="error">Некорректный заказ.</Alert>
|
||
if (orderQuery.isLoading) return <Typography>Загрузка…</Typography>
|
||
if (orderQuery.isError || !order) return <Alert severity="error">Не удалось загрузить заказ.</Alert>
|
||
|
||
return (
|
||
<Box>
|
||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}>
|
||
<Box sx={{ flexGrow: 1 }}>
|
||
<Typography variant="h4">Заказ #{order.id.slice(-6)}</Typography>
|
||
<Typography color="text.secondary">Статус: {orderStatusLabelRu(order.status)}</Typography>
|
||
</Box>
|
||
<Button component={RouterLink} to="/me/orders" variant="outlined">
|
||
К списку
|
||
</Button>
|
||
</Stack>
|
||
|
||
<Stack spacing={2} sx={{ maxWidth: 900 }}>
|
||
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
|
||
<Typography variant="h6" gutterBottom>
|
||
Позиции
|
||
</Typography>
|
||
<Stack spacing={1}>
|
||
{order.items.map((i) => (
|
||
<Stack key={i.id} direction="row" spacing={2} sx={{ alignItems: 'center' }}>
|
||
<Box sx={{ flexGrow: 1 }}>
|
||
<Typography sx={{ fontWeight: 700 }}>{i.titleSnapshot}</Typography>
|
||
<Typography color="text.secondary" variant="body2">
|
||
{i.qty} × {formatPriceRub(i.priceCentsSnapshot)}
|
||
</Typography>
|
||
</Box>
|
||
<Typography sx={{ fontWeight: 700 }}>{formatPriceRub(i.priceCentsSnapshot * i.qty)}</Typography>
|
||
</Stack>
|
||
))}
|
||
</Stack>
|
||
<Divider sx={{ my: 2 }} />
|
||
<Stack spacing={0.25}>
|
||
<Typography variant="body2" color="text.secondary">
|
||
Товары: {formatPriceRub(order.itemsSubtotalCents)}
|
||
</Typography>
|
||
{order.deliveryType === 'delivery' && (
|
||
<Typography variant="body2" color="text.secondary">
|
||
Доставка: {formatPriceRub(order.deliveryFeeCents)}
|
||
</Typography>
|
||
)}
|
||
<Typography variant="h6">Итого: {formatPriceRub(order.totalCents)}</Typography>
|
||
</Stack>
|
||
</Box>
|
||
|
||
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
|
||
<Typography variant="h6" gutterBottom>
|
||
Получение
|
||
</Typography>
|
||
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
|
||
Способ: {order.deliveryType === 'pickup' ? 'Самовывоз' : 'Доставка'}
|
||
</Typography>
|
||
{order.deliveryType === 'delivery' && (
|
||
<>
|
||
{address ? (
|
||
<>
|
||
<Typography sx={{ fontWeight: 700 }}>{address.addressLine}</Typography>
|
||
<Typography color="text.secondary" variant="body2">
|
||
Получатель: {address.recipientName} · {address.recipientPhone}
|
||
</Typography>
|
||
{address.comment && (
|
||
<Typography color="text.secondary" variant="body2">
|
||
Комментарий: {address.comment}
|
||
</Typography>
|
||
)}
|
||
</>
|
||
) : (
|
||
<Typography color="text.secondary">Адрес не распознан.</Typography>
|
||
)}
|
||
</>
|
||
)}
|
||
{order.deliveryType === 'pickup' && (
|
||
<Typography color="text.secondary">
|
||
Адрес доставки не требуется. Мы свяжемся для согласования самовывоза.
|
||
</Typography>
|
||
)}
|
||
{order.comment && (
|
||
<Typography color="text.secondary" variant="body2" sx={{ mt: 1 }}>
|
||
Комментарий к заказу: {order.comment}
|
||
</Typography>
|
||
)}
|
||
</Box>
|
||
|
||
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
|
||
<Typography variant="h6" gutterBottom>
|
||
Оплата
|
||
</Typography>
|
||
{order.status === 'PENDING_PAYMENT' && (
|
||
<>
|
||
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
|
||
Пока это заглушка. После нажатия заказ перейдёт в статус «Проверка оплаты».
|
||
</Typography>
|
||
<Button variant="contained" onClick={() => payMut.mutate()} disabled={payMut.isPending}>
|
||
Оплатить (заглушка)
|
||
</Button>
|
||
</>
|
||
)}
|
||
{order.status === 'PAYMENT_VERIFICATION' && (
|
||
<Typography color="info.main" variant="body2">
|
||
Оплата отправлена на проверку. Мы проверим поступление и обновим статус.
|
||
</Typography>
|
||
)}
|
||
{!['PENDING_PAYMENT', 'PAYMENT_VERIFICATION'].includes(order.status) && (
|
||
<Typography color="text.secondary" variant="body2">
|
||
На этом этапе действий по оплате в этом блоке не требуется.
|
||
</Typography>
|
||
)}
|
||
</Box>
|
||
|
||
{(order.deliveryType === 'delivery' && order.status === 'SHIPPED') ||
|
||
(order.deliveryType === 'pickup' && order.status === 'READY_FOR_PICKUP') ? (
|
||
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
|
||
<Typography variant="h6" gutterBottom>
|
||
Получение заказа
|
||
</Typography>
|
||
<Typography color="text.secondary" variant="body2" sx={{ mb: 2 }}>
|
||
{order.deliveryType === 'delivery'
|
||
? 'Когда забрали посылку у курьера или на пункте выдачи — подтвердите получение.'
|
||
: 'Когда забрали заказ самовывозом — подтвердите получение.'}
|
||
</Typography>
|
||
<Button
|
||
variant="contained"
|
||
color="success"
|
||
onClick={() => confirmMut.mutate()}
|
||
disabled={confirmMut.isPending}
|
||
>
|
||
Подтвердить получение
|
||
</Button>
|
||
</Box>
|
||
) : null}
|
||
|
||
{order.status === 'DONE' && eligibilityQuery.isSuccess && eligibilityQuery.data.canReview && (
|
||
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
|
||
<Typography variant="h6" gutterBottom>
|
||
Отзывы
|
||
</Typography>
|
||
<Typography color="text.secondary" variant="body2" sx={{ mb: 2 }}>
|
||
Поделитесь впечатлением о товарах. Отзывы появляются после модерации.
|
||
</Typography>
|
||
<Stack spacing={1}>
|
||
{eligibilityQuery.data.items.map((row) => (
|
||
<Stack
|
||
key={row.productId}
|
||
direction={{ xs: 'column', sm: 'row' }}
|
||
spacing={1}
|
||
sx={{ alignItems: { sm: 'center' }, justifyContent: 'space-between' }}
|
||
>
|
||
<Typography sx={{ flexGrow: 1 }}>{row.title}</Typography>
|
||
<Button
|
||
size="small"
|
||
variant="outlined"
|
||
disabled={row.hasReview}
|
||
onClick={() => setReviewTarget({ productId: row.productId, title: row.title })}
|
||
>
|
||
{row.hasReview ? 'Отзыв отправлен' : 'Оставить отзыв'}
|
||
</Button>
|
||
</Stack>
|
||
))}
|
||
</Stack>
|
||
</Box>
|
||
)}
|
||
|
||
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
|
||
<Typography variant="h6" gutterBottom>
|
||
Чат по заказу
|
||
</Typography>
|
||
<Stack spacing={1} sx={{ mb: 2 }}>
|
||
{order.messages.map((m) => (
|
||
<Box
|
||
key={m.id}
|
||
sx={{
|
||
p: 1.25,
|
||
borderRadius: 2,
|
||
bgcolor: m.authorType === 'admin' ? 'grey.100' : 'primary.50',
|
||
border: 1,
|
||
borderColor: 'divider',
|
||
alignSelf: m.authorType === 'admin' ? 'flex-start' : 'flex-end',
|
||
width: 'fit-content',
|
||
maxWidth: '85%',
|
||
}}
|
||
>
|
||
<Typography variant="caption" color="text.secondary">
|
||
{m.authorType === 'admin' ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()}
|
||
</Typography>
|
||
<RichTextMessageContent value={m.text} />
|
||
</Box>
|
||
))}
|
||
{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"
|
||
onClick={() => msgMut.mutate()}
|
||
disabled={msgMut.isPending || !canSendMessage}
|
||
sx={{ minWidth: 160 }}
|
||
>
|
||
Отправить
|
||
</Button>
|
||
</Stack>
|
||
</Box>
|
||
</Stack>
|
||
|
||
<Dialog
|
||
open={Boolean(reviewTarget)}
|
||
onClose={() => {
|
||
if (reviewMut.isPending) return
|
||
setReviewTarget(null)
|
||
setReviewRating(5)
|
||
setReviewText('')
|
||
setReviewImageUrl(null)
|
||
}}
|
||
fullWidth
|
||
maxWidth="sm"
|
||
>
|
||
<DialogTitle>Отзыв: {reviewTarget?.title}</DialogTitle>
|
||
<DialogContent>
|
||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||
Оценка
|
||
</Typography>
|
||
<Rating
|
||
value={reviewRating}
|
||
onChange={(_, v) => {
|
||
if (v !== null) setReviewRating(v)
|
||
}}
|
||
/>
|
||
<Box sx={{ mt: 2 }}>
|
||
<RichTextMessageEditor
|
||
value={reviewText}
|
||
onChange={setReviewText}
|
||
placeholder="Комментарий (необязательно)"
|
||
/>
|
||
</Box>
|
||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5} sx={{ mt: 2, alignItems: { sm: 'center' } }}>
|
||
<Button component="label" variant="outlined" disabled={uploadReviewImageMut.isPending}>
|
||
{reviewImageUrl ? 'Заменить фото' : 'Прикрепить фото'}
|
||
<input
|
||
hidden
|
||
type="file"
|
||
accept="image/png,image/jpeg,image/webp"
|
||
onChange={(e) => {
|
||
const file = e.target.files?.[0]
|
||
if (!file) return
|
||
uploadReviewImageMut.mutate(file)
|
||
e.currentTarget.value = ''
|
||
}}
|
||
/>
|
||
</Button>
|
||
{reviewImageUrl && (
|
||
<Button
|
||
color="error"
|
||
variant="text"
|
||
onClick={() => setReviewImageUrl(null)}
|
||
disabled={reviewMut.isPending}
|
||
>
|
||
Удалить фото
|
||
</Button>
|
||
)}
|
||
</Stack>
|
||
{reviewImageUrl && (
|
||
<Box
|
||
component="img"
|
||
src={reviewImageUrl}
|
||
alt="Фото к отзыву"
|
||
sx={{
|
||
mt: 1,
|
||
width: 120,
|
||
height: 120,
|
||
objectFit: 'cover',
|
||
borderRadius: 1.5,
|
||
border: 1,
|
||
borderColor: 'divider',
|
||
}}
|
||
/>
|
||
)}
|
||
{uploadReviewImageMut.isError && (
|
||
<Alert severity="error" sx={{ mt: 2 }}>
|
||
Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.
|
||
</Alert>
|
||
)}
|
||
{reviewMut.isError && (
|
||
<Alert severity="error" sx={{ mt: 2 }}>
|
||
{reviewSubmitErrorMessage(reviewMut.error)}
|
||
</Alert>
|
||
)}
|
||
</DialogContent>
|
||
<DialogActions>
|
||
<Button
|
||
onClick={() => {
|
||
setReviewTarget(null)
|
||
setReviewRating(5)
|
||
setReviewText('')
|
||
setReviewImageUrl(null)
|
||
}}
|
||
disabled={reviewMut.isPending}
|
||
>
|
||
Отмена
|
||
</Button>
|
||
<Button variant="contained" disabled={reviewMut.isPending} onClick={() => reviewMut.mutate()}>
|
||
Отправить
|
||
</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
</Box>
|
||
)
|
||
}
|