Merge branch 'refactor'
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export { AdminCategoriesPage } from './ui/AdminCategoriesPage'
|
||||
@@ -0,0 +1,295 @@
|
||||
import { 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 Stack from '@mui/material/Stack'
|
||||
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 {
|
||||
createCategory,
|
||||
deleteAdminCategory,
|
||||
fetchAdminCategories,
|
||||
updateAdminCategory,
|
||||
} from '@/entities/product/api/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'
|
||||
import { EntityRowActions } from '@/shared/ui/EntityRowActions'
|
||||
|
||||
const UNSPECIFIED_CATEGORY_SLUG = 'ne-ukazano'
|
||||
|
||||
export function AdminCategoriesPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [catOpen, setCatOpen] = useState(false)
|
||||
const [categoryEditOpen, setCategoryEditOpen] = useState(false)
|
||||
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
|
||||
const [categoryDeleteTarget, setCategoryDeleteTarget] = useState<Category | null>(null)
|
||||
|
||||
const categoryForm = useForm<{ name: string; slug: string }>({
|
||||
defaultValues: { name: '', slug: '' },
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const categoryEditForm = useForm<{ name: string; slug: string; sort: string }>({
|
||||
defaultValues: { name: '', slug: '', sort: '0' },
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const adminCategoriesQuery = useQuery({
|
||||
queryKey: ['admin', 'categories'],
|
||||
queryFn: fetchAdminCategories,
|
||||
})
|
||||
|
||||
const createCategoryMut = useMutation({
|
||||
mutationFn: () => {
|
||||
const v = categoryForm.getValues()
|
||||
return createCategory({
|
||||
name: v.name.trim(),
|
||||
slug: v.slug.trim() || undefined,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
void invalidateQueryKeys(queryClient, [['categories'], ['admin', 'categories']])
|
||||
setCatOpen(false)
|
||||
categoryForm.reset({ name: '', slug: '' })
|
||||
},
|
||||
})
|
||||
|
||||
const updateCategoryMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!editingCategory) return
|
||||
const v = categoryEditForm.getValues()
|
||||
const payload: { name: string; slug?: string; sort: number } = {
|
||||
name: v.name.trim(),
|
||||
sort: Number(v.sort),
|
||||
}
|
||||
if (!Number.isFinite(payload.sort)) throw new Error('Некорректный порядок sort')
|
||||
if (editingCategory.slug !== UNSPECIFIED_CATEGORY_SLUG) {
|
||||
payload.slug = v.slug.trim()
|
||||
}
|
||||
return updateAdminCategory(editingCategory.id, payload)
|
||||
},
|
||||
onSuccess: () => {
|
||||
void invalidateQueryKeys(queryClient, [['categories'], ['admin', 'categories'], ['admin', 'products']])
|
||||
setCategoryEditOpen(false)
|
||||
setEditingCategory(null)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteCategoryMut = useMutation({
|
||||
mutationFn: (id: string) => deleteAdminCategory(id),
|
||||
onSuccess: () => {
|
||||
void invalidateQueryKeys(queryClient, [['categories'], ['admin', 'categories'], ['admin', 'products']])
|
||||
setCategoryDeleteTarget(null)
|
||||
},
|
||||
})
|
||||
|
||||
const mutationError = createCategoryMut.error ?? updateCategoryMut.error ?? deleteCategoryMut.error
|
||||
|
||||
const openCategoryEdit = (c: Category) => {
|
||||
setEditingCategory(c)
|
||||
categoryEditForm.reset({
|
||||
name: c.name,
|
||||
slug: c.slug,
|
||||
sort: String(c.sort),
|
||||
})
|
||||
setCategoryEditOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Управление категориями
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
|
||||
<Button variant="contained" onClick={() => setCatOpen(true)}>
|
||||
Новая категория
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{adminCategoriesQuery.isError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
Не удалось загрузить категории.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{mutationError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{getErrorMessage(mutationError)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Название</TableCell>
|
||||
<TableCell>Slug</TableCell>
|
||||
<TableCell>Порядок</TableCell>
|
||||
<TableCell align="right">Действия</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{(adminCategoriesQuery.data ?? []).map((c) => (
|
||||
<TableRow key={c.id} hover>
|
||||
<TableCell>{c.name}</TableCell>
|
||||
<TableCell>{c.slug}</TableCell>
|
||||
<TableCell>{c.sort}</TableCell>
|
||||
<TableCell align="right">
|
||||
<EntityRowActions
|
||||
onEdit={() => openCategoryEdit(c)}
|
||||
onDelete={c.slug === UNSPECIFIED_CATEGORY_SLUG ? undefined : () => setCategoryDeleteTarget(c)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<Dialog open={catOpen} onClose={() => setCatOpen(false)} fullWidth maxWidth="xs">
|
||||
<DialogTitle>Новая категория</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<Controller
|
||||
control={categoryForm.control}
|
||||
name="name"
|
||||
render={({ field }) => <TextField label="Название" fullWidth required {...field} />}
|
||||
/>
|
||||
<Controller
|
||||
control={categoryForm.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label="Slug"
|
||||
fullWidth
|
||||
{...field}
|
||||
helperText="Необязательно — можно сгенерировать из названия на сервере"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setCatOpen(false)}>Отмена</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!categoryForm.watch('name').trim() || createCategoryMut.isPending}
|
||||
onClick={() => createCategoryMut.mutate()}
|
||||
>
|
||||
Создать
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={categoryEditOpen}
|
||||
onClose={() => {
|
||||
setCategoryEditOpen(false)
|
||||
setEditingCategory(null)
|
||||
}}
|
||||
fullWidth
|
||||
maxWidth="xs"
|
||||
>
|
||||
<DialogTitle>Редактировать категорию</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<Controller
|
||||
control={categoryEditForm.control}
|
||||
name="name"
|
||||
render={({ field }) => <TextField label="Название" fullWidth required {...field} />}
|
||||
/>
|
||||
<Controller
|
||||
control={categoryEditForm.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label="Slug"
|
||||
fullWidth
|
||||
{...field}
|
||||
disabled={editingCategory?.slug === UNSPECIFIED_CATEGORY_SLUG}
|
||||
helperText={
|
||||
editingCategory?.slug === UNSPECIFIED_CATEGORY_SLUG
|
||||
? 'Служебный slug нельзя изменить'
|
||||
: 'Идентификатор в URL'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={categoryEditForm.control}
|
||||
name="sort"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label="Порядок сортировки"
|
||||
fullWidth
|
||||
type="number"
|
||||
{...field}
|
||||
slotProps={{ htmlInput: { step: 1 } }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setCategoryEditOpen(false)
|
||||
setEditingCategory(null)
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!categoryEditForm.watch('name').trim() || updateCategoryMut.isPending || !editingCategory}
|
||||
onClick={() => updateCategoryMut.mutate()}
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(categoryDeleteTarget)}
|
||||
onClose={() => setCategoryDeleteTarget(null)}
|
||||
maxWidth="xs"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Удалить категорию?</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2">
|
||||
{categoryDeleteTarget && (
|
||||
<>
|
||||
Категория «{categoryDeleteTarget.name}» будет удалена. Все товары из неё получат категорию «Не указано».
|
||||
</>
|
||||
)}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setCategoryDeleteTarget(null)}>Отмена</Button>
|
||||
<Button
|
||||
color="error"
|
||||
variant="contained"
|
||||
disabled={deleteCategoryMut.isPending}
|
||||
onClick={() => {
|
||||
if (categoryDeleteTarget) deleteCategoryMut.mutate(categoryDeleteTarget.id)
|
||||
}}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,12 @@
|
||||
import { useRef } from 'react'
|
||||
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { fetchAdminCatalogSlider } from '@/entities/catalog-slider/api/catalog-slider-api'
|
||||
import { deleteGalleryImage, fetchAdminGallery } from '@/entities/gallery/api/gallery-api'
|
||||
import { fetchAdminCatalogSlider } from '@/entities/catalog-slider'
|
||||
import { deleteGalleryImage, fetchAdminGallery, GalleryGrid } from '@/entities/gallery'
|
||||
import { uploadAdminProductImages } from '@/entities/product/api/product-api'
|
||||
import { formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
|
||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||
@@ -120,51 +117,7 @@ export function AdminGalleryPage() {
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<Box
|
||||
key={item.id}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
aspectRatio: '1',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={item.url}
|
||||
alt=""
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
<Tooltip title="Удалить из галереи">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
bgcolor: 'background.paper',
|
||||
'&:hover': { bgcolor: 'error.light', color: 'error.contrastText' },
|
||||
}}
|
||||
disabled={deleteMut.isPending}
|
||||
onClick={() => deleteMut.mutate(item.id)}
|
||||
>
|
||||
<DeleteOutlineOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<GalleryGrid items={items} deleting={deleteMut.isPending} onDelete={(id) => deleteMut.mutate(id)} />
|
||||
|
||||
{!galleryQuery.isLoading && items.length === 0 && (
|
||||
<Typography color="text.secondary">Пока нет загруженных изображений.</Typography>
|
||||
|
||||
@@ -14,8 +14,8 @@ import Stack from '@mui/material/Stack'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { putAdminCatalogSlider } from '@/entities/catalog-slider/api/catalog-slider-api'
|
||||
import type { GalleryImageItem } from '@/entities/gallery/model/types'
|
||||
import { putAdminCatalogSlider } from '@/entities/catalog-slider'
|
||||
import type { GalleryImageItem } from '@/entities/gallery'
|
||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||
|
||||
export type SlideDraft = { galleryImageId: string; caption: string }
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
fetchAdminInfoBlocks,
|
||||
type InfoPageBlock,
|
||||
updateInfoBlock,
|
||||
} from '@/entities/info/api/info-page-api'
|
||||
} from '@/entities/info'
|
||||
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'
|
||||
|
||||
@@ -24,10 +24,11 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { useUnit } from 'effector-react'
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { fetchAdminOrdersSummary } from '@/entities/order/api/admin-order-api'
|
||||
import { AdminPage } from '@/pages/admin'
|
||||
import { AdminCategoriesPage } from '@/pages/admin-categories'
|
||||
import { AdminGalleryPage } from '@/pages/admin-gallery'
|
||||
import { AdminInfoPage } from '@/pages/admin-info'
|
||||
import { AdminOrdersPage } from '@/pages/admin-orders'
|
||||
import { AdminProductsPage } from '@/pages/admin-products'
|
||||
import { AdminReviewsPage } from '@/pages/admin-reviews'
|
||||
import { AdminUsersPage } from '@/pages/admin-users'
|
||||
import { $user } from '@/shared/model/auth'
|
||||
@@ -60,6 +61,7 @@ export function AdminLayoutPage() {
|
||||
const navItems: NavItem[] = useMemo(
|
||||
() => [
|
||||
{ to: '/admin', label: 'Товары', icon: <StorefrontOutlinedIcon /> },
|
||||
{ to: '/admin/categories', label: 'Категории', icon: <AdminPanelSettingsOutlinedIcon /> },
|
||||
{ to: '/admin/gallery', label: 'Галерея', icon: <PhotoLibraryOutlinedIcon /> },
|
||||
{ to: '/admin/orders', label: 'Заказы', icon: <AssignmentOutlinedIcon /> },
|
||||
{ to: '/admin/reviews', label: 'Отзывы', icon: <RateReviewOutlinedIcon /> },
|
||||
@@ -185,7 +187,8 @@ export function AdminLayoutPage() {
|
||||
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0, width: '100%' }}>
|
||||
<Routes>
|
||||
<Route index element={<AdminPage />} />
|
||||
<Route index element={<AdminProductsPage />} />
|
||||
<Route path="categories" element={<AdminCategoriesPage />} />
|
||||
<Route path="gallery" element={<AdminGalleryPage />} />
|
||||
<Route path="orders" element={<AdminOrdersPage />} />
|
||||
<Route path="reviews" element={<AdminReviewsPage />} />
|
||||
|
||||
@@ -2,10 +2,6 @@ import { Fragment, 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 FormControl from '@mui/material/FormControl'
|
||||
import InputLabel from '@mui/material/InputLabel'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
@@ -33,6 +29,7 @@ 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'
|
||||
@@ -247,156 +244,155 @@ export function AdminOrdersPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} fullWidth maxWidth="md">
|
||||
<DialogTitle>Заказ</DialogTitle>
|
||||
<DialogContent>
|
||||
{!detail && orderDetailQuery.isLoading && <Typography>Загрузка…</Typography>}
|
||||
{orderDetailQuery.isError && <Alert severity="error">Не удалось загрузить заказ.</Alert>}
|
||||
{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>
|
||||
<AdminDialog
|
||||
open={dialogOpen}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
title="Заказ"
|
||||
maxWidth="md"
|
||||
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 ?? '—'}
|
||||
{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.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 variant="body2">
|
||||
<Box component="span" sx={{ color: 'text.secondary' }}>
|
||||
Адрес:
|
||||
</Box>{' '}
|
||||
{deliverySnapshot.addressLine ?? '—'}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
<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 === 'DELIVERY_FEE_ADJUSTMENT' && (
|
||||
<Alert severity="info">
|
||||
Укажите итоговую стоимость доставки (₽). После сохранения заказ получит статус «
|
||||
{orderStatusLabelRu('PENDING_PAYMENT')}», и клиент сможет оплатить с учётом этой суммы.
|
||||
</Alert>
|
||||
)}
|
||||
{detail.status === 'DELIVERY_FEE_ADJUSTMENT' && (
|
||||
<Alert severity="info">
|
||||
Укажите итоговую стоимость доставки (₽). После сохранения заказ получит статус «
|
||||
{orderStatusLabelRu('PENDING_PAYMENT')}», и клиент сможет оплатить с учётом этой суммы.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{detail.status === 'DELIVERY_FEE_ADJUSTMENT' && (
|
||||
<DeliveryFeeAdjustmentForm
|
||||
key={detail.id}
|
||||
orderId={detail.id}
|
||||
deliveryFeeCents={detail.deliveryFeeCents}
|
||||
/>
|
||||
)}
|
||||
{detail.status === 'DELIVERY_FEE_ADJUSTMENT' && (
|
||||
<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>
|
||||
<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>
|
||||
{nextStatuses.map((s) => (
|
||||
<MenuItem key={s} value={s}>
|
||||
{orderStatusLabelRu(s)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
))}
|
||||
</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>
|
||||
|
||||
<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>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDialogOpen(false)}>Закрыть</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<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>
|
||||
)}
|
||||
</AdminDialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { AdminProductsPage } from './ui/AdminProductsPage'
|
||||
@@ -0,0 +1,604 @@
|
||||
import { useRef, 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 {
|
||||
createProduct,
|
||||
deleteProduct,
|
||||
fetchAdminProducts,
|
||||
fetchCategories,
|
||||
updateProduct,
|
||||
uploadAdminProductImages,
|
||||
} from '@/entities/product/api/product-api'
|
||||
import type { Category, Product } from '@/entities/product/model/types'
|
||||
import { formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
|
||||
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'
|
||||
|
||||
type FormState = {
|
||||
title: string
|
||||
slug: string
|
||||
shortDescription: string
|
||||
description: string
|
||||
quantity: string
|
||||
materials: string
|
||||
priceRub: string
|
||||
imageUrls: string[]
|
||||
published: boolean
|
||||
inStock: boolean
|
||||
leadTimeDays: string
|
||||
categoryId: string
|
||||
}
|
||||
|
||||
const emptyForm = (): FormState => ({
|
||||
title: '',
|
||||
slug: '',
|
||||
shortDescription: '',
|
||||
description: '',
|
||||
quantity: '',
|
||||
materials: '',
|
||||
priceRub: '',
|
||||
imageUrls: [],
|
||||
published: true,
|
||||
inStock: true,
|
||||
leadTimeDays: '',
|
||||
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(),
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const titleValue = productForm.watch('title')
|
||||
const inStockValue = productForm.watch('inStock')
|
||||
|
||||
const categoriesQuery = useQuery({
|
||||
queryKey: ['categories'],
|
||||
queryFn: () => fetchCategories(),
|
||||
})
|
||||
|
||||
const productsQuery = useQuery({
|
||||
queryKey: ['admin', 'products'],
|
||||
queryFn: fetchAdminProducts,
|
||||
})
|
||||
|
||||
const galleryForPickQuery = useQuery({
|
||||
queryKey: ['admin', 'gallery'],
|
||||
queryFn: fetchAdminGallery,
|
||||
enabled: galleryPickOpen,
|
||||
})
|
||||
|
||||
const openCreate = () => {
|
||||
productForm.reset(emptyForm())
|
||||
openCreateDialog()
|
||||
}
|
||||
|
||||
const openEdit = (p: Product) => {
|
||||
openEditDialog(p)
|
||||
const urls =
|
||||
(p.images ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.sort - b.sort)
|
||||
.map((x) => x.url) ?? (p.imageUrl ? [p.imageUrl] : [])
|
||||
productForm.reset({
|
||||
title: p.title,
|
||||
slug: p.slug,
|
||||
shortDescription: p.shortDescription ?? '',
|
||||
description: p.description ?? '',
|
||||
quantity: p.quantity === null || p.quantity === undefined ? '' : String(p.quantity),
|
||||
materials: (p.materials ?? []).join(', '),
|
||||
priceRub: String(p.priceCents / 100),
|
||||
imageUrls: urls,
|
||||
published: p.published,
|
||||
inStock: p.inStock,
|
||||
leadTimeDays: p.leadTimeDays ? String(p.leadTimeDays) : '',
|
||||
categoryId: p.categoryId,
|
||||
})
|
||||
}
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const form = productForm.getValues()
|
||||
const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100)
|
||||
if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена')
|
||||
const leadTimeDays = form.leadTimeDays.trim() ? Number(form.leadTimeDays) : null
|
||||
if (!form.inStock) {
|
||||
if (!Number.isFinite(leadTimeDays) || !leadTimeDays || leadTimeDays <= 0) {
|
||||
throw new Error('Если "под заказ", укажите срок исполнения (дней) > 0')
|
||||
}
|
||||
}
|
||||
const qty = form.quantity.trim() ? Number(form.quantity) : null
|
||||
if (qty !== null && (!Number.isFinite(qty) || qty < 0)) throw new Error('Некорректное количество')
|
||||
const materials = form.materials
|
||||
.split(',')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
await createProduct({
|
||||
title: form.title.trim(),
|
||||
slug: form.slug.trim() || undefined,
|
||||
shortDescription: form.shortDescription.trim() || null,
|
||||
description: form.description.trim() || null,
|
||||
quantity: qty === null ? null : Math.floor(qty),
|
||||
materials,
|
||||
priceCents,
|
||||
imageUrls: form.imageUrls,
|
||||
published: form.published,
|
||||
inStock: form.inStock,
|
||||
leadTimeDays: form.inStock ? null : Math.round(leadTimeDays!),
|
||||
categoryId: form.categoryId,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']])
|
||||
closeDialog()
|
||||
},
|
||||
})
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const form = productForm.getValues()
|
||||
const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100)
|
||||
if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена')
|
||||
const leadTimeDays = form.leadTimeDays.trim() ? Number(form.leadTimeDays) : null
|
||||
if (!form.inStock) {
|
||||
if (!Number.isFinite(leadTimeDays) || !leadTimeDays || leadTimeDays <= 0) {
|
||||
throw new Error('Если "под заказ", укажите срок исполнения (дней) > 0')
|
||||
}
|
||||
}
|
||||
const qty = form.quantity.trim() ? Number(form.quantity) : null
|
||||
if (qty !== null && (!Number.isFinite(qty) || qty < 0)) throw new Error('Некорректное количество')
|
||||
const materials = form.materials
|
||||
.split(',')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
await updateProduct(editing!.id, {
|
||||
title: form.title.trim(),
|
||||
slug: form.slug.trim(),
|
||||
shortDescription: form.shortDescription.trim() || null,
|
||||
description: form.description.trim() || null,
|
||||
quantity: qty === null ? null : Math.floor(qty),
|
||||
materials,
|
||||
priceCents,
|
||||
imageUrls: form.imageUrls,
|
||||
published: form.published,
|
||||
inStock: form.inStock,
|
||||
leadTimeDays: form.inStock ? null : Math.round(leadTimeDays!),
|
||||
categoryId: form.categoryId,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']])
|
||||
closeDialog()
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: string) => deleteProduct(id),
|
||||
onSuccess: () => {
|
||||
void invalidateQueryKeys(queryClient, [['admin', 'products'], ['products']])
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (editing) updateMut.mutate()
|
||||
else createMut.mutate()
|
||||
}
|
||||
|
||||
const productImagesInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const uploadImagesMut = useMutation({
|
||||
mutationFn: (picked: File[]) => uploadAdminProductImages(picked),
|
||||
onSuccess: (urls) => {
|
||||
const current = productForm.getValues('imageUrls')
|
||||
productForm.setValue('imageUrls', [...current, ...urls], { shouldDirty: true })
|
||||
if (productImagesInputRef.current) {
|
||||
productImagesInputRef.current.value = ''
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const mutationError = createMut.error ?? updateMut.error ?? deleteMut.error ?? uploadImagesMut.error
|
||||
|
||||
const removeImage = (url: string) => {
|
||||
const current = productForm.getValues('imageUrls')
|
||||
productForm.setValue(
|
||||
'imageUrls',
|
||||
current.filter((u) => u !== url),
|
||||
{ shouldDirty: true },
|
||||
)
|
||||
}
|
||||
|
||||
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 current = productForm.getValues('imageUrls')
|
||||
const merged = [...current]
|
||||
for (const url of gallerySelectedUrls) {
|
||||
if (!merged.includes(url)) {
|
||||
merged.push(url)
|
||||
}
|
||||
}
|
||||
productForm.setValue('imageUrls', merged, { shouldDirty: true })
|
||||
setGalleryPickOpen(false)
|
||||
setGallerySelectedUrls(new Set())
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Управление товарами
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
|
||||
<Button variant="contained" onClick={openCreate}>
|
||||
Новый товар
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{productsQuery.isError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
Ошибка загрузки. Проверьте, что сервер запущен, и у вас есть права администратора.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{mutationError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{getErrorMessage(mutationError)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Название</TableCell>
|
||||
<TableCell>Категория</TableCell>
|
||||
<TableCell>Цена</TableCell>
|
||||
<TableCell>Витрина</TableCell>
|
||||
<TableCell align="right">Действия</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{(productsQuery.data ?? []).map((p) => (
|
||||
<TableRow key={p.id} hover>
|
||||
<TableCell>{p.title}</TableCell>
|
||||
<TableCell>{p.category?.name ?? '—'}</TableCell>
|
||||
<TableCell>{formatPriceRub(p.priceCents)}</TableCell>
|
||||
<TableCell>{p.published ? 'да' : 'нет'}</TableCell>
|
||||
<TableCell align="right">
|
||||
<EntityRowActions onEdit={() => openEdit(p)} onDelete={() => deleteMut.mutate(p.id)} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<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"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label="Количество"
|
||||
fullWidth
|
||||
{...field}
|
||||
inputMode="numeric"
|
||||
helperText="Оставьте пустым, если не хотите вести учёт"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="priceRub"
|
||||
render={({ field }) => <TextField label="Цена, ₽" fullWidth {...field} />}
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
|
||||
Фото (загрузка)
|
||||
</Typography>
|
||||
<FormHelperText sx={{ mt: 0, mb: 1 }}>
|
||||
PNG, JPEG или WebP, до {formatAdminImageMaxSizeHint()} на файл. Крестик на превью убирает фото только из
|
||||
карточки; файл остаётся на сервере и в галерее.
|
||||
</FormHelperText>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
alignItems: { sm: 'center' },
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Button component="label" variant="outlined" disabled={uploadImagesMut.isPending}>
|
||||
Выбрать файлы
|
||||
<input
|
||||
ref={productImagesInputRef}
|
||||
hidden
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
const files = e.target.files
|
||||
if (!files || files.length === 0) return
|
||||
uploadImagesMut.mutate(Array.from(files))
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
setGallerySelectedUrls(new Set())
|
||||
setGalleryPickOpen(true)
|
||||
}}
|
||||
>
|
||||
Из галереи
|
||||
</Button>
|
||||
{uploadImagesMut.isPending && <Typography color="text.secondary">Загрузка…</Typography>}
|
||||
{uploadImagesMut.isError && <Typography color="error">Не удалось загрузить фото</Typography>}
|
||||
</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}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={url}
|
||||
alt="Фото товара"
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
<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>
|
||||
<InputLabel id="cat-label">Категория</InputLabel>
|
||||
<Select labelId="cat-label" label="Категория" {...field}>
|
||||
<MenuItem value="">
|
||||
<em>Не указано</em>
|
||||
</MenuItem>
|
||||
{(categoriesQuery.data ?? []).map((c: Category) => (
|
||||
<MenuItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="published"
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={<Switch checked={field.value} onChange={(_, v) => field.onChange(v)} />}
|
||||
label="Показывать в каталоге"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="inStock"
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={<Switch checked={field.value} onChange={(_, v) => field.onChange(v)} />}
|
||||
label={field.value ? 'В наличии' : 'Под заказ'}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{!inStockValue && (
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="leadTimeDays"
|
||||
render={({ field }) => <TextField label="Срок исполнения, дней" fullWidth {...field} />}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={closeDialog}>Отмена</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSubmit}
|
||||
disabled={!titleValue.trim() || createMut.isPending || updateMut.isPending}
|
||||
>
|
||||
{editing ? 'Сохранить' : 'Создать'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
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>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
|
||||
gap: 1.5,
|
||||
pt: 1,
|
||||
}}
|
||||
>
|
||||
{(galleryForPickQuery.data?.items ?? []).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
|
||||
component="img"
|
||||
src={item.url}
|
||||
alt=""
|
||||
sx={{ width: '100%', maxHeight: 100, objectFit: 'cover', borderRadius: 1, display: 'block' }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</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>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -2,15 +2,8 @@ import { useEffect, 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 Stack from '@mui/material/Stack'
|
||||
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 TablePagination from '@mui/material/TablePagination'
|
||||
import TableRow from '@mui/material/TableRow'
|
||||
import TextField from '@mui/material/TextField'
|
||||
@@ -23,6 +16,8 @@ import type { AdminUser } from '@/entities/user/model/types'
|
||||
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 { AdminDialog } from '@/shared/ui/AdminDialog/AdminDialog'
|
||||
import { AdminTable } from '@/shared/ui/AdminTable'
|
||||
import { EntityRowActions } from '@/shared/ui/EntityRowActions'
|
||||
|
||||
type UserFormState = {
|
||||
@@ -170,18 +165,25 @@ export function AdminUsersPage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<AdminTable
|
||||
columns={[
|
||||
{ key: 'email', label: 'Почта' },
|
||||
{ key: 'name', label: 'Имя' },
|
||||
{ key: 'createdAt', label: 'Создан' },
|
||||
{ key: 'updatedAt', label: 'Обновлён' },
|
||||
{ key: 'actions', label: 'Действия', align: 'right' },
|
||||
]}
|
||||
loading={usersQuery.isLoading}
|
||||
error={usersQuery.isError ? 'Ошибка загрузки.' : null}
|
||||
>
|
||||
{users.length === 0 && !usersQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell>Почта</TableCell>
|
||||
<TableCell>Имя</TableCell>
|
||||
<TableCell>Создан</TableCell>
|
||||
<TableCell>Обновлён</TableCell>
|
||||
<TableCell align="right">Действия</TableCell>
|
||||
<TableCell colSpan={5} sx={{ color: 'text.secondary' }}>
|
||||
Пользователей пока нет.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{users.map((u) => (
|
||||
) : (
|
||||
users.map((u) => (
|
||||
<TableRow key={u.id} hover>
|
||||
<TableCell>{u.email}</TableCell>
|
||||
<TableCell>{u.name ?? '—'}</TableCell>
|
||||
@@ -196,16 +198,9 @@ export function AdminUsersPage() {
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{users.length === 0 && !usersQuery.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} sx={{ color: 'text.secondary' }}>
|
||||
Пользователей пока нет.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
))
|
||||
)}
|
||||
</AdminTable>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
@@ -220,33 +215,37 @@ export function AdminUsersPage() {
|
||||
rowsPerPageOptions={[10, 20, 50, 100]}
|
||||
/>
|
||||
|
||||
<Dialog open={dialogOpen} onClose={closeDialog} fullWidth maxWidth="xs">
|
||||
<DialogTitle>{editing ? 'Редактировать пользователя' : 'Новый пользователь'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<Controller
|
||||
control={userForm.control}
|
||||
name="email"
|
||||
render={({ field }) => <TextField label="Почта" fullWidth required {...field} />}
|
||||
/>
|
||||
<Controller
|
||||
control={userForm.control}
|
||||
name="name"
|
||||
render={({ field }) => <TextField label="Имя/ник" fullWidth {...field} />}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={closeDialog}>Отмена</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => (editing ? updateMut.mutate() : createMut.mutate())}
|
||||
disabled={isSaveDisabled}
|
||||
>
|
||||
{editing ? 'Сохранить' : 'Создать'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<AdminDialog
|
||||
open={dialogOpen}
|
||||
onClose={closeDialog}
|
||||
title={editing ? 'Редактировать пользователя' : 'Новый пользователь'}
|
||||
maxWidth="xs"
|
||||
actions={
|
||||
<>
|
||||
<Button onClick={closeDialog}>Отмена</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => (editing ? updateMut.mutate() : createMut.mutate())}
|
||||
disabled={isSaveDisabled}
|
||||
>
|
||||
{editing ? 'Сохранить' : 'Создать'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<Controller
|
||||
control={userForm.control}
|
||||
name="email"
|
||||
render={({ field }) => <TextField label="Почта" fullWidth required {...field} />}
|
||||
/>
|
||||
<Controller
|
||||
control={userForm.control}
|
||||
name="name"
|
||||
render={({ field }) => <TextField label="Имя/ник" fullWidth {...field} />}
|
||||
/>
|
||||
</Stack>
|
||||
</AdminDialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'
|
||||
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/api/gallery-api'
|
||||
import { fetchAdminGallery } from '@/entities/gallery'
|
||||
import {
|
||||
createCategory,
|
||||
createProduct,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { SelectChangeEvent } from '@mui/material/Select'
|
||||
|
||||
export type UseProductFiltersResult = ReturnType<typeof useProductFilters>
|
||||
|
||||
export function useProductFilters() {
|
||||
const [categorySlug, setCategorySlug] = useState<string>('')
|
||||
const [availability, setAvailability] = useState<'all' | 'in_stock' | 'made_to_order'>('all')
|
||||
const [qInput, setQInput] = useState('')
|
||||
const [q, setQ] = useState('')
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
const [sort, setSort] = useState<'price_asc' | 'price_desc' | ''>('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(12)
|
||||
const [priceMinRub, setPriceMinRub] = useState('')
|
||||
const [priceMaxRub, setPriceMaxRub] = useState('')
|
||||
const [cardScale, setCardScale] = useState<70 | 90 | 110 | 130>(90)
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => {
|
||||
setQ(qInput.trim())
|
||||
setPage(1)
|
||||
}, 250)
|
||||
return () => window.clearTimeout(t)
|
||||
}, [qInput])
|
||||
|
||||
const handleCategoryChange = (e: SelectChangeEvent<string>) => {
|
||||
setCategorySlug(e.target.value)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleSortChange = (e: SelectChangeEvent<string>) => {
|
||||
const v = e.target.value
|
||||
if (v === '' || v === 'price_asc' || v === 'price_desc') {
|
||||
setSort(v)
|
||||
setPage(1)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (e: SelectChangeEvent<string>) => {
|
||||
const n = Number(e.target.value)
|
||||
if (Number.isFinite(n) && n > 0) {
|
||||
setPageSize(n)
|
||||
setPage(1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAvailabilityChange = (v: string) => {
|
||||
if (v === 'all' || v === 'in_stock' || v === 'made_to_order') {
|
||||
setAvailability(v)
|
||||
setPage(1)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePriceMinChange = (v: string) => {
|
||||
setPriceMinRub(v)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handlePriceMaxChange = (v: string) => {
|
||||
setPriceMaxRub(v)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleCardScaleChange = (v: number) => {
|
||||
if (v === 70 || v === 90 || v === 110 || v === 130) setCardScale(v as 70 | 90 | 110 | 130)
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
setCategorySlug('')
|
||||
setAvailability('all')
|
||||
setQInput('')
|
||||
setSort('')
|
||||
setPriceMinRub('')
|
||||
setPriceMaxRub('')
|
||||
setPageSize(12)
|
||||
setCardScale(90)
|
||||
setMoreOpen(false)
|
||||
}
|
||||
|
||||
const toCents = (v: string) => {
|
||||
const n = Number(String(v).trim().replace(',', '.'))
|
||||
return Number.isFinite(n) && n >= 0 ? Math.round(n * 100) : undefined
|
||||
}
|
||||
|
||||
return {
|
||||
categorySlug,
|
||||
availability,
|
||||
qInput,
|
||||
q,
|
||||
moreOpen,
|
||||
sort,
|
||||
page,
|
||||
pageSize,
|
||||
priceMinRub,
|
||||
priceMaxRub,
|
||||
cardScale,
|
||||
setPage,
|
||||
setQInput,
|
||||
setMoreOpen,
|
||||
handleCategoryChange,
|
||||
handleSortChange,
|
||||
handlePageSizeChange,
|
||||
handleAvailabilityChange,
|
||||
handlePriceMinChange,
|
||||
handlePriceMaxChange,
|
||||
handleCardScaleChange,
|
||||
resetFilters,
|
||||
toCents,
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Collapse from '@mui/material/Collapse'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import FormControl from '@mui/material/FormControl'
|
||||
import Grid from '@mui/material/Grid'
|
||||
import InputLabel from '@mui/material/InputLabel'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
import Pagination from '@mui/material/Pagination'
|
||||
import Paper from '@mui/material/Paper'
|
||||
import Select from '@mui/material/Select'
|
||||
import type { SelectChangeEvent } from '@mui/material/Select'
|
||||
import Skeleton from '@mui/material/Skeleton'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import ToggleButton from '@mui/material/ToggleButton'
|
||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useUnit } from 'effector-react'
|
||||
@@ -26,108 +14,59 @@ import { ToggleCartIcon } from '@/features/cart/toggle-cart-icon'
|
||||
import { $user } from '@/shared/model/auth'
|
||||
import { CatalogSlider } from '@/widgets/catalog-slider'
|
||||
import { ReviewsBlock } from '@/widgets/reviews-block'
|
||||
import { useProductFilters } from '../lib/use-product-filters'
|
||||
import { ProductFilters } from './ProductFilters'
|
||||
|
||||
export function HomePage() {
|
||||
const user = useUnit($user)
|
||||
const isAdmin = Boolean(user?.isAdmin)
|
||||
const [categorySlug, setCategorySlug] = useState<string>('')
|
||||
const [availability, setAvailability] = useState<'all' | 'in_stock' | 'made_to_order'>('all')
|
||||
const [qInput, setQInput] = useState('')
|
||||
const [q, setQ] = useState('')
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
const [sort, setSort] = useState<'price_asc' | 'price_desc' | ''>('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(12)
|
||||
const [priceMinRub, setPriceMinRub] = useState('')
|
||||
const [priceMaxRub, setPriceMaxRub] = useState('')
|
||||
const [cardScale, setCardScale] = useState<70 | 90 | 110 | 130>(90)
|
||||
const filters = useProductFilters()
|
||||
|
||||
const categoriesQuery = useQuery({
|
||||
queryKey: ['categories'],
|
||||
queryFn: () => fetchCategories(),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => {
|
||||
setQ(qInput.trim())
|
||||
setPage(1)
|
||||
}, 250)
|
||||
return () => window.clearTimeout(t)
|
||||
}, [qInput])
|
||||
|
||||
const productsQuery = useQuery({
|
||||
queryKey: [
|
||||
'products',
|
||||
'public',
|
||||
{
|
||||
categorySlug: categorySlug || 'all',
|
||||
availability,
|
||||
q,
|
||||
sort,
|
||||
page,
|
||||
pageSize,
|
||||
priceMinRub,
|
||||
priceMaxRub,
|
||||
categorySlug: filters.categorySlug || 'all',
|
||||
availability: filters.availability,
|
||||
q: filters.q,
|
||||
sort: filters.sort,
|
||||
page: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
priceMinRub: filters.priceMinRub,
|
||||
priceMaxRub: filters.priceMaxRub,
|
||||
},
|
||||
],
|
||||
queryFn: () => {
|
||||
const toCents = (v: string) => {
|
||||
const n = Number(String(v).trim().replace(',', '.'))
|
||||
return Number.isFinite(n) && n >= 0 ? Math.round(n * 100) : undefined
|
||||
}
|
||||
return fetchPublicProducts({
|
||||
categorySlug: categorySlug || undefined,
|
||||
availability: availability === 'all' ? undefined : availability,
|
||||
q: q || undefined,
|
||||
sort: sort || '',
|
||||
page,
|
||||
pageSize,
|
||||
priceMinCents: toCents(priceMinRub),
|
||||
priceMaxCents: toCents(priceMaxRub),
|
||||
})
|
||||
},
|
||||
queryFn: () =>
|
||||
fetchPublicProducts({
|
||||
categorySlug: filters.categorySlug || undefined,
|
||||
availability: filters.availability === 'all' ? undefined : filters.availability,
|
||||
q: filters.q || undefined,
|
||||
sort: filters.sort || '',
|
||||
page: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
priceMinCents: filters.toCents(filters.priceMinRub),
|
||||
priceMaxCents: filters.toCents(filters.priceMaxRub),
|
||||
}),
|
||||
})
|
||||
|
||||
const handleCategoryChange = (e: SelectChangeEvent<string>) => {
|
||||
setCategorySlug(e.target.value)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleSortChange = (e: SelectChangeEvent<string>) => {
|
||||
const v = e.target.value
|
||||
if (v === '' || v === 'price_asc' || v === 'price_desc') {
|
||||
setSort(v)
|
||||
setPage(1)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (e: SelectChangeEvent<string>) => {
|
||||
const n = Number(e.target.value)
|
||||
if (Number.isFinite(n) && n > 0) {
|
||||
setPageSize(n)
|
||||
setPage(1)
|
||||
}
|
||||
}
|
||||
|
||||
const title = useMemo(
|
||||
() =>
|
||||
categorySlug ? `Категория: ${categoriesQuery.data?.find((c) => c.slug === categorySlug)?.name ?? ''}` : 'Каталог',
|
||||
[categorySlug, categoriesQuery.data],
|
||||
filters.categorySlug
|
||||
? `Категория: ${categoriesQuery.data?.find((c) => c.slug === filters.categorySlug)?.name ?? ''}`
|
||||
: 'Каталог',
|
||||
[filters.categorySlug, categoriesQuery.data],
|
||||
)
|
||||
|
||||
const categoriesForFilter = useMemo(() => {
|
||||
const list = categoriesQuery.data ?? []
|
||||
return [...list].sort((a, b) => {
|
||||
if (a.slug === 'ne-ukazano') return 1
|
||||
if (b.slug === 'ne-ukazano') return -1
|
||||
return a.sort - b.sort || a.name.localeCompare(b.name, 'ru')
|
||||
})
|
||||
}, [categoriesQuery.data])
|
||||
|
||||
const products = productsQuery.data?.items ?? []
|
||||
const total = productsQuery.data?.total ?? 0
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||
const mediaHeight = Math.round(200 * (cardScale / 100))
|
||||
const totalPages = Math.max(1, Math.ceil(total / filters.pageSize))
|
||||
const mediaHeight = Math.round(200 * (filters.cardScale / 100))
|
||||
|
||||
return (
|
||||
<Box>
|
||||
@@ -140,224 +79,14 @@ export function HomePage() {
|
||||
Игрушки, сувениры и другие изделия ручной работы.
|
||||
</Typography>
|
||||
|
||||
<Stack spacing={2} sx={{ mb: 3 }}>
|
||||
<Stack
|
||||
direction={{ xs: 'column', md: 'row' }}
|
||||
spacing={2}
|
||||
sx={{ alignItems: { md: 'center' }, flexWrap: { md: 'wrap' } }}
|
||||
>
|
||||
<FormControl sx={{ minWidth: 220 }} size="small">
|
||||
<InputLabel id="category-filter-label">Категория</InputLabel>
|
||||
<Select<string>
|
||||
labelId="category-filter-label"
|
||||
label="Категория"
|
||||
value={categorySlug}
|
||||
onChange={handleCategoryChange}
|
||||
disabled={categoriesQuery.isLoading}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>Все</em>
|
||||
</MenuItem>
|
||||
{categoriesForFilter.map((c) => (
|
||||
<MenuItem key={c.id} value={c.slug}>
|
||||
{c.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
label="Поиск"
|
||||
value={qInput}
|
||||
onChange={(e) => setQInput(e.target.value)}
|
||||
sx={{ flexGrow: 1, minWidth: { xs: '100%', md: 360 } }}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'background.paper',
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
gap: 1.5,
|
||||
alignItems: { sm: 'center' },
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="subtitle2">Наличие</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Быстрый фильтр по наличию
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
size="small"
|
||||
value={availability}
|
||||
onChange={(_, v) => {
|
||||
if (v === 'all' || v === 'in_stock' || v === 'made_to_order') {
|
||||
setAvailability(v)
|
||||
setPage(1)
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
alignSelf: { xs: 'flex-start', sm: 'auto' },
|
||||
'& .MuiToggleButton-root': { px: 2, fontWeight: 700, letterSpacing: 0.2, textTransform: 'none' },
|
||||
'& .MuiToggleButton-root.Mui-selected': {
|
||||
bgcolor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
'&:hover': { bgcolor: 'primary.dark' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="all">Все</ToggleButton>
|
||||
<ToggleButton value="in_stock">В наличии</ToggleButton>
|
||||
<ToggleButton value="made_to_order">Под заказ</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Paper>
|
||||
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
spacing={1.5}
|
||||
sx={{ alignItems: { sm: 'center' }, justifyContent: 'space-between', flexWrap: 'wrap' }}
|
||||
>
|
||||
<Button variant="text" onClick={() => setMoreOpen((v) => !v)} sx={{ alignSelf: { xs: 'flex-start' } }}>
|
||||
{moreOpen ? 'Скрыть фильтры' : 'Фильтры и сортировка'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
setCategorySlug('')
|
||||
setAvailability('all')
|
||||
setQInput('')
|
||||
setSort('')
|
||||
setPriceMinRub('')
|
||||
setPriceMaxRub('')
|
||||
setPageSize(12)
|
||||
setCardScale(90)
|
||||
setMoreOpen(false)
|
||||
}}
|
||||
sx={{ alignSelf: { xs: 'flex-start' } }}
|
||||
>
|
||||
Сбросить
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Collapse in={moreOpen} unmountOnExit>
|
||||
<Stack
|
||||
direction={{ xs: 'column', md: 'row' }}
|
||||
spacing={2}
|
||||
sx={{ mt: 2, alignItems: { md: 'center' }, flexWrap: { md: 'wrap' } }}
|
||||
>
|
||||
<FormControl sx={{ minWidth: 220 }} size="small">
|
||||
<InputLabel id="sort-label">Сортировка</InputLabel>
|
||||
<Select<string> labelId="sort-label" label="Сортировка" value={sort} onChange={handleSortChange}>
|
||||
<MenuItem value="">
|
||||
<em>Сначала новые</em>
|
||||
</MenuItem>
|
||||
<MenuItem value="price_asc">Цена: по возрастанию</MenuItem>
|
||||
<MenuItem value="price_desc">Цена: по убыванию</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
label="Цена от, ₽"
|
||||
value={priceMinRub}
|
||||
onChange={(e) => {
|
||||
setPriceMinRub(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
sx={{ width: { xs: '100%', md: 180 } }}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Цена до, ₽"
|
||||
value={priceMaxRub}
|
||||
onChange={(e) => {
|
||||
setPriceMaxRub(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
sx={{ width: { xs: '100%', md: 180 } }}
|
||||
/>
|
||||
|
||||
<FormControl sx={{ minWidth: 220 }} size="small">
|
||||
<InputLabel id="page-size-label">На странице</InputLabel>
|
||||
<Select<string>
|
||||
labelId="page-size-label"
|
||||
label="На странице"
|
||||
value={String(pageSize)}
|
||||
onChange={handlePageSizeChange}
|
||||
>
|
||||
{[6, 12, 18, 24].map((n) => (
|
||||
<MenuItem key={n} value={String(n)}>
|
||||
{n}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'background.paper',
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
gap: 1.5,
|
||||
alignItems: { sm: 'center' },
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="subtitle2">Масштаб карточек</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Выберите размер карточек в каталоге
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
size="small"
|
||||
value={cardScale}
|
||||
onChange={(_, v) => {
|
||||
if (v === 70 || v === 90 || v === 110 || v === 130) setCardScale(v)
|
||||
}}
|
||||
sx={{
|
||||
alignSelf: { xs: 'flex-start', sm: 'auto' },
|
||||
'& .MuiToggleButton-root': {
|
||||
px: 2,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.2,
|
||||
textTransform: 'none',
|
||||
},
|
||||
'& .MuiToggleButton-root.Mui-selected': {
|
||||
bgcolor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
'&:hover': { bgcolor: 'primary.dark' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ToggleButton value={70}>S</ToggleButton>
|
||||
<ToggleButton value={90}>M</ToggleButton>
|
||||
<ToggleButton value={110}>L</ToggleButton>
|
||||
<ToggleButton value={130}>XL</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Paper>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
<ProductFilters
|
||||
{...filters}
|
||||
categories={categoriesQuery.data ?? []}
|
||||
categoriesLoading={categoriesQuery.isLoading}
|
||||
/>
|
||||
|
||||
{productsQuery.isLoading && (
|
||||
<Grid container spacing={2}>
|
||||
<Grid container spacing={2} sx={{ mt: 2 }}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={i}>
|
||||
<Skeleton variant="rectangular" height={360} />
|
||||
@@ -367,16 +96,20 @@ export function HomePage() {
|
||||
)}
|
||||
|
||||
{productsQuery.isError && (
|
||||
<Alert severity="error">Не удалось загрузить товары. Проверьте, что API запущен.</Alert>
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
Не удалось загрузить товары. Проверьте, что API запущен.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{productsQuery.isSuccess && products.length === 0 && (
|
||||
<Typography color="text.secondary">Пока нет опубликованных товаров.</Typography>
|
||||
<Typography color="text.secondary" sx={{ mt: 2 }}>
|
||||
Пока нет опубликованных товаров.
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{productsQuery.isSuccess && products.length > 0 && (
|
||||
<>
|
||||
<Grid container spacing={2}>
|
||||
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||
{products.map((p) => (
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={p.id}>
|
||||
<ProductCard
|
||||
@@ -393,9 +126,9 @@ export function HomePage() {
|
||||
{totalPages > 1 && (
|
||||
<Stack direction="row" sx={{ mt: 3, justifyContent: 'center' }}>
|
||||
<Pagination
|
||||
page={page}
|
||||
page={filters.page}
|
||||
count={totalPages}
|
||||
onChange={(_, v) => setPage(v)}
|
||||
onChange={(_, v) => filters.setPage(v)}
|
||||
color="primary"
|
||||
shape="rounded"
|
||||
showFirstButton
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { useMemo } from 'react'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Collapse from '@mui/material/Collapse'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import FormControl from '@mui/material/FormControl'
|
||||
import InputLabel from '@mui/material/InputLabel'
|
||||
import MenuItem from '@mui/material/MenuItem'
|
||||
import Paper from '@mui/material/Paper'
|
||||
import Select from '@mui/material/Select'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import ToggleButton from '@mui/material/ToggleButton'
|
||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import type { Category } from '@/entities/product/model/types'
|
||||
import type { UseProductFiltersResult } from '../lib/use-product-filters'
|
||||
|
||||
type Props = UseProductFiltersResult & {
|
||||
categories: Category[]
|
||||
categoriesLoading: boolean
|
||||
}
|
||||
|
||||
export function ProductFilters({
|
||||
categorySlug,
|
||||
availability,
|
||||
qInput,
|
||||
moreOpen,
|
||||
sort,
|
||||
pageSize,
|
||||
priceMinRub,
|
||||
priceMaxRub,
|
||||
cardScale,
|
||||
categories,
|
||||
categoriesLoading,
|
||||
setQInput,
|
||||
setMoreOpen,
|
||||
handleCategoryChange,
|
||||
handleSortChange,
|
||||
handlePageSizeChange,
|
||||
handleAvailabilityChange,
|
||||
handlePriceMinChange,
|
||||
handlePriceMaxChange,
|
||||
handleCardScaleChange,
|
||||
resetFilters,
|
||||
}: Props) {
|
||||
const categoriesForFilter = useMemo(() => {
|
||||
const list = categories ?? []
|
||||
return [...list].sort((a, b) => {
|
||||
if (a.slug === 'ne-ukazano') return 1
|
||||
if (b.slug === 'ne-ukazano') return -1
|
||||
return a.sort - b.sort || a.name.localeCompare(b.name, 'ru')
|
||||
})
|
||||
}, [categories])
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Stack
|
||||
direction={{ xs: 'column', md: 'row' }}
|
||||
spacing={2}
|
||||
sx={{ alignItems: { md: 'center' }, flexWrap: { md: 'wrap' } }}
|
||||
>
|
||||
<FormControl sx={{ minWidth: 220 }} size="small">
|
||||
<InputLabel id="category-filter-label">Категория</InputLabel>
|
||||
<Select<string>
|
||||
labelId="category-filter-label"
|
||||
label="Категория"
|
||||
value={categorySlug}
|
||||
onChange={handleCategoryChange}
|
||||
disabled={categoriesLoading}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>Все</em>
|
||||
</MenuItem>
|
||||
{categoriesForFilter.map((c) => (
|
||||
<MenuItem key={c.id} value={c.slug}>
|
||||
{c.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
label="Поиск"
|
||||
value={qInput}
|
||||
onChange={(e) => setQInput(e.target.value)}
|
||||
sx={{ flexGrow: 1, minWidth: { xs: '100%', md: 360 } }}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'background.paper',
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
gap: 1.5,
|
||||
alignItems: { sm: 'center' },
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="subtitle2">Наличие</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Быстрый фильтр по наличию
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
size="small"
|
||||
value={availability}
|
||||
onChange={(_, v) => handleAvailabilityChange(v)}
|
||||
sx={{
|
||||
alignSelf: { xs: 'flex-start', sm: 'auto' },
|
||||
'& .MuiToggleButton-root': { px: 2, fontWeight: 700, letterSpacing: 0.2, textTransform: 'none' },
|
||||
'& .MuiToggleButton-root.Mui-selected': {
|
||||
bgcolor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
'&:hover': { bgcolor: 'primary.dark' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="all">Все</ToggleButton>
|
||||
<ToggleButton value="in_stock">В наличии</ToggleButton>
|
||||
<ToggleButton value="made_to_order">Под заказ</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Paper>
|
||||
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
spacing={1.5}
|
||||
sx={{ alignItems: { sm: 'center' }, justifyContent: 'space-between', flexWrap: 'wrap' }}
|
||||
>
|
||||
<Button variant="text" onClick={() => setMoreOpen((v) => !v)} sx={{ alignSelf: { xs: 'flex-start' } }}>
|
||||
{moreOpen ? 'Скрыть фильтры' : 'Фильтры и сортировка'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={resetFilters}
|
||||
sx={{ alignSelf: { xs: 'flex-start' } }}
|
||||
>
|
||||
Сбросить
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Collapse in={moreOpen} unmountOnExit>
|
||||
<Stack
|
||||
direction={{ xs: 'column', md: 'row' }}
|
||||
spacing={2}
|
||||
sx={{ mt: 2, alignItems: { md: 'center' }, flexWrap: { md: 'wrap' } }}
|
||||
>
|
||||
<FormControl sx={{ minWidth: 220 }} size="small">
|
||||
<InputLabel id="sort-label">Сортировка</InputLabel>
|
||||
<Select<string> labelId="sort-label" label="Сортировка" value={sort} onChange={handleSortChange}>
|
||||
<MenuItem value="">
|
||||
<em>Сначала новые</em>
|
||||
</MenuItem>
|
||||
<MenuItem value="price_asc">Цена: по возрастанию</MenuItem>
|
||||
<MenuItem value="price_desc">Цена: по убыванию</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
label="Цена от, ₽"
|
||||
value={priceMinRub}
|
||||
onChange={(e) => handlePriceMinChange(e.target.value)}
|
||||
sx={{ width: { xs: '100%', md: 180 } }}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Цена до, ₽"
|
||||
value={priceMaxRub}
|
||||
onChange={(e) => handlePriceMaxChange(e.target.value)}
|
||||
sx={{ width: { xs: '100%', md: 180 } }}
|
||||
/>
|
||||
|
||||
<FormControl sx={{ minWidth: 220 }} size="small">
|
||||
<InputLabel id="page-size-label">На странице</InputLabel>
|
||||
<Select<string>
|
||||
labelId="page-size-label"
|
||||
label="На странице"
|
||||
value={String(pageSize)}
|
||||
onChange={handlePageSizeChange}
|
||||
>
|
||||
{[6, 12, 18, 24].map((n) => (
|
||||
<MenuItem key={n} value={String(n)}>
|
||||
{n}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'background.paper',
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
gap: 1.5,
|
||||
alignItems: { sm: 'center' },
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="subtitle2">Масштаб карточек</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Выберите размер карточек в каталоге
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
size="small"
|
||||
value={cardScale}
|
||||
onChange={(_, v) => handleCardScaleChange(v)}
|
||||
sx={{
|
||||
alignSelf: { xs: 'flex-start', sm: 'auto' },
|
||||
'& .MuiToggleButton-root': {
|
||||
px: 2,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.2,
|
||||
textTransform: 'none',
|
||||
},
|
||||
'& .MuiToggleButton-root.Mui-selected': {
|
||||
bgcolor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
'&:hover': { bgcolor: 'primary.dark' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ToggleButton value={70}>S</ToggleButton>
|
||||
<ToggleButton value={90}>M</ToggleButton>
|
||||
<ToggleButton value={110}>L</ToggleButton>
|
||||
<ToggleButton value={130}>XL</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Paper>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import Paper from '@mui/material/Paper'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchPublicInfoBlocks } from '@/entities/info/api/info-page-api'
|
||||
import { fetchPublicInfoBlocks } from '@/entities/info'
|
||||
|
||||
export function InfoPage() {
|
||||
const q = useQuery({
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user