This commit is contained in:
@kirill.komarov
2026-05-11 20:15:01 +05:00
parent 4eda6d0f81
commit 7a92991cff
19 changed files with 1010 additions and 49 deletions
@@ -0,0 +1,28 @@
import { apiClient } from '@/shared/api/client'
export type CatalogSliderSlide = {
id: string
url: string
caption: string
}
export type AdminCatalogSliderSlide = CatalogSliderSlide & {
galleryImageId: string
}
export async function fetchCatalogSlider(): Promise<{ slides: CatalogSliderSlide[] }> {
const { data } = await apiClient.get<{ slides: CatalogSliderSlide[] }>('catalog-slider')
return data
}
export async function fetchAdminCatalogSlider(): Promise<{ slides: AdminCatalogSliderSlide[] }> {
const { data } = await apiClient.get<{ slides: AdminCatalogSliderSlide[] }>('admin/catalog-slider')
return data
}
export async function putAdminCatalogSlider(body: {
slides: Array<{ galleryImageId: string; caption: string }>
}): Promise<{ slides: AdminCatalogSliderSlide[] }> {
const { data } = await apiClient.put<{ slides: AdminCatalogSliderSlide[] }>('admin/catalog-slider', body)
return data
}
+20 -2
View File
@@ -62,7 +62,8 @@ export async function createProduct(body: {
published: boolean
inStock?: boolean
leadTimeDays?: number | null
categoryId: string
/** Пустая строка / отсутствует — категория «Не указано» на сервере */
categoryId?: string
}): Promise<Product> {
const { data } = await apiClient.post<Product>('admin/products', body)
return data
@@ -83,7 +84,7 @@ export async function updateProduct(
published: boolean
inStock: boolean
leadTimeDays: number | null
categoryId: string
categoryId?: string
}>,
): Promise<Product> {
const { data } = await apiClient.patch<Product>(`admin/products/${id}`, body)
@@ -99,6 +100,23 @@ export async function createCategory(body: { name: string; slug?: string; sort?:
return data
}
export async function fetchAdminCategories(): Promise<Category[]> {
const { data } = await apiClient.get<{ items: Category[] }>('admin/categories')
return data.items
}
export async function updateAdminCategory(
id: string,
body: Partial<{ name: string; slug: string; sort: number }>,
): Promise<Category> {
const { data } = await apiClient.patch<Category>(`admin/categories/${id}`, body)
return data
}
export async function deleteAdminCategory(id: string): Promise<void> {
await apiClient.delete(`admin/categories/${id}`)
}
/** FormData: не задавать Content-Type вручную (boundary задаёт браузер). */
export async function uploadAdminProductImages(files: FileList | readonly File[]): Promise<string[]> {
const fd = new FormData()
@@ -2,19 +2,27 @@ 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 { uploadAdminProductImages } from '@/entities/product/api/product-api'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
import { GallerySliderSection } from './GallerySliderSection'
export function AdminGalleryPage() {
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const sliderQuery = useQuery({
queryKey: ['admin', 'catalog-slider'],
queryFn: fetchAdminCatalogSlider,
})
const galleryQuery = useQuery({
queryKey: ['admin', 'gallery'],
queryFn: fetchAdminGallery,
@@ -33,7 +41,7 @@ export function AdminGalleryPage() {
const deleteMut = useMutation({
mutationFn: (id: string) => deleteGalleryImage(id),
onSuccess: () => {
void invalidateQueryKeys(queryClient, [['admin', 'gallery']])
void invalidateQueryKeys(queryClient, [['admin', 'gallery'], ['admin', 'catalog-slider'], ['catalog-slider']])
},
})
@@ -49,6 +57,29 @@ export function AdminGalleryPage() {
галереи». Удаление из списка стирает файл с диска, если оно не используется в товаре.
</Typography>
{sliderQuery.isError && (
<Typography color="error" sx={{ mb: 2 }}>
Не удалось загрузить настройки слайдера.
</Typography>
)}
{sliderQuery.isLoading && (
<Typography color="text.secondary" sx={{ mb: 2 }}>
Загрузка настроек слайдера
</Typography>
)}
{sliderQuery.isSuccess && (
<GallerySliderSection
key={sliderQuery.dataUpdatedAt}
initialSlides={sliderQuery.data.slides.map((s) => ({
galleryImageId: s.galleryImageId,
caption: s.caption,
}))}
galleryItems={items}
/>
)}
<Divider sx={{ mb: 3 }} />
<Stack direction="row" spacing={2} sx={{ mb: 3, alignItems: 'center', flexWrap: 'wrap' }}>
<Button variant="contained" component="label" disabled={uploadMut.isPending}>
Загрузить файлы
@@ -0,0 +1,190 @@
import { useState } from 'react'
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'
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 IconButton from '@mui/material/IconButton'
import Paper from '@mui/material/Paper'
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 { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
export type SlideDraft = { galleryImageId: string; caption: string }
type Props = {
initialSlides: SlideDraft[]
galleryItems: GalleryImageItem[]
}
export function GallerySliderSection({ initialSlides, galleryItems }: Props) {
const queryClient = useQueryClient()
const [sliderDraft, setSliderDraft] = useState<SlideDraft[]>(initialSlides)
const [pickOpen, setPickOpen] = useState(false)
const usedIds = new Set(sliderDraft.map((s) => s.galleryImageId))
const pickCandidates = galleryItems.filter((i) => !usedIds.has(i.id))
const saveSliderMut = useMutation({
mutationFn: () => putAdminCatalogSlider({ slides: sliderDraft }),
onSuccess: async () => {
await invalidateQueryKeys(queryClient, [['admin', 'catalog-slider'], ['catalog-slider']])
},
})
const moveSlide = (idx: number, dir: -1 | 1) => {
const next = idx + dir
if (next < 0 || next >= sliderDraft.length) return
setSliderDraft((prev) => {
const copy = [...prev]
const t = copy[idx]!
copy[idx] = copy[next]!
copy[next] = t
return copy
})
}
return (
<>
<Paper variant="outlined" sx={{ p: 2, mb: 3, borderRadius: 2 }}>
<Typography variant="h6" gutterBottom>
Слайдер главной (каталог)
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Сначала загрузите фото в галерею ниже, затем добавьте слайды, укажите подписи и сохраните. Порядок строк =
порядок показа на витрине.
</Typography>
<Stack spacing={1.5} sx={{ mb: 2 }}>
{sliderDraft.map((row, idx) => {
const img = galleryItems.find((g) => g.id === row.galleryImageId)
return (
<Stack
key={`${row.galleryImageId}-${idx}`}
direction={{ xs: 'column', sm: 'row' }}
spacing={1.5}
sx={{ alignItems: { sm: 'flex-start' } }}
>
<Box
sx={{
width: 100,
height: 100,
flexShrink: 0,
borderRadius: 1,
overflow: 'hidden',
border: 1,
borderColor: 'divider',
}}
>
<Box
component="img"
src={img?.url ?? ''}
alt=""
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
</Box>
<TextField
label="Подпись на слайде"
fullWidth
multiline
minRows={2}
value={row.caption}
onChange={(e) => {
const v = e.target.value
setSliderDraft((prev) => {
const copy = [...prev]
copy[idx] = { ...copy[idx]!, caption: v }
return copy
})
}}
/>
<Stack direction="row" spacing={0.5}>
<IconButton size="small" aria-label="Выше" onClick={() => moveSlide(idx, -1)} disabled={idx === 0}>
<ArrowUpwardIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
aria-label="Ниже"
onClick={() => moveSlide(idx, 1)}
disabled={idx >= sliderDraft.length - 1}
>
<ArrowDownwardIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
color="error"
aria-label="Убрать из слайдера"
onClick={() => setSliderDraft((prev) => prev.filter((_, i) => i !== idx))}
>
<DeleteOutlineOutlinedIcon fontSize="small" />
</IconButton>
</Stack>
</Stack>
)
})}
</Stack>
<Stack direction="row" spacing={2} sx={{ flexWrap: 'wrap', alignItems: 'center' }}>
<Button variant="outlined" disabled={pickCandidates.length === 0} onClick={() => setPickOpen(true)}>
Добавить слайд из галереи
</Button>
<Button variant="contained" disabled={saveSliderMut.isPending} onClick={() => saveSliderMut.mutate()}>
Сохранить слайдер
</Button>
{saveSliderMut.isError && (
<Typography color="error">
{saveSliderMut.error instanceof Error ? saveSliderMut.error.message : 'Ошибка сохранения'}
</Typography>
)}
</Stack>
</Paper>
<Dialog open={pickOpen} onClose={() => setPickOpen(false)} fullWidth maxWidth="sm">
<DialogTitle>Выберите изображение</DialogTitle>
<DialogContent dividers>
{pickCandidates.length === 0 ? (
<Typography color="text.secondary">Нет доступных файлов (все уже в слайдере или галерея пуста).</Typography>
) : (
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
gap: 1.5,
pt: 1,
}}
>
{pickCandidates.map((item) => (
<Button
key={item.id}
sx={{ p: 0, minWidth: 0, display: 'block', borderRadius: 1, overflow: 'hidden' }}
onClick={() => {
setSliderDraft((prev) => [...prev, { galleryImageId: item.id, caption: '' }])
setPickOpen(false)
}}
>
<Box
component="img"
src={item.url}
alt=""
sx={{ width: '100%', aspectRatio: '1', objectFit: 'cover', display: 'block' }}
/>
</Button>
))}
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setPickOpen(false)}>Закрыть</Button>
</DialogActions>
</Dialog>
</>
)
}
@@ -59,7 +59,7 @@ function DeliveryFeeAdjustmentForm({ orderId, deliveryFeeCents }: { orderId: str
type="number"
value={rub}
onChange={(e) => setRub(e.target.value)}
inputProps={{ min: 0, step: 1 }}
slotProps={{ htmlInput: { min: 0, step: 1 } }}
sx={{ width: { xs: '100%', sm: 200 } }}
/>
<Button
+264 -38
View File
@@ -21,6 +21,8 @@ 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 ToggleButton from '@mui/material/ToggleButton'
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'
@@ -28,19 +30,24 @@ import { fetchAdminGallery } from '@/entities/gallery/api/gallery-api'
import {
createCategory,
createProduct,
deleteAdminCategory,
deleteProduct,
fetchAdminCategories,
fetchAdminProducts,
fetchCategories,
updateAdminCategory,
updateProduct,
uploadAdminProductImages,
} from '@/entities/product/api/product-api'
import type { Product } from '@/entities/product/model/types'
import type { Category, Product } from '@/entities/product/model/types'
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'
const UNSPECIFIED_CATEGORY_SLUG = 'ne-ukazano'
type FormState = {
title: string
slug: string
@@ -74,7 +81,11 @@ const emptyForm = (): FormState => ({
export function AdminPage() {
const queryClient = useQueryClient()
const { dialogOpen, editing, openCreateDialog, openEditDialog, closeDialog } = useEditDialogState<Product>()
const [adminSection, setAdminSection] = useState<'products' | 'categories'>('products')
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 [galleryPickOpen, setGalleryPickOpen] = useState(false)
const [gallerySelectedUrls, setGallerySelectedUrls] = useState<Set<string>>(() => new Set())
@@ -89,7 +100,6 @@ export function AdminPage() {
})
const titleValue = productForm.watch('title')
const categoryIdValue = productForm.watch('categoryId')
const inStockValue = productForm.watch('inStock')
const categoriesQuery = useQuery({
@@ -108,6 +118,17 @@ export function AdminPage() {
enabled: galleryPickOpen,
})
const adminCategoriesQuery = useQuery({
queryKey: ['admin', 'categories'],
queryFn: fetchAdminCategories,
enabled: adminSection === 'categories',
})
const categoryEditForm = useForm<{ name: string; slug: string; sort: string }>({
defaultValues: { name: '', slug: '', sort: '0' },
mode: 'onChange',
})
const openCreate = () => {
productForm.reset(emptyForm())
openCreateDialog()
@@ -228,12 +249,41 @@ export function AdminPage() {
})
},
onSuccess: () => {
void invalidateQueryKeys(queryClient, [['categories']])
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 handleSubmit = () => {
if (editing) updateMut.mutate()
else createMut.mutate()
@@ -253,7 +303,23 @@ export function AdminPage() {
})
const mutationError =
createMut.error ?? updateMut.error ?? deleteMut.error ?? createCategoryMut.error ?? uploadImagesMut.error
createMut.error ??
updateMut.error ??
deleteMut.error ??
createCategoryMut.error ??
updateCategoryMut.error ??
deleteCategoryMut.error ??
uploadImagesMut.error
const openCategoryEdit = (c: Category) => {
setEditingCategory(c)
categoryEditForm.reset({
name: c.name,
slug: c.slug,
sort: String(c.sort),
})
setCategoryEditOpen(true)
}
const removeImage = (url: string) => {
const current = productForm.getValues('imageUrls')
@@ -297,16 +363,37 @@ export function AdminPage() {
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Управление товарами и категориями. Доступно пользователю с правами администратора.
</Typography>
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
<Button variant="contained" onClick={openCreate}>
Новый товар
</Button>
<Button variant="outlined" onClick={() => setCatOpen(true)}>
Новая категория
</Button>
</Stack>
{productsQuery.isError && (
<ToggleButtonGroup
exclusive
value={adminSection}
onChange={(_, v) => {
if (v === 'products' || v === 'categories') setAdminSection(v)
}}
size="small"
sx={{ mb: 2 }}
>
<ToggleButton value="products">Товары</ToggleButton>
<ToggleButton value="categories">Категории</ToggleButton>
</ToggleButtonGroup>
{adminSection === 'products' && (
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
<Button variant="contained" onClick={openCreate}>
Новый товар
</Button>
</Stack>
)}
{adminSection === 'categories' && (
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
<Button variant="contained" onClick={() => setCatOpen(true)}>
Новая категория
</Button>
</Stack>
)}
{adminSection === 'products' && productsQuery.isError && (
<Alert severity="error" sx={{ mb: 2 }}>
Ошибка загрузки. Проверьте, что сервер запущен, и у вас есть права администратора.
</Alert>
@@ -318,30 +405,67 @@ export function AdminPage() {
</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>
{adminSection === 'products' && (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Название</TableCell>
<TableCell>Категория</TableCell>
<TableCell>Цена</TableCell>
<TableCell>Витрина</TableCell>
<TableCell align="right">Действия</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</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>
)}
{adminSection === 'categories' && (
<>
{adminCategoriesQuery.isError && (
<Alert severity="error" sx={{ mb: 2 }}>
Не удалось загрузить категории.
</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={dialogOpen} onClose={closeDialog} fullWidth maxWidth="sm">
<DialogTitle>{editing ? 'Редактировать товар' : 'Новый товар'}</DialogTitle>
@@ -500,9 +624,12 @@ export function AdminPage() {
control={productForm.control}
name="categoryId"
render={({ field }) => (
<FormControl fullWidth required>
<FormControl fullWidth>
<InputLabel id="cat-label">Категория</InputLabel>
<Select labelId="cat-label" label="Категория" {...field}>
<MenuItem value="">
<em>Не указано</em>
</MenuItem>
{(categoriesQuery.data ?? []).map((c) => (
<MenuItem key={c.id} value={c.id}>
{c.name}
@@ -546,7 +673,7 @@ export function AdminPage() {
<Button
variant="contained"
onClick={handleSubmit}
disabled={!titleValue.trim() || !categoryIdValue || createMut.isPending || updateMut.isPending}
disabled={!titleValue.trim() || createMut.isPending || updateMut.isPending}
>
{editing ? 'Сохранить' : 'Создать'}
</Button>
@@ -658,6 +785,105 @@ export function AdminPage() {
</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>
)
}
+13 -1
View File
@@ -24,6 +24,7 @@ import { fetchCategories, fetchPublicProducts } from '@/entities/product/api/pro
import { ProductCard } from '@/entities/product/ui/ProductCard'
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'
export function HomePage() {
@@ -114,6 +115,15 @@ export function HomePage() {
[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))
@@ -121,6 +131,8 @@ export function HomePage() {
return (
<Box>
<CatalogSlider />
<Typography variant="h4" component="h1" gutterBottom>
{title}
</Typography>
@@ -146,7 +158,7 @@ export function HomePage() {
<MenuItem value="">
<em>Все</em>
</MenuItem>
{(categoriesQuery.data ?? []).map((c) => (
{categoriesForFilter.map((c) => (
<MenuItem key={c.id} value={c.slug}>
{c.name}
</MenuItem>
@@ -0,0 +1 @@
export { CatalogSlider } from './ui/CatalogSlider'
@@ -0,0 +1,156 @@
import { useEffect, useState } from 'react'
import Box from '@mui/material/Box'
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 type { CatalogSliderSlide } from '@/entities/catalog-slider/api/catalog-slider-api'
import { fetchCatalogSlider } from '@/entities/catalog-slider/api/catalog-slider-api'
const AUTO_MS = 5500
function CatalogSliderInner({ slides }: { slides: CatalogSliderSlide[] }) {
const [index, setIndex] = useState(0)
const [paused, setPaused] = useState(false)
useEffect(() => {
if (paused || slides.length <= 1) return undefined
const id = window.setInterval(() => {
setIndex((i) => (i + 1) % slides.length)
}, AUTO_MS)
return () => window.clearInterval(id)
}, [paused, slides.length])
return (
<Paper
elevation={0}
variant="outlined"
sx={{
borderRadius: 2,
overflow: 'hidden',
position: 'relative',
}}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
<Box
component="section"
aria-roledescription="carousel"
aria-label="Фотогалерея каталога"
aria-live={paused ? 'off' : 'polite'}
sx={{
position: 'relative',
width: '100%',
aspectRatio: { xs: '4/3', sm: '21/9' },
maxHeight: { xs: 320, sm: 400 },
bgcolor: 'action.hover',
}}
>
{slides.map((slide, i) => (
<Box
key={slide.id}
sx={{
position: 'absolute',
inset: 0,
opacity: i === index ? 1 : 0,
transition: 'opacity 0.75s ease-in-out',
pointerEvents: 'none',
}}
>
<Box
component="img"
src={slide.url}
alt=""
loading={i === 0 ? 'eager' : 'lazy'}
sx={{
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block',
}}
/>
{slide.caption.trim() ? (
<Box
sx={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
px: 2,
pt: 4,
pb: slides.length > 1 ? 5 : 2,
background: 'linear-gradient(to top, rgba(0,0,0,0.78) 0%, rgba(0,0,0,0.35) 55%, transparent 100%)',
}}
>
<Typography
color="common.white"
variant="subtitle1"
sx={{ fontWeight: 700, textShadow: '0 1px 4px rgba(0,0,0,0.6)' }}
>
{slide.caption.trim()}
</Typography>
</Box>
) : null}
</Box>
))}
{slides.length > 1 && (
<Stack
direction="row"
spacing={0.75}
sx={{
position: 'absolute',
bottom: 12,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 2,
px: 1,
py: 0.5,
borderRadius: 2,
bgcolor: 'rgba(0,0,0,0.35)',
}}
>
{slides.map((slide, i) => (
<Box
key={slide.id}
component="button"
type="button"
aria-label={`Слайд ${i + 1} из ${slides.length}`}
aria-current={i === index ? 'true' : undefined}
onClick={() => setIndex(i)}
sx={{
width: i === index ? 22 : 8,
height: 8,
p: 0,
border: 'none',
borderRadius: 99,
bgcolor: i === index ? 'common.white' : 'rgba(255,255,255,0.45)',
cursor: 'pointer',
transition: 'width 0.25s, background-color 0.25s',
}}
/>
))}
</Stack>
)}
</Box>
</Paper>
)
}
export function CatalogSlider() {
const { data, isSuccess } = useQuery({
queryKey: ['catalog-slider'],
queryFn: fetchCatalogSlider,
})
const slides = data?.slides ?? []
const slideKey = slides.map((s) => s.id).join('|')
if (!isSuccess || slides.length === 0) return null
return (
<Box sx={{ width: '100%', mb: 3 }}>
<CatalogSliderInner key={slideKey} slides={slides} />
</Box>
)
}