deploy
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user