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
|
published: boolean
|
||||||
inStock?: boolean
|
inStock?: boolean
|
||||||
leadTimeDays?: number | null
|
leadTimeDays?: number | null
|
||||||
categoryId: string
|
/** Пустая строка / отсутствует — категория «Не указано» на сервере */
|
||||||
|
categoryId?: string
|
||||||
}): Promise<Product> {
|
}): Promise<Product> {
|
||||||
const { data } = await apiClient.post<Product>('admin/products', body)
|
const { data } = await apiClient.post<Product>('admin/products', body)
|
||||||
return data
|
return data
|
||||||
@@ -83,7 +84,7 @@ export async function updateProduct(
|
|||||||
published: boolean
|
published: boolean
|
||||||
inStock: boolean
|
inStock: boolean
|
||||||
leadTimeDays: number | null
|
leadTimeDays: number | null
|
||||||
categoryId: string
|
categoryId?: string
|
||||||
}>,
|
}>,
|
||||||
): Promise<Product> {
|
): Promise<Product> {
|
||||||
const { data } = await apiClient.patch<Product>(`admin/products/${id}`, body)
|
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
|
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 задаёт браузер). */
|
/** FormData: не задавать Content-Type вручную (boundary задаёт браузер). */
|
||||||
export async function uploadAdminProductImages(files: FileList | readonly File[]): Promise<string[]> {
|
export async function uploadAdminProductImages(files: FileList | readonly File[]): Promise<string[]> {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
|
|||||||
@@ -2,19 +2,27 @@ import { useRef } from 'react'
|
|||||||
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'
|
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'
|
||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import Button from '@mui/material/Button'
|
import Button from '@mui/material/Button'
|
||||||
|
import Divider from '@mui/material/Divider'
|
||||||
import IconButton from '@mui/material/IconButton'
|
import IconButton from '@mui/material/IconButton'
|
||||||
import Stack from '@mui/material/Stack'
|
import Stack from '@mui/material/Stack'
|
||||||
import Tooltip from '@mui/material/Tooltip'
|
import Tooltip from '@mui/material/Tooltip'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
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 { deleteGalleryImage, fetchAdminGallery } from '@/entities/gallery/api/gallery-api'
|
||||||
import { uploadAdminProductImages } from '@/entities/product/api/product-api'
|
import { uploadAdminProductImages } from '@/entities/product/api/product-api'
|
||||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||||
|
import { GallerySliderSection } from './GallerySliderSection'
|
||||||
|
|
||||||
export function AdminGalleryPage() {
|
export function AdminGalleryPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const sliderQuery = useQuery({
|
||||||
|
queryKey: ['admin', 'catalog-slider'],
|
||||||
|
queryFn: fetchAdminCatalogSlider,
|
||||||
|
})
|
||||||
|
|
||||||
const galleryQuery = useQuery({
|
const galleryQuery = useQuery({
|
||||||
queryKey: ['admin', 'gallery'],
|
queryKey: ['admin', 'gallery'],
|
||||||
queryFn: fetchAdminGallery,
|
queryFn: fetchAdminGallery,
|
||||||
@@ -33,7 +41,7 @@ export function AdminGalleryPage() {
|
|||||||
const deleteMut = useMutation({
|
const deleteMut = useMutation({
|
||||||
mutationFn: (id: string) => deleteGalleryImage(id),
|
mutationFn: (id: string) => deleteGalleryImage(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void invalidateQueryKeys(queryClient, [['admin', 'gallery']])
|
void invalidateQueryKeys(queryClient, [['admin', 'gallery'], ['admin', 'catalog-slider'], ['catalog-slider']])
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -49,6 +57,29 @@ export function AdminGalleryPage() {
|
|||||||
галереи». Удаление из списка стирает файл с диска, если оно не используется в товаре.
|
галереи». Удаление из списка стирает файл с диска, если оно не используется в товаре.
|
||||||
</Typography>
|
</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' }}>
|
<Stack direction="row" spacing={2} sx={{ mb: 3, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
<Button variant="contained" component="label" disabled={uploadMut.isPending}>
|
<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"
|
type="number"
|
||||||
value={rub}
|
value={rub}
|
||||||
onChange={(e) => setRub(e.target.value)}
|
onChange={(e) => setRub(e.target.value)}
|
||||||
inputProps={{ min: 0, step: 1 }}
|
slotProps={{ htmlInput: { min: 0, step: 1 } }}
|
||||||
sx={{ width: { xs: '100%', sm: 200 } }}
|
sx={{ width: { xs: '100%', sm: 200 } }}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import TableCell from '@mui/material/TableCell'
|
|||||||
import TableHead from '@mui/material/TableHead'
|
import TableHead from '@mui/material/TableHead'
|
||||||
import TableRow from '@mui/material/TableRow'
|
import TableRow from '@mui/material/TableRow'
|
||||||
import TextField from '@mui/material/TextField'
|
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 Typography from '@mui/material/Typography'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Controller, useForm } from 'react-hook-form'
|
import { Controller, useForm } from 'react-hook-form'
|
||||||
@@ -28,19 +30,24 @@ import { fetchAdminGallery } from '@/entities/gallery/api/gallery-api'
|
|||||||
import {
|
import {
|
||||||
createCategory,
|
createCategory,
|
||||||
createProduct,
|
createProduct,
|
||||||
|
deleteAdminCategory,
|
||||||
deleteProduct,
|
deleteProduct,
|
||||||
|
fetchAdminCategories,
|
||||||
fetchAdminProducts,
|
fetchAdminProducts,
|
||||||
fetchCategories,
|
fetchCategories,
|
||||||
|
updateAdminCategory,
|
||||||
updateProduct,
|
updateProduct,
|
||||||
uploadAdminProductImages,
|
uploadAdminProductImages,
|
||||||
} from '@/entities/product/api/product-api'
|
} 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 { formatPriceRub } from '@/shared/lib/format-price'
|
||||||
import { getErrorMessage } from '@/shared/lib/get-error-message'
|
import { getErrorMessage } from '@/shared/lib/get-error-message'
|
||||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||||
import { useEditDialogState } from '@/shared/lib/use-edit-dialog-state'
|
import { useEditDialogState } from '@/shared/lib/use-edit-dialog-state'
|
||||||
import { EntityRowActions } from '@/shared/ui/EntityRowActions'
|
import { EntityRowActions } from '@/shared/ui/EntityRowActions'
|
||||||
|
|
||||||
|
const UNSPECIFIED_CATEGORY_SLUG = 'ne-ukazano'
|
||||||
|
|
||||||
type FormState = {
|
type FormState = {
|
||||||
title: string
|
title: string
|
||||||
slug: string
|
slug: string
|
||||||
@@ -74,7 +81,11 @@ const emptyForm = (): FormState => ({
|
|||||||
export function AdminPage() {
|
export function AdminPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { dialogOpen, editing, openCreateDialog, openEditDialog, closeDialog } = useEditDialogState<Product>()
|
const { dialogOpen, editing, openCreateDialog, openEditDialog, closeDialog } = useEditDialogState<Product>()
|
||||||
|
const [adminSection, setAdminSection] = useState<'products' | 'categories'>('products')
|
||||||
const [catOpen, setCatOpen] = useState(false)
|
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 [galleryPickOpen, setGalleryPickOpen] = useState(false)
|
||||||
const [gallerySelectedUrls, setGallerySelectedUrls] = useState<Set<string>>(() => new Set())
|
const [gallerySelectedUrls, setGallerySelectedUrls] = useState<Set<string>>(() => new Set())
|
||||||
|
|
||||||
@@ -89,7 +100,6 @@ export function AdminPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const titleValue = productForm.watch('title')
|
const titleValue = productForm.watch('title')
|
||||||
const categoryIdValue = productForm.watch('categoryId')
|
|
||||||
const inStockValue = productForm.watch('inStock')
|
const inStockValue = productForm.watch('inStock')
|
||||||
|
|
||||||
const categoriesQuery = useQuery({
|
const categoriesQuery = useQuery({
|
||||||
@@ -108,6 +118,17 @@ export function AdminPage() {
|
|||||||
enabled: galleryPickOpen,
|
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 = () => {
|
const openCreate = () => {
|
||||||
productForm.reset(emptyForm())
|
productForm.reset(emptyForm())
|
||||||
openCreateDialog()
|
openCreateDialog()
|
||||||
@@ -228,12 +249,41 @@ export function AdminPage() {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void invalidateQueryKeys(queryClient, [['categories']])
|
void invalidateQueryKeys(queryClient, [['categories'], ['admin', 'categories']])
|
||||||
setCatOpen(false)
|
setCatOpen(false)
|
||||||
categoryForm.reset({ name: '', slug: '' })
|
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 = () => {
|
const handleSubmit = () => {
|
||||||
if (editing) updateMut.mutate()
|
if (editing) updateMut.mutate()
|
||||||
else createMut.mutate()
|
else createMut.mutate()
|
||||||
@@ -253,7 +303,23 @@ export function AdminPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const mutationError =
|
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 removeImage = (url: string) => {
|
||||||
const current = productForm.getValues('imageUrls')
|
const current = productForm.getValues('imageUrls')
|
||||||
@@ -297,16 +363,37 @@ export function AdminPage() {
|
|||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
Управление товарами и категориями. Доступно пользователю с правами администратора.
|
Управление товарами и категориями. Доступно пользователю с правами администратора.
|
||||||
</Typography>
|
</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 severity="error" sx={{ mb: 2 }}>
|
||||||
Ошибка загрузки. Проверьте, что сервер запущен, и у вас есть права администратора.
|
Ошибка загрузки. Проверьте, что сервер запущен, и у вас есть права администратора.
|
||||||
</Alert>
|
</Alert>
|
||||||
@@ -318,30 +405,67 @@ export function AdminPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Table size="small">
|
{adminSection === 'products' && (
|
||||||
<TableHead>
|
<Table size="small">
|
||||||
<TableRow>
|
<TableHead>
|
||||||
<TableCell>Название</TableCell>
|
<TableRow>
|
||||||
<TableCell>Категория</TableCell>
|
<TableCell>Название</TableCell>
|
||||||
<TableCell>Цена</TableCell>
|
<TableCell>Категория</TableCell>
|
||||||
<TableCell>Витрина</TableCell>
|
<TableCell>Цена</TableCell>
|
||||||
<TableCell align="right">Действия</TableCell>
|
<TableCell>Витрина</TableCell>
|
||||||
</TableRow>
|
<TableCell align="right">Действия</TableCell>
|
||||||
</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>
|
</TableRow>
|
||||||
))}
|
</TableHead>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{(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">
|
<Dialog open={dialogOpen} onClose={closeDialog} fullWidth maxWidth="sm">
|
||||||
<DialogTitle>{editing ? 'Редактировать товар' : 'Новый товар'}</DialogTitle>
|
<DialogTitle>{editing ? 'Редактировать товар' : 'Новый товар'}</DialogTitle>
|
||||||
@@ -500,9 +624,12 @@ export function AdminPage() {
|
|||||||
control={productForm.control}
|
control={productForm.control}
|
||||||
name="categoryId"
|
name="categoryId"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormControl fullWidth required>
|
<FormControl fullWidth>
|
||||||
<InputLabel id="cat-label">Категория</InputLabel>
|
<InputLabel id="cat-label">Категория</InputLabel>
|
||||||
<Select labelId="cat-label" label="Категория" {...field}>
|
<Select labelId="cat-label" label="Категория" {...field}>
|
||||||
|
<MenuItem value="">
|
||||||
|
<em>Не указано</em>
|
||||||
|
</MenuItem>
|
||||||
{(categoriesQuery.data ?? []).map((c) => (
|
{(categoriesQuery.data ?? []).map((c) => (
|
||||||
<MenuItem key={c.id} value={c.id}>
|
<MenuItem key={c.id} value={c.id}>
|
||||||
{c.name}
|
{c.name}
|
||||||
@@ -546,7 +673,7 @@ export function AdminPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!titleValue.trim() || !categoryIdValue || createMut.isPending || updateMut.isPending}
|
disabled={!titleValue.trim() || createMut.isPending || updateMut.isPending}
|
||||||
>
|
>
|
||||||
{editing ? 'Сохранить' : 'Создать'}
|
{editing ? 'Сохранить' : 'Создать'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -658,6 +785,105 @@ export function AdminPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</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>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { fetchCategories, fetchPublicProducts } from '@/entities/product/api/pro
|
|||||||
import { ProductCard } from '@/entities/product/ui/ProductCard'
|
import { ProductCard } from '@/entities/product/ui/ProductCard'
|
||||||
import { ToggleCartIcon } from '@/features/cart/toggle-cart-icon'
|
import { ToggleCartIcon } from '@/features/cart/toggle-cart-icon'
|
||||||
import { $user } from '@/shared/model/auth'
|
import { $user } from '@/shared/model/auth'
|
||||||
|
import { CatalogSlider } from '@/widgets/catalog-slider'
|
||||||
import { ReviewsBlock } from '@/widgets/reviews-block'
|
import { ReviewsBlock } from '@/widgets/reviews-block'
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
@@ -114,6 +115,15 @@ export function HomePage() {
|
|||||||
[categorySlug, categoriesQuery.data],
|
[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 products = productsQuery.data?.items ?? []
|
||||||
const total = productsQuery.data?.total ?? 0
|
const total = productsQuery.data?.total ?? 0
|
||||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||||
@@ -121,6 +131,8 @@ export function HomePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
<CatalogSlider />
|
||||||
|
|
||||||
<Typography variant="h4" component="h1" gutterBottom>
|
<Typography variant="h4" component="h1" gutterBottom>
|
||||||
{title}
|
{title}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -146,7 +158,7 @@ export function HomePage() {
|
|||||||
<MenuItem value="">
|
<MenuItem value="">
|
||||||
<em>Все</em>
|
<em>Все</em>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
{(categoriesQuery.data ?? []).map((c) => (
|
{categoriesForFilter.map((c) => (
|
||||||
<MenuItem key={c.id} value={c.slug}>
|
<MenuItem key={c.id} value={c.slug}>
|
||||||
{c.name}
|
{c.name}
|
||||||
</MenuItem>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+39
@@ -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");
|
||||||
@@ -32,7 +32,7 @@ model Product {
|
|||||||
published Boolean @default(false)
|
published Boolean @default(false)
|
||||||
inStock Boolean @default(true)
|
inStock Boolean @default(true)
|
||||||
leadTimeDays Int?
|
leadTimeDays Int?
|
||||||
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict)
|
||||||
categoryId String
|
categoryId String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -60,6 +60,19 @@ model GalleryImage {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
url String @unique
|
url String @unique
|
||||||
createdAt DateTime @default(now())
|
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 {
|
model User {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import multipart from '@fastify/multipart'
|
|||||||
import fastifyStatic from '@fastify/static'
|
import fastifyStatic from '@fastify/static'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { ensureAdminUser } from './lib/bootstrap-admin.js'
|
import { ensureAdminUser } from './lib/bootstrap-admin.js'
|
||||||
|
import { getOrCreateUnspecifiedCategory } from './lib/default-category.js'
|
||||||
import { getMaxUploadBodyBytes, getProductImageMaxFileBytes } from './lib/upload-limits.js'
|
import { getMaxUploadBodyBytes, getProductImageMaxFileBytes } from './lib/upload-limits.js'
|
||||||
import { registerAuth } from './plugins/auth.js'
|
import { registerAuth } from './plugins/auth.js'
|
||||||
import { registerApiRoutes } from './routes/api.js'
|
import { registerApiRoutes } from './routes/api.js'
|
||||||
@@ -58,6 +59,7 @@ await registerAuthRoutes(fastify)
|
|||||||
await registerOAuthSocialRoutes(fastify)
|
await registerOAuthSocialRoutes(fastify)
|
||||||
await registerApiRoutes(fastify)
|
await registerApiRoutes(fastify)
|
||||||
await ensureAdminUser()
|
await ensureAdminUser()
|
||||||
|
await getOrCreateUnspecifiedCategory()
|
||||||
|
|
||||||
fastify.get('/health', async () => ({ ok: true }))
|
fastify.get('/health', async () => ({ ok: true }))
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from './api/_product-helpers.js'
|
} from './api/_product-helpers.js'
|
||||||
import { registerAdminGalleryRoutes } from './api/admin-gallery.js'
|
import { registerAdminGalleryRoutes } from './api/admin-gallery.js'
|
||||||
import { registerAdminCategoryRoutes } from './api/admin-categories.js'
|
import { registerAdminCategoryRoutes } from './api/admin-categories.js'
|
||||||
|
import { registerCatalogSliderRoutes } from './api/catalog-slider.js'
|
||||||
import { registerAdminOrderRoutes } from './api/admin-orders.js'
|
import { registerAdminOrderRoutes } from './api/admin-orders.js'
|
||||||
import { registerAdminProductRoutes } from './api/admin-products.js'
|
import { registerAdminProductRoutes } from './api/admin-products.js'
|
||||||
import { registerAdminReviewRoutes } from './api/admin-reviews.js'
|
import { registerAdminReviewRoutes } from './api/admin-reviews.js'
|
||||||
@@ -17,6 +18,7 @@ export async function registerApiRoutes(fastify) {
|
|||||||
await registerPublicCatalogRoutes(fastify, { mapProductForApi })
|
await registerPublicCatalogRoutes(fastify, { mapProductForApi })
|
||||||
await registerPublicReviewRoutes(fastify)
|
await registerPublicReviewRoutes(fastify)
|
||||||
await registerInfoPageRoutes(fastify)
|
await registerInfoPageRoutes(fastify)
|
||||||
|
await registerCatalogSliderRoutes(fastify)
|
||||||
|
|
||||||
await registerAdminProductRoutes(fastify, {
|
await registerAdminProductRoutes(fastify, {
|
||||||
slugify,
|
slugify,
|
||||||
|
|||||||
@@ -1,6 +1,22 @@
|
|||||||
|
import {
|
||||||
|
getOrCreateUnspecifiedCategory,
|
||||||
|
isUnspecifiedCategorySlug,
|
||||||
|
UNSPECIFIED_CATEGORY_SLUG,
|
||||||
|
} from '../../lib/default-category.js'
|
||||||
import { prisma } from '../../lib/prisma.js'
|
import { prisma } from '../../lib/prisma.js'
|
||||||
|
|
||||||
export async function registerAdminCategoryRoutes(fastify, { slugify } = {}) {
|
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(
|
fastify.post(
|
||||||
'/api/admin/categories',
|
'/api/admin/categories',
|
||||||
{ preHandler: [fastify.verifyAdmin] },
|
{ preHandler: [fastify.verifyAdmin] },
|
||||||
@@ -12,6 +28,10 @@ export async function registerAdminCategoryRoutes(fastify, { slugify } = {}) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const slug = String(body.slug ?? '').trim() || slugify(name) || `cat-${Date.now()}`
|
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 sort = body.sort !== undefined && body.sort !== null && body.sort !== '' ? Number(body.sort) : undefined
|
||||||
const exists = await prisma.category.findUnique({ where: { slug } })
|
const exists = await prisma.category.findUnique({ where: { slug } })
|
||||||
if (exists) {
|
if (exists) {
|
||||||
@@ -28,5 +48,90 @@ export async function registerAdminCategoryRoutes(fastify, { slugify } = {}) {
|
|||||||
reply.code(201).send(category)
|
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()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getOrCreateUnspecifiedCategory } from '../../lib/default-category.js'
|
||||||
import { upsertGalleryImagesByUrls } from '../../lib/gallery.js'
|
import { upsertGalleryImagesByUrls } from '../../lib/gallery.js'
|
||||||
import { prisma } from '../../lib/prisma.js'
|
import { prisma } from '../../lib/prisma.js'
|
||||||
import {
|
import {
|
||||||
@@ -60,10 +61,15 @@ export async function registerAdminProductRoutes(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const slug = String(body.slug ?? '').trim() || slugify(title) || `item-${Date.now()}`
|
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) {
|
if (!categoryId) {
|
||||||
reply.code(400).send({ error: 'Укажите категорию' })
|
categoryId = (await getOrCreateUnspecifiedCategory()).id
|
||||||
return
|
} else {
|
||||||
|
const cat = await prisma.category.findUnique({ where: { id: categoryId } })
|
||||||
|
if (!cat) {
|
||||||
|
reply.code(400).send({ error: 'Категория не найдена' })
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const priceCents = Number(body.priceCents)
|
const priceCents = Number(body.priceCents)
|
||||||
if (!Number.isFinite(priceCents) || priceCents < 0) {
|
if (!Number.isFinite(priceCents) || priceCents < 0) {
|
||||||
@@ -190,7 +196,20 @@ export async function registerAdminProductRoutes(
|
|||||||
data.imageUrl = body.imageUrl ? String(body.imageUrl) : null
|
data.imageUrl = body.imageUrl ? String(body.imageUrl) : null
|
||||||
}
|
}
|
||||||
if (body.published !== undefined) data.published = Boolean(body.published)
|
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.inStock !== undefined) data.inStock = Boolean(body.inStock)
|
||||||
if (body.leadTimeDays !== undefined) {
|
if (body.leadTimeDays !== undefined) {
|
||||||
|
|||||||
@@ -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 |
Reference in New Issue
Block a user