base commit
This commit is contained in:
@@ -31,26 +31,33 @@ import {
|
||||
updateProduct,
|
||||
} from '@/entities/product/api/product-api'
|
||||
import type { Product } from '@/entities/product/model/types'
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token'
|
||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||
|
||||
type FormState = {
|
||||
title: string
|
||||
slug: string
|
||||
shortDescription: string
|
||||
description: string
|
||||
priceRub: string
|
||||
imageUrl: string
|
||||
imageUrls: string[]
|
||||
published: boolean
|
||||
inStock: boolean
|
||||
leadTimeDays: string
|
||||
categoryId: string
|
||||
}
|
||||
|
||||
const emptyForm = (): FormState => ({
|
||||
title: '',
|
||||
slug: '',
|
||||
shortDescription: '',
|
||||
description: '',
|
||||
priceRub: '',
|
||||
imageUrl: '',
|
||||
imageUrls: [],
|
||||
published: true,
|
||||
inStock: true,
|
||||
leadTimeDays: '',
|
||||
categoryId: '',
|
||||
})
|
||||
|
||||
@@ -78,6 +85,7 @@ export function AdminPage() {
|
||||
|
||||
const titleValue = productForm.watch('title')
|
||||
const categoryIdValue = productForm.watch('categoryId')
|
||||
const inStockValue = productForm.watch('inStock')
|
||||
|
||||
useEffect(() => {
|
||||
tokenForm.reset({ token: '' })
|
||||
@@ -113,13 +121,21 @@ export function AdminPage() {
|
||||
|
||||
const openEdit = (p: Product) => {
|
||||
setEditing(p)
|
||||
const urls =
|
||||
(p.images ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.sort - b.sort)
|
||||
.map((x) => x.url) ?? (p.imageUrl ? [p.imageUrl] : [])
|
||||
productForm.reset({
|
||||
title: p.title,
|
||||
slug: p.slug,
|
||||
shortDescription: p.shortDescription ?? '',
|
||||
description: p.description ?? '',
|
||||
priceRub: String(p.priceCents / 100),
|
||||
imageUrl: p.imageUrl ?? '',
|
||||
imageUrls: urls,
|
||||
published: p.published,
|
||||
inStock: p.inStock,
|
||||
leadTimeDays: p.leadTimeDays ? String(p.leadTimeDays) : '',
|
||||
categoryId: p.categoryId,
|
||||
})
|
||||
setDialogOpen(true)
|
||||
@@ -130,13 +146,22 @@ export function AdminPage() {
|
||||
const form = productForm.getValues()
|
||||
const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100)
|
||||
if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена')
|
||||
const leadTimeDays = form.leadTimeDays.trim() ? Number(form.leadTimeDays) : null
|
||||
if (!form.inStock) {
|
||||
if (!Number.isFinite(leadTimeDays) || !leadTimeDays || leadTimeDays <= 0) {
|
||||
throw new Error('Если "под заказ", укажите срок исполнения (дней) > 0')
|
||||
}
|
||||
}
|
||||
await createProduct(token!, {
|
||||
title: form.title.trim(),
|
||||
slug: form.slug.trim() || undefined,
|
||||
shortDescription: form.shortDescription.trim() || null,
|
||||
description: form.description.trim() || null,
|
||||
priceCents,
|
||||
imageUrl: form.imageUrl.trim() || null,
|
||||
imageUrls: form.imageUrls,
|
||||
published: form.published,
|
||||
inStock: form.inStock,
|
||||
leadTimeDays: form.inStock ? null : Math.round(leadTimeDays!),
|
||||
categoryId: form.categoryId,
|
||||
})
|
||||
},
|
||||
@@ -152,13 +177,22 @@ export function AdminPage() {
|
||||
const form = productForm.getValues()
|
||||
const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100)
|
||||
if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена')
|
||||
const leadTimeDays = form.leadTimeDays.trim() ? Number(form.leadTimeDays) : null
|
||||
if (!form.inStock) {
|
||||
if (!Number.isFinite(leadTimeDays) || !leadTimeDays || leadTimeDays <= 0) {
|
||||
throw new Error('Если "под заказ", укажите срок исполнения (дней) > 0')
|
||||
}
|
||||
}
|
||||
await updateProduct(token!, editing!.id, {
|
||||
title: form.title.trim(),
|
||||
slug: form.slug.trim(),
|
||||
shortDescription: form.shortDescription.trim() || null,
|
||||
description: form.description.trim() || null,
|
||||
priceCents,
|
||||
imageUrl: form.imageUrl.trim() || null,
|
||||
imageUrls: form.imageUrls,
|
||||
published: form.published,
|
||||
inStock: form.inStock,
|
||||
leadTimeDays: form.inStock ? null : Math.round(leadTimeDays!),
|
||||
categoryId: form.categoryId,
|
||||
})
|
||||
},
|
||||
@@ -199,6 +233,33 @@ export function AdminPage() {
|
||||
|
||||
const mutationError = createMut.error ?? updateMut.error ?? deleteMut.error ?? createCategoryMut.error
|
||||
|
||||
const uploadImagesMut = useMutation({
|
||||
mutationFn: async (files: FileList) => {
|
||||
const fd = new FormData()
|
||||
Array.from(files).forEach((f) => fd.append('files', f))
|
||||
const { data } = await apiClient.post<{ urls: string[] }>('admin/uploads', fd, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
return data.urls
|
||||
},
|
||||
onSuccess: (urls) => {
|
||||
const current = productForm.getValues('imageUrls')
|
||||
productForm.setValue('imageUrls', [...current, ...urls], { shouldDirty: true })
|
||||
},
|
||||
})
|
||||
|
||||
const removeImage = (url: string) => {
|
||||
const current = productForm.getValues('imageUrls')
|
||||
productForm.setValue(
|
||||
'imageUrls',
|
||||
current.filter((u) => u !== url),
|
||||
{ shouldDirty: true },
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
@@ -311,6 +372,13 @@ export function AdminPage() {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="shortDescription"
|
||||
render={({ field }) => (
|
||||
<TextField label="Краткое описание (для каталога)" fullWidth multiline minRows={2} {...field} />
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="description"
|
||||
@@ -321,11 +389,83 @@ export function AdminPage() {
|
||||
name="priceRub"
|
||||
render={({ field }) => <TextField label="Цена, ₽" fullWidth {...field} />}
|
||||
/>
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="imageUrl"
|
||||
render={({ field }) => <TextField label="Ссылка на изображение" fullWidth {...field} />}
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
Фото (загрузка)
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
alignItems: { sm: 'center' },
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
}}
|
||||
>
|
||||
<Button component="label" variant="outlined" disabled={uploadImagesMut.isPending || !token}>
|
||||
Выбрать файлы
|
||||
<input
|
||||
hidden
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
const files = e.target.files
|
||||
if (!files || files.length === 0) return
|
||||
uploadImagesMut.mutate(files)
|
||||
e.currentTarget.value = ''
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
{uploadImagesMut.isPending && <Typography color="text.secondary">Загрузка…</Typography>}
|
||||
{uploadImagesMut.isError && (
|
||||
<Typography color="error">Не удалось загрузить фото (проверьте токен и сервер)</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{productForm.watch('imageUrls').length > 0 && (
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{productForm.watch('imageUrls').map((url) => (
|
||||
<Box
|
||||
key={url}
|
||||
sx={{
|
||||
width: 92,
|
||||
height: 92,
|
||||
borderRadius: 1,
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
title={url}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={url}
|
||||
alt="Фото товара"
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
variant="contained"
|
||||
onClick={() => removeImage(url)}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
minWidth: 0,
|
||||
px: 0.75,
|
||||
py: 0,
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="categoryId"
|
||||
@@ -352,6 +492,23 @@ export function AdminPage() {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="inStock"
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={<Switch checked={field.value} onChange={(_, v) => field.onChange(v)} />}
|
||||
label={field.value ? 'В наличии' : 'Под заказ'}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{!inStockValue && (
|
||||
<Controller
|
||||
control={productForm.control}
|
||||
name="leadTimeDays"
|
||||
render={({ field }) => <TextField label="Срок исполнения, дней" fullWidth {...field} />}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ProductPage } from './ui/ProductPage'
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import CloseIcon from '@mui/icons-material/Close'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Chip from '@mui/material/Chip'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Skeleton from '@mui/material/Skeleton'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Swiper, SwiperSlide } from 'swiper/react'
|
||||
import { Navigation } from 'swiper/modules'
|
||||
import 'swiper/css'
|
||||
import 'swiper/css/navigation'
|
||||
import { fetchPublicProduct } from '@/entities/product/api/product-api'
|
||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||
|
||||
export function ProductPage() {
|
||||
const { id } = useParams()
|
||||
const [viewerOpen, setViewerOpen] = useState(false)
|
||||
const [viewerIndex, setViewerIndex] = useState(0)
|
||||
|
||||
const productQuery = useQuery({
|
||||
queryKey: ['products', 'public', 'byId', id],
|
||||
queryFn: () => fetchPublicProduct(id!),
|
||||
enabled: Boolean(id),
|
||||
})
|
||||
|
||||
const imageUrls = useMemo(() => {
|
||||
const p = productQuery.data
|
||||
if (!p) return []
|
||||
const fromImages = (p.images ?? []).slice().sort((a, b) => a.sort - b.sort).map((x) => x.url)
|
||||
const urls = fromImages.length ? fromImages : p.imageUrl ? [p.imageUrl] : []
|
||||
return urls
|
||||
}, [productQuery.data])
|
||||
|
||||
if (!id) return <Alert severity="error">Некорректная ссылка на товар.</Alert>
|
||||
|
||||
if (productQuery.isLoading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Skeleton variant="rectangular" height={420} />
|
||||
<Skeleton variant="text" width="60%" />
|
||||
<Skeleton variant="text" width="40%" />
|
||||
<Skeleton variant="text" />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (productQuery.isError) return <Alert severity="error">Не удалось загрузить товар.</Alert>
|
||||
|
||||
const p = productQuery.data
|
||||
if (!p) return <Alert severity="error">Товар не найден.</Alert>
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{imageUrls.length > 0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'background.paper',
|
||||
}}
|
||||
>
|
||||
<Swiper modules={[Navigation]} navigation style={{ width: '100%', height: 420 }}>
|
||||
{imageUrls.map((url, idx) => (
|
||||
<SwiperSlide key={url}>
|
||||
<Box
|
||||
component="img"
|
||||
src={url}
|
||||
alt={p.title}
|
||||
onClick={() => {
|
||||
setViewerIndex(idx)
|
||||
setViewerOpen(true)
|
||||
}}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: 420,
|
||||
objectFit: 'cover',
|
||||
display: 'block',
|
||||
cursor: 'zoom-in',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
/>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
height: 420,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'grey.100',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography color="text.secondary">Нет фото</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{p.category?.name && <Chip label={p.category.name} />}
|
||||
<Chip label={p.inStock ? 'В наличии' : `Под заказ · ${p.leadTimeDays ?? '—'} дн.`} color="default" />
|
||||
</Box>
|
||||
|
||||
<Typography variant="h4" component="h1">
|
||||
{p.title}
|
||||
</Typography>
|
||||
<Typography variant="h5" color="primary">
|
||||
{formatPriceRub(p.priceCents)}
|
||||
</Typography>
|
||||
|
||||
{p.description ? (
|
||||
<Typography sx={{ whiteSpace: 'pre-wrap' }}>{p.description}</Typography>
|
||||
) : (
|
||||
<Typography color="text.secondary">Описание появится позже.</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Dialog fullScreen open={viewerOpen} onClose={() => setViewerOpen(false)}>
|
||||
<Box sx={{ position: 'relative', height: '100%', bgcolor: 'black' }}>
|
||||
<IconButton
|
||||
onClick={() => setViewerOpen(false)}
|
||||
sx={{ position: 'absolute', top: 12, right: 12, zIndex: 2, color: 'white' }}
|
||||
aria-label="Закрыть"
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
|
||||
<Swiper
|
||||
modules={[Navigation]}
|
||||
navigation
|
||||
initialSlide={viewerIndex}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
{imageUrls.map((url) => (
|
||||
<SwiperSlide key={`fs:${url}`}>
|
||||
<Box
|
||||
component="img"
|
||||
src={url}
|
||||
alt={p.title}
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'contain', display: 'block' }}
|
||||
/>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user