test commit

This commit is contained in:
Kirill
2026-05-19 11:25:23 +05:00
parent f8867f6457
commit 5adbe9baa7
81 changed files with 6549 additions and 3108 deletions
@@ -21,7 +21,7 @@ import {
deleteAdminCategory,
fetchAdminCategories,
updateAdminCategory,
} from '@/entities/product/api/product-api'
} from '@/entities/product/api/admin-product-api'
import type { Category } from '@/entities/product/model/types'
import { getErrorMessage } from '@/shared/lib/get-error-message'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
@@ -14,76 +14,21 @@ import TableHead from '@mui/material/TableHead'
import TableRow from '@mui/material/TableRow'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
fetchAdminOrder,
fetchAdminOrders,
patchAdminOrderDeliveryFee,
postAdminOrderMessage,
setAdminOrderStatus,
} from '@/entities/order/api/admin-order-api'
import { deliveryCarrierLabelRu } from '@/shared/constants/delivery-carrier'
import { ORDER_STATUSES, getAdminNextOrderStatuses } from '@/shared/constants/order'
import { useQuery } from '@tanstack/react-query'
import { fetchAdminOrder, fetchAdminOrders } from '@/entities/order/api/admin-order-api'
import { OrderDetailContent } from '@/features/order-detail/ui/OrderDetailContent'
import { ORDER_STATUSES } from '@/shared/constants/order'
import { formatPriceRub } from '@/shared/lib/format-price'
import { groupOrdersByStatus } from '@/shared/lib/group-orders-by-status'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
import { parseOrderAddressSnapshot } from '@/shared/lib/order-address-snapshot'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
import { AdminDialog } from '@/shared/ui/AdminDialog/AdminDialog'
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
function DeliveryFeeAdjustmentForm({ orderId, deliveryFeeCents }: { orderId: string; deliveryFeeCents: number }) {
const qc = useQueryClient()
const [rub, setRub] = useState(() => String(deliveryFeeCents / 100))
const feeMut = useMutation({
mutationFn: () => patchAdminOrderDeliveryFee(orderId, Math.round(Number.parseFloat(rub) * 100)),
onSuccess: async () => {
await invalidateQueryKeys(qc, [
['admin', 'orders'],
['admin', 'orders', 'detail'],
['admin', 'orders', 'summary'],
])
},
})
return (
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
<TextField
size="small"
label="Доставка, ₽"
type="number"
value={rub}
onChange={(e) => setRub(e.target.value)}
slotProps={{ htmlInput: { min: 0, step: 1 } }}
sx={{ width: { xs: '100%', sm: 200 } }}
/>
<Button
variant="contained"
disabled={
feeMut.isPending ||
!rub.trim() ||
!Number.isFinite(Number.parseFloat(rub)) ||
Number.parseFloat(rub) < 0 ||
!Number.isInteger(Number.parseFloat(rub))
}
onClick={() => feeMut.mutate()}
>
Утвердить доставку и открыть оплату
</Button>
</Stack>
)
}
export function AdminOrdersPage() {
const qc = useQueryClient()
const [q, setQ] = useState('')
const [status, setStatus] = useState('')
const [deliveryType, setDeliveryType] = useState<'delivery' | 'pickup' | ''>('')
const [dialogOpen, setDialogOpen] = useState(false)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [msg, setMsg] = useState('')
const ordersQuery = useQuery({
queryKey: ['admin', 'orders', { q, status, deliveryType }],
@@ -101,25 +46,6 @@ export function AdminOrdersPage() {
enabled: Boolean(selectedId),
})
const statusMut = useMutation({
mutationFn: (next: string) => setAdminOrderStatus(selectedId!, next),
onSuccess: async () => {
await invalidateQueryKeys(qc, [
['admin', 'orders'],
['admin', 'orders', 'detail'],
['admin', 'orders', 'summary'],
])
},
})
const msgMut = useMutation({
mutationFn: () => postAdminOrderMessage(selectedId!, msg.trim()),
onSuccess: async () => {
setMsg('')
await invalidateQueryKeys(qc, [['admin', 'orders', 'detail']])
},
})
const open = (id: string) => {
setSelectedId(id)
setDialogOpen(true)
@@ -136,17 +62,6 @@ export function AdminOrdersPage() {
)
const detail = orderDetailQuery.data?.item
const canSendMessage = msg.replace(/<[^>]*>/g, ' ').trim().length > 0
const deliverySnapshot = useMemo(
() => (detail?.deliveryType === 'delivery' ? parseOrderAddressSnapshot(detail.addressSnapshotJson) : null),
[detail],
)
const nextStatuses = useMemo(() => {
if (!detail) return []
return getAdminNextOrderStatuses(detail.status, detail.deliveryType ?? 'delivery')
}, [detail])
return (
<Box>
@@ -252,146 +167,7 @@ export function AdminOrdersPage() {
loading={!detail && orderDetailQuery.isLoading}
error={orderDetailQuery.isError ? 'Не удалось загрузить заказ.' : null}
>
{detail && (
<Stack spacing={2} sx={{ mt: 1 }}>
<Typography sx={{ fontWeight: 700 }}>
#{detail.id.slice(-8)} · {detail.user.email} · {orderStatusLabelRu(detail.status)} ·{' '}
{formatPriceRub(detail.totalCents)}
</Typography>
<Typography variant="body2" color="text.secondary">
Получение: {detail.deliveryType === 'pickup' ? 'самовывоз' : 'доставка'}
{(detail.paymentMethod ?? 'online') === 'on_pickup' ? ' · оплата при получении' : ' · онлайн-оплата'}
{detail.deliveryType === 'delivery' && deliveryCarrierLabelRu(detail.deliveryCarrier) && (
<> · служба: {deliveryCarrierLabelRu(detail.deliveryCarrier)}</>
)}
</Typography>
{detail.deliveryType === 'delivery' && (
<Box
sx={{
border: 1,
borderColor: 'divider',
borderRadius: 1,
p: 1.5,
bgcolor: 'action.hover',
}}
>
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 700 }}>
Адрес и получатель (на момент заказа)
</Typography>
{deliverySnapshot ? (
<Stack spacing={0.75}>
{deliverySnapshot.label?.trim() && (
<Typography variant="body2" color="text.secondary">
Метка: {deliverySnapshot.label}
</Typography>
)}
<Typography variant="body2">
<Box component="span" sx={{ color: 'text.secondary' }}>
Адрес:
</Box>{' '}
{deliverySnapshot.addressLine ?? '—'}
</Typography>
<Typography variant="body2">
<Box component="span" sx={{ color: 'text.secondary' }}>
Получатель:
</Box>{' '}
{deliverySnapshot.recipientName ?? '—'}
</Typography>
<Typography variant="body2">
<Box component="span" sx={{ color: 'text.secondary' }}>
Телефон:
</Box>{' '}
{deliverySnapshot.recipientPhone ?? '—'}
</Typography>
{deliverySnapshot.comment?.trim() && (
<Typography variant="body2" color="text.secondary">
Комментарий к адресу: {deliverySnapshot.comment}
</Typography>
)}
</Stack>
) : (
<Typography color="text.secondary" variant="body2">
Данные адреса в заказе отсутствуют или не распознаны.
</Typography>
)}
</Box>
)}
{detail.status === 'PENDING_PAYMENT' && detail.deliveryFeeLocked === false && (
<Alert severity="info">
Укажите итоговую стоимость доставки (). После сохранения клиент сможет оплатить заказ с учётом этой
суммы.
</Alert>
)}
{detail.status === 'PENDING_PAYMENT' && detail.deliveryFeeLocked === false && (
<DeliveryFeeAdjustmentForm
key={detail.id}
orderId={detail.id}
deliveryFeeCents={detail.deliveryFeeCents}
/>
)}
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'center' } }}>
<FormControl size="small" sx={{ minWidth: 240 }}>
<InputLabel id="next-status-label">Сменить статус</InputLabel>
<Select
labelId="next-status-label"
label="Сменить статус"
value=""
onChange={(e) => {
const next = String(e.target.value)
if (!next) return
statusMut.mutate(next)
}}
disabled={statusMut.isPending || nextStatuses.length === 0}
>
<MenuItem value="">
<em>Выберите</em>
</MenuItem>
{nextStatuses.map((s) => (
<MenuItem key={s} value={s}>
{orderStatusLabelRu(s)}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Сообщения
</Typography>
<Stack spacing={1} sx={{ mb: 1 }}>
{detail.messages.map((m) => (
<ChatMessageBubble key={m.id} authorType={m.authorType === 'admin' ? 'user' : 'admin'}>
<Typography variant="caption" color="text.secondary">
{m.authorType === 'admin' ? 'Админ (вы)' : 'Пользователь'} ·{' '}
{new Date(m.createdAt).toLocaleString()}
</Typography>
<OrderMessageBody text={m.text} attachmentUrl={m.attachmentUrl} />
</ChatMessageBubble>
))}
{detail.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={msg} onChange={setMsg} placeholder="Ответ админа" />
</Box>
<Button
variant="contained"
onClick={() => msgMut.mutate()}
disabled={msgMut.isPending || !canSendMessage}
sx={{ minWidth: 160 }}
>
Отправить
</Button>
</Stack>
</Box>
</Stack>
)}
{detail && <OrderDetailContent detail={detail} orderId={detail.id} />}
</AdminDialog>
</Box>
)
@@ -2,75 +2,40 @@ import { useState } from 'react'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Checkbox from '@mui/material/Checkbox'
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 FormControl from '@mui/material/FormControl'
import FormControlLabel from '@mui/material/FormControlLabel'
import FormHelperText from '@mui/material/FormHelperText'
import InputLabel from '@mui/material/InputLabel'
import MenuItem from '@mui/material/MenuItem'
import Select from '@mui/material/Select'
import Stack from '@mui/material/Stack'
import Switch from '@mui/material/Switch'
import Table from '@mui/material/Table'
import TableBody from '@mui/material/TableBody'
import TableCell from '@mui/material/TableCell'
import TableHead from '@mui/material/TableHead'
import TableRow from '@mui/material/TableRow'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Controller, useForm } from 'react-hook-form'
import { fetchAdminGallery } from '@/entities/gallery'
import { useForm } from 'react-hook-form'
import {
createProduct,
deleteProduct,
fetchAdminProducts,
fetchCategories,
updateProduct,
} from '@/entities/product/api/product-api'
import type { Category, Product } from '@/entities/product/model/types'
} from '@/entities/product/api/admin-product-api'
import { fetchCategories } from '@/entities/product/api/product-api'
import type { Product } from '@/entities/product/model/types'
import { emptyForm, type FormState } from '@/features/product-form'
import { GalleryImagePicker } from '@/features/product-form/ui/GalleryImagePicker'
import { ProductFormFields } from '@/features/product-form/ui/ProductFormFields'
import { formatPriceRub } from '@/shared/lib/format-price'
import { getErrorMessage } from '@/shared/lib/get-error-message'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
import { useEditDialogState } from '@/shared/lib/use-edit-dialog-state'
import { EntityRowActions } from '@/shared/ui/EntityRowActions'
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
type FormState = {
title: string
slug: string
shortDescription: string
description: string
quantity: string
materials: string
priceRub: string
imageUrls: string[]
published: boolean
categoryId: string
}
const emptyForm = (): FormState => ({
title: '',
slug: '',
shortDescription: '',
description: '',
quantity: '0',
materials: '',
priceRub: '',
imageUrls: [],
published: true,
categoryId: '',
})
export function AdminProductsPage() {
const queryClient = useQueryClient()
const { dialogOpen, editing, openCreateDialog, openEditDialog, closeDialog } = useEditDialogState<Product>()
const [galleryPickOpen, setGalleryPickOpen] = useState(false)
const [gallerySelectedUrls, setGallerySelectedUrls] = useState<Set<string>>(() => new Set())
const productForm = useForm<FormState>({
defaultValues: emptyForm(),
@@ -89,12 +54,6 @@ export function AdminProductsPage() {
queryFn: fetchAdminProducts,
})
const galleryForPickQuery = useQuery({
queryKey: ['admin', 'gallery'],
queryFn: fetchAdminGallery,
enabled: galleryPickOpen,
})
const openCreate = () => {
productForm.reset(emptyForm())
openCreateDialog()
@@ -212,29 +171,15 @@ export function AdminProductsPage() {
)
}
const toggleGalleryPickUrl = (url: string) => {
setGallerySelectedUrls((prev) => {
const next = new Set(prev)
if (next.has(url)) {
next.delete(url)
} else {
next.add(url)
}
return next
})
}
const appendGalleryUrlsToForm = () => {
const handleGallerySelect = (urls: string[]) => {
const current = productForm.getValues('imageUrls')
const merged = [...current]
for (const url of gallerySelectedUrls) {
for (const url of urls) {
if (!merged.includes(url)) {
merged.push(url)
}
}
productForm.setValue('imageUrls', merged, { shouldDirty: true })
setGalleryPickOpen(false)
setGallerySelectedUrls(new Set())
}
return (
@@ -289,204 +234,12 @@ export function AdminProductsPage() {
<Dialog open={dialogOpen} onClose={closeDialog} fullWidth maxWidth="sm">
<DialogTitle>{editing ? 'Редактировать товар' : 'Новый товар'}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<Controller
control={productForm.control}
name="title"
render={({ field }) => <TextField label="Название" fullWidth required {...field} />}
/>
<Controller
control={productForm.control}
name="slug"
render={({ field }) => (
<TextField
label="Slug (URL)"
fullWidth
{...field}
helperText="Можно оставить пустым при создании — сгенерируется из названия"
/>
)}
/>
<Controller
control={productForm.control}
name="shortDescription"
render={({ field }) => (
<TextField label="Краткое описание (для каталога)" fullWidth multiline minRows={2} {...field} />
)}
/>
<Controller
control={productForm.control}
name="description"
render={({ field }) => <TextField label="Описание" fullWidth multiline minRows={2} {...field} />}
/>
<Controller
control={productForm.control}
name="materials"
render={({ field }) => (
<TextField
label="Материалы"
fullWidth
{...field}
helperText="Список через запятую (например: хлопок, дерево, акрил)"
/>
)}
/>
<Controller
control={productForm.control}
name="quantity"
rules={{
validate: (v) => {
const n = Number(v)
if (!Number.isInteger(n) || n < 0 || n > 10) return 'Целое число от 0 до 10'
return true
},
}}
render={({ field, fieldState }) => (
<TextField
label="Количество"
fullWidth
{...field}
inputMode="numeric"
onChange={(e) => {
const v = e.target.value.replace(/[^0-9]/g, '')
field.onChange(v)
}}
helperText={fieldState.error?.message ?? '0 = нет в наличии'}
error={!!fieldState.error}
/>
)}
/>
<Controller
control={productForm.control}
name="priceRub"
rules={{
required: 'Укажите цену',
validate: (v) => {
const n = Number(v.replace(',', '.'))
if (!Number.isFinite(n) || n <= 0) return 'Цена должна быть больше 0'
if (n > 10_000) return 'Цена не может превышать 10 000 ₽'
if (!Number.isInteger(Math.round(n * 100))) return 'Не более 2 знаков после запятой'
return true
},
}}
render={({ field, fieldState }) => (
<TextField
label="Цена, ₽"
fullWidth
{...field}
inputMode="decimal"
onChange={(e) => {
const v = e.target.value.replace(/[^0-9.,]/g, '')
field.onChange(v)
}}
helperText={fieldState.error?.message}
error={!!fieldState.error}
/>
)}
/>
<Box>
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
Фото (из галереи)
</Typography>
<FormHelperText sx={{ mt: 0, mb: 1 }}>
Выберите обработанные изображения из галереи. Крестик на превью убирает фото только из карточки; файл
остаётся на сервере и в галерее.
</FormHelperText>
<Box
sx={{
display: 'flex',
gap: 2,
alignItems: { sm: 'center' },
flexDirection: { xs: 'column', sm: 'row' },
flexWrap: 'wrap',
}}
>
<Button
variant="outlined"
onClick={() => {
setGallerySelectedUrls(new Set())
setGalleryPickOpen(true)
}}
>
Из галереи
</Button>
</Box>
{productForm.watch('imageUrls').length > 0 && (
<Box sx={{ mt: 2, display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{productForm.watch('imageUrls').map((url) => (
<Box
key={url}
sx={{
width: 92,
height: 92,
borderRadius: 1,
border: 1,
borderColor: 'divider',
overflow: 'hidden',
position: 'relative',
}}
title={url}
>
<OptimizedImage
src={url}
alt="Фото товара"
widths={[320, 640]}
sizes="80px"
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
<Button
size="small"
color="error"
variant="contained"
onClick={() => removeImage(url)}
aria-label="Убрать из карточки"
title="Убрать из карточки"
sx={{
position: 'absolute',
top: 4,
right: 4,
minWidth: 0,
px: 0.75,
py: 0,
lineHeight: 1.2,
}}
>
×
</Button>
</Box>
))}
</Box>
)}
</Box>
<Controller
control={productForm.control}
name="categoryId"
render={({ field }) => (
<FormControl fullWidth error={!field.value}>
<InputLabel id="cat-label">Категория</InputLabel>
<Select labelId="cat-label" label="Категория" {...field}>
{(categoriesQuery.data ?? []).map((c: Category) => (
<MenuItem key={c.id} value={c.id}>
{c.name}
</MenuItem>
))}
</Select>
{!field.value && <FormHelperText>Выберите категорию</FormHelperText>}
</FormControl>
)}
/>
<Controller
control={productForm.control}
name="published"
render={({ field }) => (
<FormControlLabel
control={<Switch checked={field.value} onChange={(_, v) => field.onChange(v)} />}
label="Показывать в каталоге"
/>
)}
/>
</Stack>
<ProductFormFields
form={productForm}
categories={categoriesQuery.data ?? []}
onRemoveImage={removeImage}
onPickFromGallery={() => setGalleryPickOpen(true)}
/>
</DialogContent>
<DialogActions>
<Button onClick={closeDialog}>Отмена</Button>
@@ -508,89 +261,12 @@ export function AdminProductsPage() {
</DialogActions>
</Dialog>
<Dialog
<GalleryImagePicker
open={galleryPickOpen}
onClose={() => {
setGalleryPickOpen(false)
setGallerySelectedUrls(new Set())
}}
fullWidth
maxWidth="sm"
>
<DialogTitle>Изображения из галереи</DialogTitle>
<DialogContent dividers>
{galleryForPickQuery.isLoading && <Typography color="text.secondary">Загрузка списка</Typography>}
{galleryForPickQuery.isError && (
<Alert severity="error">Не удалось загрузить галерею. Попробуйте ещё раз.</Alert>
)}
{galleryForPickQuery.data?.items.length === 0 && !galleryForPickQuery.isLoading && (
<Typography color="text.secondary">В галерее пока нет файлов. Загрузите их в разделе «Галерея».</Typography>
)}
{galleryForPickQuery.data &&
galleryForPickQuery.data.items.length > 0 &&
galleryForPickQuery.data.items.filter((i) => i.isResized).length === 0 &&
!galleryForPickQuery.isLoading && (
<Typography color="text.secondary">
В галерее нет обработанных изображений. Сначала обработайте их в разделе «Галерея».
</Typography>
)}
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
gap: 1.5,
pt: 1,
}}
>
{(galleryForPickQuery.data?.items ?? [])
.filter((item) => item.isResized)
.map((item) => {
const alreadyInCard = productForm.watch('imageUrls').includes(item.url)
return (
<FormControlLabel
key={item.id}
sx={{ m: 0, alignItems: 'flex-start' }}
control={
<Checkbox
checked={alreadyInCard || gallerySelectedUrls.has(item.url)}
disabled={alreadyInCard}
onChange={() => toggleGalleryPickUrl(item.url)}
/>
}
label={
<Box sx={{ width: '100%', maxHeight: 100, borderRadius: 1, overflow: 'hidden' }}>
<OptimizedImage
src={item.url}
alt=""
widths={[320, 640]}
sizes="120px"
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
}
/>
)
})}
</Box>
</DialogContent>
<DialogActions>
<Button
onClick={() => {
setGalleryPickOpen(false)
setGallerySelectedUrls(new Set())
}}
>
Отмена
</Button>
<Button
variant="contained"
onClick={appendGalleryUrlsToForm}
disabled={![...gallerySelectedUrls].some((u) => !productForm.watch('imageUrls').includes(u))}
>
Добавить
</Button>
</DialogActions>
</Dialog>
onClose={() => setGalleryPickOpen(false)}
onSelect={handleGallerySelect}
currentUrls={productForm.watch('imageUrls')}
/>
</Box>
)
}
+29 -126
View File
@@ -3,17 +3,10 @@ import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Chip from '@mui/material/Chip'
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 FormControlLabel from '@mui/material/FormControlLabel'
import Stack from '@mui/material/Stack'
import Switch from '@mui/material/Switch'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Controller, useForm } from 'react-hook-form'
import { useForm } from 'react-hook-form'
import {
createMyAddress,
deleteMyAddress,
@@ -22,7 +15,18 @@ import {
updateMyAddress,
} from '@/entities/user/api/address-api'
import type { ShippingAddress } from '@/entities/user/model/types'
import { AddressMapPicker } from '@/features/address-map-picker'
import { AddressFormDialog, type AddressFormValues } from '@/features/address-form'
const defaultAddressForm = (isDefault: boolean): AddressFormValues => ({
label: '',
recipientName: '',
recipientPhone: '',
addressLine: '',
comment: '',
lat: null,
lng: null,
isDefault,
})
export function AddressesPage() {
const queryClient = useQueryClient()
@@ -34,26 +38,8 @@ export function AddressesPage() {
queryFn: fetchMyAddresses,
})
const form = useForm<{
label: string
recipientName: string
recipientPhone: string
addressLine: string
comment: string
lat: number | null
lng: number | null
isDefault: boolean
}>({
defaultValues: {
label: '',
recipientName: '',
recipientPhone: '',
addressLine: '',
comment: '',
lat: null,
lng: null,
isDefault: false,
},
const form = useForm<AddressFormValues>({
defaultValues: defaultAddressForm(false),
mode: 'onChange',
})
@@ -115,16 +101,7 @@ export function AddressesPage() {
const openCreate = () => {
setEditing(null)
form.reset({
label: '',
recipientName: '',
recipientPhone: '',
addressLine: '',
comment: '',
lat: null,
lng: null,
isDefault: items.length === 0,
})
form.reset(defaultAddressForm(items.length === 0))
setOpen(true)
}
@@ -143,6 +120,11 @@ export function AddressesPage() {
setOpen(true)
}
const handleSubmit = () => {
if (editing) updateMut.mutate()
else createMut.mutate()
}
return (
<Box>
<Typography variant="h4" gutterBottom>
@@ -226,93 +208,14 @@ export function AddressesPage() {
)}
</Stack>
<Dialog open={open} onClose={() => setOpen(false)} fullWidth maxWidth="md">
<DialogTitle>{editing ? 'Редактировать адрес' : 'Новый адрес'}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<Controller
control={form.control}
name="label"
render={({ field }) => <TextField label="Метка (например: Дом/Работа)" fullWidth {...field} />}
/>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<Controller
control={form.control}
name="recipientName"
render={({ field }) => <TextField label="ФИО получателя" fullWidth required {...field} />}
/>
<Controller
control={form.control}
name="recipientPhone"
render={({ field }) => <TextField label="Телефон получателя" fullWidth required {...field} />}
/>
</Stack>
<Controller
control={form.control}
name="addressLine"
render={({ field }) => <TextField label="Адрес" fullWidth required {...field} />}
/>
<Controller
control={form.control}
name="comment"
render={({ field }) => <TextField label="Комментарий (необязательно)" fullWidth {...field} />}
/>
<Controller
control={form.control}
name="lat"
render={({ field: latField }) => (
<Controller
control={form.control}
name="lng"
render={({ field: lngField }) => (
<AddressMapPicker
value={
latField.value !== null && lngField.value !== null
? { lat: latField.value, lng: lngField.value }
: null
}
onChange={(v) => {
latField.onChange(v.lat)
lngField.onChange(v.lng)
if (v.addressLine) form.setValue('addressLine', v.addressLine, { shouldDirty: true })
}}
/>
)}
/>
)}
/>
<Controller
control={form.control}
name="isDefault"
render={({ field }) => (
<FormControlLabel
control={<Switch checked={Boolean(field.value)} onChange={(_, v) => field.onChange(v)} />}
label="Адрес по умолчанию"
/>
)}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>Отмена</Button>
<Button
variant="contained"
onClick={() => (editing ? updateMut.mutate() : createMut.mutate())}
disabled={
createMut.isPending ||
updateMut.isPending ||
!form.watch('recipientName').trim() ||
!form.watch('recipientPhone').trim() ||
!form.watch('addressLine').trim()
}
>
Сохранить
</Button>
</DialogActions>
</Dialog>
<AddressFormDialog
open={open}
onClose={() => setOpen(false)}
editing={Boolean(editing)}
form={form}
onSubmit={handleSubmit}
isPending={createMut.isPending || updateMut.isPending}
/>
</Box>
)
}
@@ -15,7 +15,7 @@ import {
submitOrderPayment,
fetchOrderReviewEligibility,
} from '@/entities/order/api/order-api'
import { postProductReview, uploadReviewImage } from '@/entities/product/api/reviews-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'
+2 -86
View File
@@ -5,7 +5,6 @@ import Chip from '@mui/material/Chip'
import Dialog from '@mui/material/Dialog'
import Divider from '@mui/material/Divider'
import IconButton from '@mui/material/IconButton'
import Paper from '@mui/material/Paper'
import Rating from '@mui/material/Rating'
import Skeleton from '@mui/material/Skeleton'
import Stack from '@mui/material/Stack'
@@ -19,14 +18,13 @@ import { Swiper, SwiperSlide } from 'swiper/react'
import 'swiper/css'
import 'swiper/css/navigation'
import { fetchPublicProduct } from '@/entities/product/api/product-api'
import { fetchPublicProductReviews } from '@/entities/product/api/reviews-api'
import { ToggleCartIcon } from '@/features/cart/toggle-cart-icon'
import { ProductReviewsList } from '@/features/product-review'
import { formatPriceRub } from '@/shared/lib/format-price'
import { getOriginalWebpUrl } from '@/shared/lib/get-original-webp-url'
import { reviewsCountRu } from '@/shared/lib/reviews-count-ru'
import { $user } from '@/shared/model/auth'
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
export function ProductPage() {
const user = useUnit($user)
@@ -41,12 +39,6 @@ export function ProductPage() {
enabled: Boolean(id),
})
const reviewsQuery = useQuery({
queryKey: ['products', 'public', id, 'reviews', { page: 1, pageSize: 30 }],
queryFn: () => fetchPublicProductReviews(id!, { page: 1, pageSize: 30 }),
enabled: Boolean(id),
})
const imageUrls = useMemo(() => {
const p = productQuery.data
if (!p) return []
@@ -191,83 +183,7 @@ export function ProductPage() {
</Stack>
)}
{reviewsQuery.isLoading && <Typography color="text.secondary">Загрузка отзывов</Typography>}
{reviewsQuery.isError && <Alert severity="warning">Не удалось загрузить отзывы.</Alert>}
{reviewsQuery.data && reviewsQuery.data.total === 0 && (
<Box sx={{ py: 3 }}>
<Typography variant="h6" color="text.secondary" sx={{ mb: 1 }}>
Отзывов пока нет
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 400 }}>
Будьте первым, кто оставит отзыв на этот товар. Ваше мнение поможет улучшить качество наших изделий.
</Typography>
</Box>
)}
{reviewsQuery.data && reviewsQuery.data.items.length > 0 && (
<Stack spacing={1.25}>
{reviewsQuery.data.items.map((rv) => {
const body = typeof rv.text === 'string' && rv.text.trim() ? rv.text.trim() : null
return (
<Paper key={rv.id} variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
<Stack spacing={0.75}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ justifyContent: 'space-between' }}>
<Typography sx={{ fontWeight: 700 }}>{rv.authorDisplay}</Typography>
<Typography variant="caption" color="text.secondary">
{new Date(rv.createdAt).toLocaleString('ru-RU')}
</Typography>
</Stack>
<Rating
value={rv.rating}
readOnly
size="small"
icon={<Star fontSize="inherit" />}
emptyIcon={<Star fontSize="inherit" />}
/>
{body ? (
<Box sx={{ color: 'text.secondary' }}>
<RichTextMessageContent value={body} tone="review" />
</Box>
) : (
<Typography variant="caption" color="text.secondary">
Без текстового комментария.
</Typography>
)}
{rv.imageUrl && (
<Box
sx={{
width: 140,
height: 140,
borderRadius: 1.5,
border: 1,
borderColor: 'divider',
overflow: 'hidden',
}}
>
<OptimizedImage
src={rv.imageUrl}
alt="Фото к отзыву"
widths={[320, 640]}
sizes="140px"
sx={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
</Box>
)}
</Stack>
</Paper>
)
})}
{reviewsQuery.data.total > reviewsQuery.data.items.length && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
Всего {reviewsCountRu(reviewsQuery.data.total)} ниже показаны последние{' '}
{reviewsQuery.data.items.length}.
</Typography>
)}
</Stack>
)}
<ProductReviewsList productId={id} />
</Box>
<Dialog fullScreen open={viewerOpen} onClose={() => setViewerOpen(false)}>