base commit

This commit is contained in:
@kirill.komarov
2026-05-10 14:28:35 +05:00
parent 1e376caecc
commit 5ddde15fd3
102 changed files with 332 additions and 7657 deletions
@@ -46,6 +46,7 @@ export type AdminOrderDetailResponse = {
id: string
authorType: string
text: string
attachmentUrl?: string | null
createdAt: string
}>
}
+12 -2
View File
@@ -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={() => {
+3
View File
@@ -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')
+26
View File
@@ -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}
</>
)
}