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
@@ -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)}