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
+234 -8
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>
<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>
<Button variant="outlined" onClick={() => setCatOpen(true)}>
</Stack>
)}
{adminSection === 'categories' && (
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
<Button variant="contained" onClick={() => setCatOpen(true)}>
Новая категория
</Button>
</Stack>
)}
{productsQuery.isError && (
{adminSection === 'products' && productsQuery.isError && (
<Alert severity="error" sx={{ mb: 2 }}>
Ошибка загрузки. Проверьте, что сервер запущен, и у вас есть права администратора.
</Alert>
@@ -318,6 +405,7 @@ export function AdminPage() {
</Alert>
)}
{adminSection === 'products' && (
<Table size="small">
<TableHead>
<TableRow>
@@ -342,6 +430,42 @@ export function AdminPage() {
))}
</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>
)
}
@@ -0,0 +1,39 @@
-- CreateTable
CREATE TABLE "CatalogSliderSlide" (
"id" TEXT NOT NULL PRIMARY KEY,
"sortOrder" INTEGER NOT NULL,
"caption" TEXT NOT NULL DEFAULT '',
"galleryImageId" TEXT NOT NULL,
CONSTRAINT "CatalogSliderSlide_galleryImageId_fkey" FOREIGN KEY ("galleryImageId") REFERENCES "GalleryImage" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Product" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"shortDescription" TEXT,
"description" TEXT,
"quantity" INTEGER NOT NULL DEFAULT 0,
"materials" TEXT NOT NULL DEFAULT '[]',
"priceCents" INTEGER NOT NULL,
"imageUrl" TEXT,
"published" BOOLEAN NOT NULL DEFAULT false,
"inStock" BOOLEAN NOT NULL DEFAULT true,
"leadTimeDays" INTEGER,
"categoryId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Product_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_Product" ("categoryId", "createdAt", "description", "id", "imageUrl", "inStock", "leadTimeDays", "materials", "priceCents", "published", "quantity", "shortDescription", "slug", "title", "updatedAt") SELECT "categoryId", "createdAt", "description", "id", "imageUrl", "inStock", "leadTimeDays", "materials", "priceCents", "published", "quantity", "shortDescription", "slug", "title", "updatedAt" FROM "Product";
DROP TABLE "Product";
ALTER TABLE "new_Product" RENAME TO "Product";
CREATE UNIQUE INDEX "Product_slug_key" ON "Product"("slug");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE INDEX "CatalogSliderSlide_sortOrder_idx" ON "CatalogSliderSlide"("sortOrder");
+14 -1
View File
@@ -32,7 +32,7 @@ model Product {
published Boolean @default(false)
inStock Boolean @default(true)
leadTimeDays Int?
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict)
categoryId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -60,6 +60,19 @@ model GalleryImage {
id String @id @default(cuid())
url String @unique
createdAt DateTime @default(now())
catalogSliderSlides CatalogSliderSlide[]
}
/// Слайды главной витрины (каталог): картинка из галереи + подпись.
model CatalogSliderSlide {
id String @id @default(cuid())
sortOrder Int
caption String @default("")
galleryImageId String
galleryImage GalleryImage @relation(fields: [galleryImageId], references: [id], onDelete: Cascade)
@@index([sortOrder])
}
model User {
+2
View File
@@ -6,6 +6,7 @@ import multipart from '@fastify/multipart'
import fastifyStatic from '@fastify/static'
import path from 'node:path'
import { ensureAdminUser } from './lib/bootstrap-admin.js'
import { getOrCreateUnspecifiedCategory } from './lib/default-category.js'
import { getMaxUploadBodyBytes, getProductImageMaxFileBytes } from './lib/upload-limits.js'
import { registerAuth } from './plugins/auth.js'
import { registerApiRoutes } from './routes/api.js'
@@ -58,6 +59,7 @@ await registerAuthRoutes(fastify)
await registerOAuthSocialRoutes(fastify)
await registerApiRoutes(fastify)
await ensureAdminUser()
await getOrCreateUnspecifiedCategory()
fastify.get('/health', async () => ({ ok: true }))
+20
View File
@@ -0,0 +1,20 @@
import { prisma } from './prisma.js'
/** Служебная категория для товаров без выбранной категории. Slug не менять. */
export const UNSPECIFIED_CATEGORY_SLUG = 'ne-ukazano'
export async function getOrCreateUnspecifiedCategory() {
return prisma.category.upsert({
where: { slug: UNSPECIFIED_CATEGORY_SLUG },
update: {},
create: {
name: 'Не указано',
slug: UNSPECIFIED_CATEGORY_SLUG,
sort: 9999,
},
})
}
export function isUnspecifiedCategorySlug(slug) {
return slug === UNSPECIFIED_CATEGORY_SLUG
}
+2
View File
@@ -5,6 +5,7 @@ import {
} from './api/_product-helpers.js'
import { registerAdminGalleryRoutes } from './api/admin-gallery.js'
import { registerAdminCategoryRoutes } from './api/admin-categories.js'
import { registerCatalogSliderRoutes } from './api/catalog-slider.js'
import { registerAdminOrderRoutes } from './api/admin-orders.js'
import { registerAdminProductRoutes } from './api/admin-products.js'
import { registerAdminReviewRoutes } from './api/admin-reviews.js'
@@ -17,6 +18,7 @@ export async function registerApiRoutes(fastify) {
await registerPublicCatalogRoutes(fastify, { mapProductForApi })
await registerPublicReviewRoutes(fastify)
await registerInfoPageRoutes(fastify)
await registerCatalogSliderRoutes(fastify)
await registerAdminProductRoutes(fastify, {
slugify,
+106 -1
View File
@@ -1,6 +1,22 @@
import {
getOrCreateUnspecifiedCategory,
isUnspecifiedCategorySlug,
UNSPECIFIED_CATEGORY_SLUG,
} from '../../lib/default-category.js'
import { prisma } from '../../lib/prisma.js'
export async function registerAdminCategoryRoutes(fastify, { slugify } = {}) {
fastify.get(
'/api/admin/categories',
{ preHandler: [fastify.verifyAdmin] },
async () => {
const items = await prisma.category.findMany({
orderBy: [{ sort: 'asc' }, { name: 'asc' }],
})
return { items }
},
)
fastify.post(
'/api/admin/categories',
{ preHandler: [fastify.verifyAdmin] },
@@ -12,6 +28,10 @@ export async function registerAdminCategoryRoutes(fastify, { slugify } = {}) {
return
}
const slug = String(body.slug ?? '').trim() || slugify(name) || `cat-${Date.now()}`
if (isUnspecifiedCategorySlug(slug)) {
reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` })
return
}
const sort = body.sort !== undefined && body.sort !== null && body.sort !== '' ? Number(body.sort) : undefined
const exists = await prisma.category.findUnique({ where: { slug } })
if (exists) {
@@ -28,5 +48,90 @@ export async function registerAdminCategoryRoutes(fastify, { slugify } = {}) {
reply.code(201).send(category)
},
)
}
fastify.patch(
'/api/admin/categories/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
const body = request.body ?? {}
const existing = await prisma.category.findUnique({ where: { id } })
if (!existing) {
reply.code(404).send({ error: 'Категория не найдена' })
return
}
const data = {}
if (body.name !== undefined) data.name = String(body.name ?? '').trim()
if (body.sort !== undefined) {
const s = Number(body.sort)
if (!Number.isFinite(s)) {
reply.code(400).send({ error: 'Некорректный sort' })
return
}
data.sort = Math.round(s)
}
if (body.slug !== undefined) {
const s = String(body.slug ?? '').trim()
if (isUnspecifiedCategorySlug(existing.slug) && s !== UNSPECIFIED_CATEGORY_SLUG) {
reply.code(400).send({ error: 'Нельзя сменить slug служебной категории «Не указано»' })
return
}
if (!s) {
reply.code(400).send({ error: 'Slug не может быть пустым' })
return
}
if (s !== existing.slug) {
if (isUnspecifiedCategorySlug(s)) {
reply.code(400).send({ error: `Slug «${UNSPECIFIED_CATEGORY_SLUG}» зарезервирован` })
return
}
const clash = await prisma.category.findFirst({ where: { slug: s, NOT: { id } } })
if (clash) {
reply.code(409).send({ error: 'Такой slug уже занят' })
return
}
}
data.slug = s
}
if (Object.keys(data).length === 0) {
return existing
}
if (data.name !== undefined && !data.name) {
reply.code(400).send({ error: 'Укажите название' })
return
}
const updated = await prisma.category.update({ where: { id }, data })
return updated
},
)
fastify.delete(
'/api/admin/categories/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
const existing = await prisma.category.findUnique({ where: { id } })
if (!existing) {
reply.code(404).send({ error: 'Категория не найдена' })
return
}
if (isUnspecifiedCategorySlug(existing.slug)) {
reply.code(409).send({ error: 'Служебную категорию «Не указано» нельзя удалить' })
return
}
const fallback = await getOrCreateUnspecifiedCategory()
await prisma.$transaction([
prisma.product.updateMany({
where: { categoryId: id },
data: { categoryId: fallback.id },
}),
prisma.category.delete({ where: { id } }),
])
return reply.code(204).send()
},
)
}
+22 -3
View File
@@ -1,3 +1,4 @@
import { getOrCreateUnspecifiedCategory } from '../../lib/default-category.js'
import { upsertGalleryImagesByUrls } from '../../lib/gallery.js'
import { prisma } from '../../lib/prisma.js'
import {
@@ -60,11 +61,16 @@ export async function registerAdminProductRoutes(
return
}
const slug = String(body.slug ?? '').trim() || slugify(title) || `item-${Date.now()}`
const categoryId = String(body.categoryId ?? '').trim()
let categoryId = String(body.categoryId ?? '').trim()
if (!categoryId) {
reply.code(400).send({ error: 'Укажите категорию' })
categoryId = (await getOrCreateUnspecifiedCategory()).id
} else {
const cat = await prisma.category.findUnique({ where: { id: categoryId } })
if (!cat) {
reply.code(400).send({ error: 'Категория не найдена' })
return
}
}
const priceCents = Number(body.priceCents)
if (!Number.isFinite(priceCents) || priceCents < 0) {
reply.code(400).send({ error: 'Некорректная цена (priceCents ≥ 0)' })
@@ -190,7 +196,20 @@ export async function registerAdminProductRoutes(
data.imageUrl = body.imageUrl ? String(body.imageUrl) : null
}
if (body.published !== undefined) data.published = Boolean(body.published)
if (body.categoryId !== undefined) data.categoryId = String(body.categoryId)
if (body.categoryId !== undefined) {
const raw = body.categoryId
if (raw === null || raw === '') {
data.categoryId = (await getOrCreateUnspecifiedCategory()).id
} else {
const cid = String(raw).trim()
const cat = await prisma.category.findUnique({ where: { id: cid } })
if (!cat) {
reply.code(400).send({ error: 'Категория не найдена' })
return
}
data.categoryId = cid
}
}
if (body.inStock !== undefined) data.inStock = Boolean(body.inStock)
if (body.leadTimeDays !== undefined) {
+99
View File
@@ -0,0 +1,99 @@
import { prisma } from '../../lib/prisma.js'
const MAX_SLIDES = 20
export async function registerCatalogSliderRoutes(fastify) {
fastify.get('/api/catalog-slider', async () => {
const slides = await prisma.catalogSliderSlide.findMany({
orderBy: { sortOrder: 'asc' },
include: { galleryImage: true },
})
return {
slides: slides.map((s) => ({
id: s.id,
url: s.galleryImage.url,
caption: s.caption,
})),
}
})
fastify.get(
'/api/admin/catalog-slider',
{ preHandler: [fastify.verifyAdmin] },
async () => {
const slides = await prisma.catalogSliderSlide.findMany({
orderBy: { sortOrder: 'asc' },
include: { galleryImage: true },
})
return {
slides: slides.map((s) => ({
id: s.id,
galleryImageId: s.galleryImageId,
url: s.galleryImage.url,
caption: s.caption,
})),
}
},
)
fastify.put(
'/api/admin/catalog-slider',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const body = request.body ?? {}
const rawSlides = body.slides
if (!Array.isArray(rawSlides)) {
return reply.code(400).send({ error: 'Ожидается slides: массив' })
}
if (rawSlides.length > MAX_SLIDES) {
return reply.code(400).send({ error: `Не более ${MAX_SLIDES} слайдов` })
}
const seenGalleryIds = new Set()
const normalized = []
for (let i = 0; i < rawSlides.length; i++) {
const row = rawSlides[i]
const galleryImageId = String(row?.galleryImageId ?? '').trim()
if (!galleryImageId) {
return reply.code(400).send({ error: `Слайд ${i + 1}: укажите galleryImageId` })
}
if (seenGalleryIds.has(galleryImageId)) {
return reply.code(400).send({ error: 'Одно изображение нельзя добавить дважды' })
}
seenGalleryIds.add(galleryImageId)
const img = await prisma.galleryImage.findUnique({ where: { id: galleryImageId } })
if (!img) {
return reply.code(400).send({ error: `Изображение не найдено: ${galleryImageId}` })
}
const caption = row?.caption == null ? '' : String(row.caption).slice(0, 500)
normalized.push({ galleryImageId, caption, sortOrder: i })
}
await prisma.$transaction(async (tx) => {
await tx.catalogSliderSlide.deleteMany({})
for (const n of normalized) {
await tx.catalogSliderSlide.create({
data: {
sortOrder: n.sortOrder,
caption: n.caption,
galleryImageId: n.galleryImageId,
},
})
}
})
const slides = await prisma.catalogSliderSlide.findMany({
orderBy: { sortOrder: 'asc' },
include: { galleryImage: true },
})
return {
slides: slides.map((s) => ({
id: s.id,
galleryImageId: s.galleryImageId,
url: s.galleryImage.url,
caption: s.caption,
})),
}
},
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 MiB