Files
shop-server/client/src/pages/me/ui/sections/OrderDetailPage.tsx
T
2026-05-21 12:02:29 +05:00

301 lines
12 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 } 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 Link from '@mui/material/Link'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link as RouterLink, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import {
confirmOrderReceived,
createOrderPayment,
fetchMyOrder,
getOrderPaymentStatus,
postOrderMessage,
fetchOrderReviewEligibility,
} from '@/entities/order/api/order-api'
import { postProductReview, uploadReviewImage } from '@/entities/review/api/reviews-api'
import { markOrderMessagesRead } from '@/entities/user/api/messages-api'
import { OrderChat } from '@/features/order-chat'
import { OrderPaymentSection } from '@/features/order-payment'
import { ReviewSection } from '@/features/product-review'
import { deliveryCarrierLabelRu } from '@/shared/constants/delivery-carrier'
import { PICKUP_ADDRESS_FULL } from '@/shared/constants/pickup-point'
import { formatPriceRub } from '@/shared/lib/format-price'
import { parseOrderAddressSnapshot } from '@/shared/lib/order-address-snapshot'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
export function OrderDetailPage() {
const { id } = useParams()
const qc = useQueryClient()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const paidParam = searchParams.get('paid')
const orderQuery = useQuery({
queryKey: ['me', 'orders', id],
queryFn: () => fetchMyOrder(id!),
enabled: Boolean(id),
})
const payMut = useMutation({
mutationFn: () => createOrderPayment(id!),
onSuccess: async (data) => {
window.location.href = data.confirmationUrl
},
})
const paymentStatusQuery = useQuery({
queryKey: ['me', 'orders', id, 'payment-status'],
queryFn: () => getOrderPaymentStatus(id!),
enabled: Boolean(id && paidParam === '1'),
refetchInterval: (query) => {
const data = query.state.data
if (data && (data.paid || data.status === 'canceled')) return false
return 3000
},
})
useEffect(() => {
const data = paymentStatusQuery.data
if (data && (data.paid || data.status === 'canceled') && paidParam === '1') {
qc.invalidateQueries({ queryKey: ['me', 'orders', id] })
navigate(`/me/orders/${id}`, { replace: true })
}
}, [paymentStatusQuery.data, paidParam, qc, id, navigate])
const confirmMut = useMutation({
mutationFn: () => confirmOrderReceived(id!),
onSuccess: () =>
Promise.all([
qc.invalidateQueries({ queryKey: ['me', 'orders', id] }),
qc.invalidateQueries({ queryKey: ['me', 'orders'] }),
]),
})
const msgMut = useMutation({
mutationFn: (text: string) => postOrderMessage(id!, text),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ['me', 'orders', id] })
await qc.invalidateQueries({ queryKey: ['me', 'conversations'] })
},
})
const order = orderQuery.data?.item
const eligibilityQuery = useQuery({
queryKey: ['me', 'orders', id, 'review-eligibility'],
queryFn: () => fetchOrderReviewEligibility(id!),
enabled: Boolean(id && order?.status === 'DONE'),
})
const reviewMut = useMutation({
mutationFn: async (params: { productId: string; rating: number; text: string; imageUrl: string | null }) => {
await postProductReview(params.productId, {
rating: params.rating,
text: params.text.length ? params.text : null,
imageUrl: params.imageUrl,
})
},
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ['me', 'orders', id, 'review-eligibility'] })
},
})
const uploadReviewImageMut = useMutation({
mutationFn: (file: File) => uploadReviewImage(file),
})
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(() => parseOrderAddressSnapshot(order?.addressSnapshotJson), [order?.addressSnapshotJson])
if (!id) return <Alert severity="error">Некорректный заказ.</Alert>
if (orderQuery.isLoading) return <Typography>Загрузка</Typography>
if (orderQuery.isError || !order) return <Alert severity="error">Не удалось загрузить заказ.</Alert>
const payOnPickup = (order.paymentMethod ?? 'online') === 'on_pickup'
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>
{paidParam === '1' && paymentStatusQuery.data && (
<Alert
severity={
paymentStatusQuery.data.paid
? 'success'
: paymentStatusQuery.data.status === 'canceled'
? 'warning'
: 'info'
}
sx={{ mb: 2 }}
>
{paymentStatusQuery.data.paid
? 'Оплата прошла успешно!'
: paymentStatusQuery.data.status === 'canceled'
? 'Оплата отмена. Вы можете попробовать снова.'
: 'Ожидаем подтверждения оплаты…'}
</Alert>
)}
<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' ? 'Самовывоз' : 'Доставка'}
{order.deliveryType === 'pickup' && <> · оплата: {payOnPickup ? 'при получении' : 'онлайн'}</>}
</Typography>
{order.deliveryType === 'delivery' && deliveryCarrierLabelRu(order.deliveryCarrier) && (
<Typography variant="body2" sx={{ mb: 1 }}>
Служба: {deliveryCarrierLabelRu(order.deliveryCarrier)}
</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' && (
<Stack spacing={0.75}>
<Typography color="text.secondary" variant="body2">
Адрес самовывоза и карта на странице{' '}
<Link component={RouterLink} to="/about" underline="hover">
О нас
</Link>
.
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{PICKUP_ADDRESS_FULL}
</Typography>
<Typography color="text.secondary" variant="body2">
Заберите заказ ко времени, которое согласуем в чате заказа.
</Typography>
</Stack>
)}
{order.comment && (
<Typography color="text.secondary" variant="body2" sx={{ mt: 1 }}>
Комментарий к заказу: {order.comment}
</Typography>
)}
</Box>
<OrderPaymentSection
status={order.status}
deliveryFeeLocked={order.deliveryFeeLocked}
paymentMethod={order.paymentMethod ?? null}
totalCents={order.totalCents}
isPayPending={payMut.isPending}
payError={payMut.error}
onPay={() => payMut.mutate()}
/>
{(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 && (
<ReviewSection
items={eligibilityQuery.data.items}
isSubmitPending={reviewMut.isPending}
isUploadPending={uploadReviewImageMut.isPending}
submitError={reviewMut.error}
uploadError={uploadReviewImageMut.error}
onSubmitReview={async (params) => {
await reviewMut.mutateAsync(params)
}}
onUploadImage={async (file) => {
const result = await uploadReviewImageMut.mutateAsync(file)
return result
}}
/>
)}
<OrderChat messages={order.messages} isPending={msgMut.isPending} onSend={(text) => msgMut.mutate(text)} />
</Stack>
</Box>
)
}