Merge branch 'refactor'

This commit is contained in:
@kirill.komarov
2026-05-13 22:07:46 +05:00
parent 3c9797af4a
commit a06f9cf2c4
85 changed files with 3762 additions and 2072 deletions
@@ -22,7 +22,7 @@ import {
updateMyAddress,
} from '@/entities/user/api/address-api'
import type { ShippingAddress } from '@/entities/user/model/types'
import { AddressMapPicker } from '@/features/address-map-picker/ui/AddressMapPicker'
import { AddressMapPicker } from '@/features/address-map-picker'
export function AddressesPage() {
const queryClient = useQueryClient()
@@ -1,92 +1,34 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo } 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 Link from '@mui/material/Link'
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 {
confirmOrderReceived,
fetchMyOrder,
fetchOrderReviewEligibility,
postOrderMessage,
submitOrderPayment,
fetchOrderReviewEligibility,
} from '@/entities/order/api/order-api'
import { postProductReview, uploadReviewImage } from '@/entities/product/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 { PAYMENT_TRANSFER_INSTRUCTIONS_PLAIN } from '@/shared/constants/payment-instructions'
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'
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
function paySubmitErrorMessage(err: unknown): string {
if (axios.isAxiosError(err)) {
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
return apiMsg || err.message || 'Не удалось отправить данные оплаты'
}
if (err instanceof Error) return err.message
return 'Не удалось отправить данные оплаты'
}
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 'Не удалось отправить отзыв'
}
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 [paymentModalOpen, setPaymentModalOpen] = useState(false)
const [paymentDetail, setPaymentDetail] = useState('')
const [paymentReceiptFile, setPaymentReceiptFile] = useState<File | null>(null)
const [paymentClientError, setPaymentClientError] = useState<string | null>(null)
const paymentReceiptPreviewUrl = useMemo(() => {
if (!paymentReceiptFile) return null
return URL.createObjectURL(paymentReceiptFile)
}, [paymentReceiptFile])
useEffect(() => {
if (!paymentReceiptPreviewUrl) return undefined
return () => URL.revokeObjectURL(paymentReceiptPreviewUrl)
}, [paymentReceiptPreviewUrl])
const orderQuery = useQuery({
queryKey: ['me', 'orders', id],
@@ -95,16 +37,8 @@ export function OrderDetailPage() {
})
const payMut = useMutation({
mutationFn: () =>
submitOrderPayment(id!, {
detail: paymentDetail,
receiptFile: paymentReceiptFile,
}),
mutationFn: (params: { detail: string; receiptFile: File | null }) => submitOrderPayment(id!, params),
onSuccess: async () => {
setPaymentModalOpen(false)
setPaymentDetail('')
setPaymentReceiptFile(null)
setPaymentClientError(null)
await Promise.all([
qc.invalidateQueries({ queryKey: ['me', 'orders', id] }),
qc.invalidateQueries({ queryKey: ['me', 'orders'] }),
@@ -123,17 +57,14 @@ export function OrderDetailPage() {
})
const msgMut = useMutation({
mutationFn: () => postOrderMessage(id!, text.trim()),
mutationFn: (text: string) => postOrderMessage(id!, text),
onSuccess: async () => {
setText('')
await qc.invalidateQueries({ queryKey: ['me', 'orders', id] })
await qc.invalidateQueries({ queryKey: ['me', 'conversations'] })
},
})
const order = orderQuery.data?.item
const payOnPickup = (order?.paymentMethod ?? 'online') === 'on_pickup'
const canSendMessage = text.replace(/<[^>]*>/g, ' ').trim().length > 0
const eligibilityQuery = useQuery({
queryKey: ['me', 'orders', id, 'review-eligibility'],
@@ -142,27 +73,20 @@ export function OrderDetailPage() {
})
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,
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 () => {
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(() => {
@@ -179,6 +103,8 @@ export function OrderDetailPage() {
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' } }}>
@@ -279,52 +205,15 @@ export function OrderDetailPage() {
)}
</Box>
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
<Typography variant="h6" gutterBottom>
Оплата
</Typography>
{payOnPickup ? (
<Typography color="text.secondary" variant="body2">
Оплата при получении на точке самовывоза (наличные или карта по договорённости).
</Typography>
) : (
<>
{order.status === 'DELIVERY_FEE_ADJUSTMENT' && (
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
Точную стоимость доставки уточняет администратор. Оплата станет доступна после перехода заказа в
статус «{orderStatusLabelRu('PENDING_PAYMENT')}».
</Typography>
)}
{order.status === 'PENDING_PAYMENT' && (
<>
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
После перевода подтвердите оплату откроется форма для комментария и фото чека. Заказ получит
статус «{orderStatusLabelRu('PAYMENT_VERIFICATION')}».
</Typography>
<Button
variant="contained"
onClick={() => {
payMut.reset()
setPaymentModalOpen(true)
}}
>
Оплатить
</Button>
</>
)}
{order.status === 'PAYMENT_VERIFICATION' && (
<Typography color="info.main" variant="body2">
Оплата отправлена на проверку. Мы проверим поступление и обновим статус.
</Typography>
)}
{!['DELIVERY_FEE_ADJUSTMENT', 'PENDING_PAYMENT', 'PAYMENT_VERIFICATION'].includes(order.status) && (
<Typography color="text.secondary" variant="body2">
На этом этапе действий по оплате в этом блоке не требуется.
</Typography>
)}
</>
)}
</Box>
<OrderPaymentSection
status={order.status}
paymentMethod={order.paymentMethod}
deliveryType={order.deliveryType}
totalCents={order.totalCents}
isPayPending={payMut.isPending}
payError={payMut.error}
onPay={(params) => payMut.mutate(params)}
/>
{(order.deliveryType === 'delivery' && order.status === 'SHIPPED') ||
(order.deliveryType === 'pickup' && order.status === 'READY_FOR_PICKUP') ? (
@@ -349,261 +238,22 @@ export function OrderDetailPage() {
) : 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>
<ReviewSection
items={eligibilityQuery.data.items}
isSubmitPending={reviewMut.isPending}
isUploadPending={uploadReviewImageMut.isPending}
submitError={reviewMut.error}
uploadError={uploadReviewImageMut.error}
onSubmitReview={(params) => reviewMut.mutate(params)}
onUploadImage={async (file) => {
const result = await uploadReviewImageMut.mutateAsync(file)
return result
}}
/>
)}
<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) => (
<ChatMessageBubble key={m.id} authorType={m.authorType === 'admin' ? 'admin' : 'user'}>
<Typography variant="caption" color="text.secondary">
{m.authorType === 'admin' ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()}
</Typography>
<OrderMessageBody text={m.text} attachmentUrl={m.attachmentUrl} imageAlt="Чек или вложение" />
</ChatMessageBubble>
))}
{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>
<OrderChat messages={order.messages} isPending={msgMut.isPending} onSend={(text) => msgMut.mutate(text)} />
</Stack>
<Dialog open={paymentModalOpen} fullWidth maxWidth="sm">
<DialogTitle>Подтверждение оплаты</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', mb: 2 }}>
{PAYMENT_TRANSFER_INSTRUCTIONS_PLAIN}
</Typography>
<TextField
label="Комментарий об оплате (сумма, время перевода и т.д.)"
value={paymentDetail}
onChange={(e) => {
setPaymentDetail(e.target.value)
setPaymentClientError(null)
}}
fullWidth
multiline
minRows={3}
sx={{ mb: 2 }}
/>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5} sx={{ mb: 1, alignItems: { sm: 'center' } }}>
<Button component="label" variant="outlined">
Прикрепить чек (png, jpg, webp)
<input
hidden
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(e) => {
const file = e.target.files?.[0]
setPaymentReceiptFile(file ?? null)
setPaymentClientError(null)
e.currentTarget.value = ''
}}
/>
</Button>
{paymentReceiptFile && (
<Button color="error" variant="text" onClick={() => setPaymentReceiptFile(null)}>
Убрать файл
</Button>
)}
</Stack>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
Нужен текст комментария и/или изображение чека.
</Typography>
{paymentReceiptPreviewUrl && (
<Box
component="img"
src={paymentReceiptPreviewUrl}
alt="Предпросмотр чека"
sx={{ maxWidth: '100%', maxHeight: 200, borderRadius: 1, border: 1, borderColor: 'divider', mb: 1 }}
/>
)}
{paymentClientError && (
<Alert severity="warning" sx={{ mb: 1 }}>
{paymentClientError}
</Alert>
)}
{payMut.isError && (
<Alert severity="error" sx={{ mt: 1 }}>
{paySubmitErrorMessage(payMut.error)}
</Alert>
)}
</DialogContent>
<DialogActions>
<Button
onClick={() => {
setPaymentModalOpen(false)
setPaymentDetail('')
setPaymentReceiptFile(null)
setPaymentClientError(null)
payMut.reset()
}}
disabled={payMut.isPending}
>
Отмена
</Button>
<Button
variant="contained"
disabled={payMut.isPending}
onClick={() => {
const hasText = paymentDetail.trim().length > 0
const hasFile = Boolean(paymentReceiptFile)
if (!hasText && !hasFile) {
setPaymentClientError('Укажите комментарий и/или прикрепите чек.')
return
}
setPaymentClientError(null)
payMut.mutate()
}}
>
Подтвердить оплату
</Button>
</DialogActions>
</Dialog>
<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>
)
}