301 lines
12 KiB
TypeScript
301 lines
12 KiB
TypeScript
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>
|
||
)
|
||
}
|