base commit
This commit is contained in:
@@ -1,17 +1,50 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
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 TextField from '@mui/material/TextField'
|
||||
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 { fetchMyOrder, payOrderStub, postOrderMessage } from '@/entities/order/api/order-api'
|
||||
import {
|
||||
confirmOrderReceived,
|
||||
fetchMyOrder,
|
||||
fetchOrderReviewEligibility,
|
||||
payOrderStub,
|
||||
postOrderMessage,
|
||||
} from '@/entities/order/api/order-api'
|
||||
import { postProductReview } 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'
|
||||
|
||||
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
|
||||
@@ -23,6 +56,9 @@ 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 orderQuery = useQuery({
|
||||
queryKey: ['me', 'orders', id],
|
||||
@@ -32,7 +68,20 @@ export function OrderDetailPage() {
|
||||
|
||||
const payMut = useMutation({
|
||||
mutationFn: () => payOrderStub(id!),
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'orders', 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({
|
||||
@@ -40,11 +89,43 @@ export function OrderDetailPage() {
|
||||
onSuccess: async () => {
|
||||
setText('')
|
||||
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 () => {
|
||||
if (!reviewTarget) return
|
||||
const t = reviewText.trim()
|
||||
await postProductReview(reviewTarget.productId, {
|
||||
rating: reviewRating,
|
||||
text: t.length ? t : null,
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setReviewTarget(null)
|
||||
setReviewRating(5)
|
||||
setReviewText('')
|
||||
await qc.invalidateQueries({ queryKey: ['me', 'orders', id, 'review-eligibility'] })
|
||||
},
|
||||
})
|
||||
|
||||
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 {
|
||||
@@ -63,7 +144,7 @@ export function OrderDetailPage() {
|
||||
<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">Статус: {order.status}</Typography>
|
||||
<Typography color="text.secondary">Статус: {orderStatusLabelRu(order.status)}</Typography>
|
||||
</Box>
|
||||
<Button component={RouterLink} to="/me/orders" variant="outlined">
|
||||
К списку
|
||||
@@ -89,27 +170,49 @@ export function OrderDetailPage() {
|
||||
))}
|
||||
</Stack>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="h6">Итого: {formatPriceRub(order.totalCents)}</Typography>
|
||||
<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>
|
||||
{address ? (
|
||||
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
|
||||
Способ: {order.deliveryType === 'pickup' ? 'Самовывоз' : 'Доставка'}
|
||||
</Typography>
|
||||
{order.deliveryType === 'delivery' && (
|
||||
<>
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Typography color="text.secondary">Адрес не распознан.</Typography>
|
||||
)}
|
||||
{order.deliveryType === 'pickup' && (
|
||||
<Typography color="text.secondary">
|
||||
Адрес доставки не требуется. Мы свяжемся для согласования самовывоза.
|
||||
</Typography>
|
||||
)}
|
||||
{order.comment && (
|
||||
<Typography color="text.secondary" variant="body2" sx={{ mt: 1 }}>
|
||||
@@ -122,14 +225,81 @@ export function OrderDetailPage() {
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Оплата
|
||||
</Typography>
|
||||
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
|
||||
Пока это заглушка. Позже подключим реальную оплату.
|
||||
</Typography>
|
||||
<Button variant="contained" onClick={() => payMut.mutate()} disabled={payMut.isPending}>
|
||||
Оплатить (заглушка)
|
||||
</Button>
|
||||
{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>
|
||||
Чат по заказу
|
||||
@@ -175,6 +345,48 @@ export function OrderDetailPage() {
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(reviewTarget)}
|
||||
onClose={() => !reviewMut.isPending && setReviewTarget(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)
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
sx={{ mt: 2 }}
|
||||
label="Комментарий (необязательно)"
|
||||
value={reviewText}
|
||||
onChange={(e) => setReviewText(e.target.value)}
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={3}
|
||||
/>
|
||||
{reviewMut.isError && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{reviewSubmitErrorMessage(reviewMut.error)}
|
||||
</Alert>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setReviewTarget(null)} disabled={reviewMut.isPending}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="contained" disabled={reviewMut.isPending} onClick={() => reviewMut.mutate()}>
|
||||
Отправить
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user