Merge branch 'refactor'

This commit is contained in:
@kirill.komarov
2026-05-13 22:07:46 +05:00
parent 3c9797af4a
commit a06f9cf2c4
85 changed files with 3762 additions and 2072 deletions
@@ -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>
)
}
+1
View File
@@ -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>
)
}
+1 -1
View File
@@ -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,
}
}
+44 -311
View File
@@ -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
+250
View File
@@ -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>
)
}
+1 -1
View File
@@ -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>
)
}