base commit
This commit is contained in:
@@ -46,6 +46,7 @@ export type AdminOrderDetailResponse = {
|
||||
id: string
|
||||
authorType: string
|
||||
text: string
|
||||
attachmentUrl?: string | null
|
||||
createdAt: string
|
||||
}>
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ export type OrderDetailResponse = {
|
||||
id: string
|
||||
authorType: string
|
||||
text: string
|
||||
attachmentUrl?: string | null
|
||||
createdAt: string
|
||||
}>
|
||||
}
|
||||
@@ -68,8 +69,17 @@ export async function postOrderMessage(id: string, text: string): Promise<void>
|
||||
await apiClient.post(`me/orders/${id}/messages`, { text })
|
||||
}
|
||||
|
||||
export async function payOrderStub(id: string): Promise<{ ok: boolean; status: string }> {
|
||||
const { data } = await apiClient.post<{ ok: boolean; status: string }>(`me/orders/${id}/pay`)
|
||||
/** Подтверждение оплаты переводом: multipart detail + необязательный файл receipt (хотя бы одно нужно на сервере). */
|
||||
export async function submitOrderPayment(
|
||||
orderId: string,
|
||||
payload: { detail: string; receiptFile: File | null },
|
||||
): Promise<{ ok: boolean; status: string }> {
|
||||
const formData = new FormData()
|
||||
formData.append('detail', payload.detail)
|
||||
if (payload.receiptFile) {
|
||||
formData.append('receipt', payload.receiptFile)
|
||||
}
|
||||
const { data } = await apiClient.post<{ ok: boolean; status: string }>(`me/orders/${orderId}/pay`, formData)
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ import { groupOrdersByStatus } from '@/shared/lib/group-orders-by-status'
|
||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
||||
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
|
||||
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
|
||||
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
|
||||
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
|
||||
|
||||
export function AdminOrdersPage() {
|
||||
@@ -250,7 +250,7 @@ export function AdminOrdersPage() {
|
||||
{m.authorType === 'admin' ? 'Админ (вы)' : 'Пользователь'} ·{' '}
|
||||
{new Date(m.createdAt).toLocaleString()}
|
||||
</Typography>
|
||||
<RichTextMessageContent value={m.text} tone="chat" />
|
||||
<OrderMessageBody text={m.text} attachmentUrl={m.attachmentUrl} />
|
||||
</ChatMessageBubble>
|
||||
))}
|
||||
{detail.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { fetchMyOrder, postOrderMessage } from '@/entities/order/api/order-api'
|
||||
import { fetchMyConversations, markOrderMessagesRead } from '@/entities/user/api/messages-api'
|
||||
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
||||
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
|
||||
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
|
||||
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
|
||||
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
|
||||
|
||||
export function MessagesPage() {
|
||||
@@ -167,7 +167,7 @@ export function MessagesPage() {
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{m.authorType === 'admin' ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()}
|
||||
</Typography>
|
||||
<RichTextMessageContent value={m.text} tone="chat" />
|
||||
<OrderMessageBody text={m.text} attachmentUrl={m.attachmentUrl} />
|
||||
</ChatMessageBubble>
|
||||
))}
|
||||
{order.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
|
||||
|
||||
@@ -10,6 +10,7 @@ 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'
|
||||
@@ -18,18 +19,32 @@ import {
|
||||
confirmOrderReceived,
|
||||
fetchMyOrder,
|
||||
fetchOrderReviewEligibility,
|
||||
payOrderStub,
|
||||
postOrderMessage,
|
||||
submitOrderPayment,
|
||||
} from '@/entities/order/api/order-api'
|
||||
import { postProductReview, uploadReviewImage } from '@/entities/product/api/reviews-api'
|
||||
import { markOrderMessagesRead } from '@/entities/user/api/messages-api'
|
||||
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 { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
||||
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
|
||||
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
|
||||
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
|
||||
@@ -65,6 +80,21 @@ export function OrderDetailPage() {
|
||||
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],
|
||||
queryFn: () => fetchMyOrder(id!),
|
||||
@@ -72,12 +102,22 @@ export function OrderDetailPage() {
|
||||
})
|
||||
|
||||
const payMut = useMutation({
|
||||
mutationFn: () => payOrderStub(id!),
|
||||
onSuccess: () =>
|
||||
Promise.all([
|
||||
mutationFn: () =>
|
||||
submitOrderPayment(id!, {
|
||||
detail: paymentDetail,
|
||||
receiptFile: paymentReceiptFile,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
setPaymentModalOpen(false)
|
||||
setPaymentDetail('')
|
||||
setPaymentReceiptFile(null)
|
||||
setPaymentClientError(null)
|
||||
await Promise.all([
|
||||
qc.invalidateQueries({ queryKey: ['me', 'orders', id] }),
|
||||
qc.invalidateQueries({ queryKey: ['me', 'orders'] }),
|
||||
]),
|
||||
qc.invalidateQueries({ queryKey: ['me', 'conversations'] }),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const confirmMut = useMutation({
|
||||
@@ -261,10 +301,17 @@ export function OrderDetailPage() {
|
||||
{order.status === 'PENDING_PAYMENT' && (
|
||||
<>
|
||||
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
|
||||
Пока это заглушка. После нажатия заказ перейдёт в статус «Проверка оплаты».
|
||||
После перевода подтвердите оплату — откроется форма для комментария и фото чека. Заказ получит
|
||||
статус «{orderStatusLabelRu('PAYMENT_VERIFICATION')}».
|
||||
</Typography>
|
||||
<Button variant="contained" onClick={() => payMut.mutate()} disabled={payMut.isPending}>
|
||||
Оплатить (заглушка)
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
payMut.reset()
|
||||
setPaymentModalOpen(true)
|
||||
}}
|
||||
>
|
||||
Оплатить
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -345,7 +392,7 @@ export function OrderDetailPage() {
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{m.authorType === 'admin' ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()}
|
||||
</Typography>
|
||||
<RichTextMessageContent value={m.text} tone="chat" />
|
||||
<OrderMessageBody text={m.text} attachmentUrl={m.attachmentUrl} imageAlt="Чек или вложение" />
|
||||
</ChatMessageBubble>
|
||||
))}
|
||||
{order.messages.length === 0 && <Typography color="text.secondary">Пока сообщений нет.</Typography>}
|
||||
@@ -367,6 +414,99 @@ export function OrderDetailPage() {
|
||||
</Box>
|
||||
</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={() => {
|
||||
|
||||
@@ -12,6 +12,9 @@ apiClient.interceptors.request.use((config) => {
|
||||
if (!token) return config
|
||||
config.headers = config.headers ?? {}
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
if (config.data instanceof FormData) {
|
||||
delete config.headers['Content-Type']
|
||||
}
|
||||
return config
|
||||
} catch {
|
||||
return config
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Текст модалки оплаты (можно переопределить через VITE_PAYMENT_INSTRUCTIONS — многострочная строка \n). */
|
||||
const fromEnv =
|
||||
typeof import.meta.env.VITE_PAYMENT_INSTRUCTIONS === 'string' ? import.meta.env.VITE_PAYMENT_INSTRUCTIONS.trim() : ''
|
||||
|
||||
export const PAYMENT_TRANSFER_INSTRUCTIONS_PLAIN =
|
||||
fromEnv ||
|
||||
[
|
||||
'Временно оплата доступна только переводом на ВТБ / Сбербанк.',
|
||||
'',
|
||||
'По номеру +79524181624',
|
||||
'Получатель: Лариса К',
|
||||
].join('\n')
|
||||
@@ -0,0 +1,26 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
|
||||
|
||||
type Props = {
|
||||
text: string
|
||||
attachmentUrl?: string | null
|
||||
imageAlt?: string
|
||||
}
|
||||
|
||||
/** Текст чата заказа (TipTap HTML) и опциональное изображение (например чек). */
|
||||
export function OrderMessageBody(props: Props) {
|
||||
const { text, attachmentUrl, imageAlt = 'Вложение к сообщению' } = props
|
||||
return (
|
||||
<>
|
||||
<RichTextMessageContent value={text} tone="chat" />
|
||||
{attachmentUrl ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={attachmentUrl}
|
||||
alt={imageAlt}
|
||||
sx={{ mt: 1, maxWidth: '100%', borderRadius: 1, display: 'block', border: 1, borderColor: 'divider' }}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user