Files
shop-server/client/src/pages/me/ui/sections/OrderDetailPage.tsx
T
@kirill.komarov 6885e39017 base commit
2026-05-03 20:30:21 +05:00

460 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}