base commit

This commit is contained in:
@kirill.komarov
2026-04-28 22:15:12 +05:00
parent d40edf97e7
commit 3f7fdb1e15
19 changed files with 929 additions and 47 deletions
+21 -1
View File
@@ -19,7 +19,8 @@
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-hook-form": "^7.74.0",
"react-router-dom": "^7.14.2"
"react-router-dom": "^7.14.2",
"swiper": "^12.1.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -6152,6 +6153,25 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/swiper": {
"version": "12.1.3",
"resolved": "https://registry.npmjs.org/swiper/-/swiper-12.1.3.tgz",
"integrity": "sha512-XcWlVmkHFICI4fuoJKgbp8PscDcS4i7pBH8nwJRBi3dpQvhCySwsWRYm4bOf/BzKVWkHOYaFw7qz9uBSrY3oug==",
"funding": [
{
"type": "patreon",
"url": "https://www.patreon.com/swiperjs"
},
{
"type": "open_collective",
"url": "http://opencollective.com/swiper"
}
],
"license": "MIT",
"engines": {
"node": ">= 4.7.0"
}
},
"node_modules/synckit": {
"version": "0.11.12",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz",
+2 -1
View File
@@ -24,7 +24,8 @@
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-hook-form": "^7.74.0",
"react-router-dom": "^7.14.2"
"react-router-dom": "^7.14.2",
"swiper": "^12.1.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
+2
View File
@@ -5,6 +5,7 @@ import { AdminPage } from '@/pages/admin'
import { AuthPage } from '@/pages/auth'
import { HomePage } from '@/pages/home'
import { MePage } from '@/pages/me/ui/MePage'
import { ProductPage } from '@/pages/product'
export function App() {
return (
@@ -16,6 +17,7 @@ export function App() {
<Route path="/admin" element={<AdminPage />} />
<Route path="/auth" element={<AuthPage />} />
<Route path="/me" element={<MePage />} />
<Route path="/products/:id" element={<ProductPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</MainLayout>
@@ -8,6 +8,11 @@ export async function fetchPublicProducts(categorySlug?: string): Promise<Produc
return data
}
export async function fetchPublicProduct(id: string): Promise<Product> {
const { data } = await apiClient.get<Product>(`products/${id}`)
return data
}
export async function fetchCategories(): Promise<Category[]> {
const { data } = await apiClient.get<Category[]>('categories')
return data
@@ -25,10 +30,14 @@ export async function createProduct(
body: {
title: string
slug?: string
shortDescription?: string | null
description?: string | null
priceCents: number
imageUrl?: string | null
imageUrls?: string[]
published: boolean
inStock?: boolean
leadTimeDays?: number | null
categoryId: string
},
): Promise<Product> {
@@ -44,10 +53,14 @@ export async function updateProduct(
body: Partial<{
title: string
slug: string
shortDescription: string | null
description: string | null
priceCents: number
imageUrl: string | null
imageUrls: string[]
published: boolean
inStock: boolean
leadTimeDays: number | null
categoryId: string
}>,
): Promise<Product> {
@@ -9,12 +9,17 @@ export type Product = {
id: string
title: string
slug: string
shortDescription: string | null
description: string | null
priceCents: number
imageUrl: string | null
imageUrls?: string[] // legacy-friendly (used only in admin payloads)
published: boolean
inStock: boolean
leadTimeDays: number | null
categoryId: string
createdAt: string
updatedAt: string
category?: Category
images?: { id: string; url: string; sort: number }[]
}
+82 -29
View File
@@ -1,15 +1,38 @@
import { useMemo, useRef } from 'react'
import Card from '@mui/material/Card'
import CardContent from '@mui/material/CardContent'
import CardMedia from '@mui/material/CardMedia'
import Chip from '@mui/material/Chip'
import Box from '@mui/material/Box'
import Link from '@mui/material/Link'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import { Swiper, SwiperSlide } from 'swiper/react'
import type { Swiper as SwiperType } from 'swiper/types'
import 'swiper/css'
import { Link as RouterLink } from 'react-router-dom'
import type { Product } from '@/entities/product/model/types'
import { formatPriceRub } from '@/shared/lib/format-price'
type Props = { product: Product }
export function ProductCard({ product }: Props) {
const swiperRef = useRef<SwiperType | null>(null)
const imageUrls = useMemo(() => {
const fromImages = (product.images ?? []).slice().sort((a, b) => a.sort - b.sort).map((x) => x.url)
const urls = fromImages.length ? fromImages : product.imageUrl ? [product.imageUrl] : []
return urls
}, [product.images, product.imageUrl])
const onMouseMove = (e: React.MouseEvent<HTMLElement>) => {
if (!swiperRef.current) return
if (imageUrls.length <= 1) return
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const rel = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width))
const idx = Math.min(imageUrls.length - 1, Math.floor(rel * imageUrls.length))
swiperRef.current.slideTo(idx, 0)
}
return (
<Card
variant="outlined"
@@ -32,37 +55,67 @@ export function ProductCard({ product }: Props) {
},
}}
>
{product.imageUrl ? (
<CardMedia
component="img"
height="200"
image={product.imageUrl}
alt={product.title}
sx={{
objectFit: 'cover',
transition: 'transform 240ms ease',
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
}}
className="product-card__media"
/>
) : (
<CardMedia
component="div"
sx={{
height: 200,
bgcolor: 'grey.100',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography color="text.secondary">Нет фото</Typography>
</CardMedia>
)}
<Link
component={RouterLink}
to={`/products/${product.id}`}
underline="none"
color="inherit"
sx={{ display: 'block' }}
>
{imageUrls.length ? (
<Box onMouseMove={onMouseMove} sx={{ height: 200 }}>
<Swiper
onSwiper={(s) => {
swiperRef.current = s
}}
allowTouchMove={false}
style={{ width: '100%', height: 200 }}
>
{imageUrls.map((url) => (
<SwiperSlide key={url}>
<Box
component="img"
src={url}
alt={product.title}
className="product-card__media"
sx={{
width: '100%',
height: 200,
objectFit: 'cover',
display: 'block',
transition: 'transform 240ms ease',
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
userSelect: 'none',
}}
/>
</SwiperSlide>
))}
</Swiper>
</Box>
) : (
<CardMedia
component="div"
sx={{
height: 200,
bgcolor: 'grey.100',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography color="text.secondary">Нет фото</Typography>
</CardMedia>
)}
</Link>
<CardContent sx={{ flexGrow: 1 }}>
<Stack spacing={1}>
{product.category && <Chip label={product.category.name} size="small" />}
<Typography variant="h6" component="h2">
<Typography
variant="h6"
component={RouterLink}
to={`/products/${product.id}`}
style={{ textDecoration: 'none', color: 'inherit' }}
>
{product.title}
</Typography>
<Typography
@@ -76,7 +129,7 @@ export function ProductCard({ product }: Props) {
WebkitBoxOrient: 'vertical',
}}
>
{product.description ?? 'Описание появится позже.'}
{product.shortDescription ?? 'Описание появится позже.'}
</Typography>
<Typography variant="h6" color="primary">
{formatPriceRub(product.priceCents)}
+167 -10
View File
@@ -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>
+2
View File
@@ -0,0 +1,2 @@
export { ProductPage } from './ui/ProductPage'
+162
View File
@@ -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>
)
}
+3
View File
@@ -7,3 +7,6 @@ interface ImportMetaEnv {
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare module 'swiper/css'
declare module 'swiper/css/navigation'
+2
View File
@@ -19,6 +19,8 @@
"noEmit": true,
"jsx": "react-jsx",
"ignoreDeprecations": "6.0",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,