ыввы
This commit is contained in:
@@ -18,7 +18,7 @@ import type { Swiper as SwiperType } from 'swiper/types'
|
||||
|
||||
type Props = { product: Product; mediaHeight?: number; actions?: ReactNode }
|
||||
|
||||
const ProductCardInner = ({ product, mediaHeight = 300, actions }: Props) => {
|
||||
const ProductCardInner = ({ product, mediaHeight = 390, actions }: Props) => {
|
||||
const navigate = useNavigate()
|
||||
const isMobile = useMediaQuery('(max-width:600px)')
|
||||
const swiperRef = useRef<SwiperType | null>(null)
|
||||
@@ -78,7 +78,7 @@ const ProductCardInner = ({ product, mediaHeight = 300, actions }: Props) => {
|
||||
>
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
{imageUrls.length ? (
|
||||
<Box onMouseMove={!isMobile ? onMouseMove : undefined} sx={{ height: mediaHeight, overflow: 'hidden' }}>
|
||||
<Box onMouseMove={!isMobile ? onMouseMove : undefined} sx={{ width: '100%', aspectRatio: '3/4', maxHeight: mediaHeight, overflow: 'hidden' }}>
|
||||
<Swiper
|
||||
slidesPerView={1}
|
||||
spaceBetween={16}
|
||||
@@ -86,7 +86,7 @@ const ProductCardInner = ({ product, mediaHeight = 300, actions }: Props) => {
|
||||
onSwiper={(s) => {
|
||||
swiperRef.current = s
|
||||
}}
|
||||
style={{ width: '100%', height: mediaHeight, overflow: 'hidden' }}
|
||||
style={{ width: '100%', height: '100%', overflow: 'hidden' }}
|
||||
>
|
||||
{imageUrls.map((url) => (
|
||||
<SwiperSlide key={url}>
|
||||
@@ -94,7 +94,7 @@ const ProductCardInner = ({ product, mediaHeight = 300, actions }: Props) => {
|
||||
className="product-card__media"
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: mediaHeight,
|
||||
height: '100%',
|
||||
transition: 'transform 320ms ease',
|
||||
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
|
||||
userSelect: 'none',
|
||||
@@ -104,7 +104,7 @@ const ProductCardInner = ({ product, mediaHeight = 300, actions }: Props) => {
|
||||
<OptimizedImage
|
||||
src={url}
|
||||
alt={product.title}
|
||||
sizes={`(max-width: 600px) ${mediaHeight}px, (max-width: 1024px) ${Math.round(mediaHeight * 1.5)}px, ${mediaHeight}px`}
|
||||
sizes="(max-width: 600px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
sx={{
|
||||
width: '101%',
|
||||
height: '100%',
|
||||
@@ -120,7 +120,9 @@ const ProductCardInner = ({ product, mediaHeight = 300, actions }: Props) => {
|
||||
<CardMedia
|
||||
component="div"
|
||||
sx={{
|
||||
height: mediaHeight,
|
||||
width: '100%',
|
||||
aspectRatio: '3/4',
|
||||
maxHeight: mediaHeight,
|
||||
bgcolor: 'grey.50',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -13,7 +13,6 @@ export function useProductFilters() {
|
||||
const [pageSize, setPageSize] = useState(12)
|
||||
const [priceMinRub, setPriceMinRub] = useState('')
|
||||
const [priceMaxRub, setPriceMaxRub] = useState('')
|
||||
const [cardScale, setCardScale] = useState<70 | 90 | 110 | 130>(90)
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => {
|
||||
@@ -54,10 +53,6 @@ export function useProductFilters() {
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleCardScaleChange = (v: number) => {
|
||||
if (v === 70 || v === 90 || v === 110 || v === 130) setCardScale(v as 70 | 90 | 110 | 130)
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
setCategorySlug('')
|
||||
setQInput('')
|
||||
@@ -65,7 +60,6 @@ export function useProductFilters() {
|
||||
setPriceMinRub('')
|
||||
setPriceMaxRub('')
|
||||
setPageSize(12)
|
||||
setCardScale(90)
|
||||
setMoreOpen(false)
|
||||
}
|
||||
|
||||
@@ -86,7 +80,6 @@ export function useProductFilters() {
|
||||
pageSize,
|
||||
priceMinRub,
|
||||
priceMaxRub,
|
||||
cardScale,
|
||||
setPage,
|
||||
setQInput,
|
||||
setMoreOpen,
|
||||
@@ -95,7 +88,6 @@ export function useProductFilters() {
|
||||
handlePageSizeChange,
|
||||
handlePriceMinChange,
|
||||
handlePriceMaxChange,
|
||||
handleCardScaleChange,
|
||||
resetFilters,
|
||||
toCents,
|
||||
}
|
||||
|
||||
@@ -64,7 +64,6 @@ export function HomePage() {
|
||||
const products = productsQuery.data?.items ?? []
|
||||
const total = productsQuery.data?.total ?? 0
|
||||
const totalPages = Math.max(1, Math.ceil(total / filters.pageSize))
|
||||
const mediaHeight = Math.round(300 * (filters.cardScale / 100))
|
||||
|
||||
return (
|
||||
<Box>
|
||||
@@ -100,8 +99,8 @@ export function HomePage() {
|
||||
{productsQuery.isLoading && (
|
||||
<Grid container spacing={2} sx={{ mt: 2 }}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={i}>
|
||||
<Skeleton variant="rectangular" height={360} />
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }} key={i}>
|
||||
<Skeleton variant="rectangular" sx={{ width: '100%', aspectRatio: '3/4' }} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
@@ -128,10 +127,9 @@ export function HomePage() {
|
||||
<>
|
||||
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||
{products.map((p) => (
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={p.id}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 3 }} key={p.id}>
|
||||
<ProductCard
|
||||
product={p}
|
||||
mediaHeight={mediaHeight}
|
||||
actions={!isAdmin && p.quantity > 0 ? <ToggleCartIcon productId={p.id} /> : undefined}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
@@ -3,7 +3,6 @@ import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Chip from '@mui/material/Chip'
|
||||
import Collapse from '@mui/material/Collapse'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import FormControl from '@mui/material/FormControl'
|
||||
import InputAdornment from '@mui/material/InputAdornment'
|
||||
import InputLabel from '@mui/material/InputLabel'
|
||||
@@ -12,9 +11,6 @@ import Paper from '@mui/material/Paper'
|
||||
import Select from '@mui/material/Select'
|
||||
import Stack from '@mui/material/Stack'
|
||||
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 { Search, SlidersHorizontal } from 'lucide-react'
|
||||
import type { Category } from '@/entities/product/model/types'
|
||||
import type { UseProductFiltersResult } from '../lib/use-product-filters'
|
||||
@@ -32,7 +28,6 @@ export function ProductFilters({
|
||||
pageSize,
|
||||
priceMinRub,
|
||||
priceMaxRub,
|
||||
cardScale,
|
||||
categories,
|
||||
categoriesLoading,
|
||||
setQInput,
|
||||
@@ -42,7 +37,6 @@ export function ProductFilters({
|
||||
handlePageSizeChange,
|
||||
handlePriceMinChange,
|
||||
handlePriceMaxChange,
|
||||
handleCardScaleChange,
|
||||
resetFilters,
|
||||
}: Props) {
|
||||
const categoriesForFilter = useMemo(() => {
|
||||
@@ -188,39 +182,6 @@ export function ProductFilters({
|
||||
</FormControl>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
gap: 1.5,
|
||||
alignItems: { sm: 'center' },
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2">Масштаб карточек</Typography>
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
size="small"
|
||||
value={cardScale}
|
||||
onChange={(_, v) => handleCardScaleChange(v)}
|
||||
sx={{
|
||||
alignSelf: { xs: 'flex-start', sm: 'auto' },
|
||||
'& .MuiToggleButton-root': { px: 1.5, fontWeight: 600, textTransform: 'none' },
|
||||
'& .MuiToggleButton-root.Mui-selected': {
|
||||
bgcolor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
'&:hover': { bgcolor: 'primary.dark' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ToggleButton value={70}>S</ToggleButton>
|
||||
<ToggleButton value={90}>M</ToggleButton>
|
||||
<ToggleButton value={110}>L</ToggleButton>
|
||||
<ToggleButton value={130}>XL</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
|
||||
@@ -57,7 +57,7 @@ export function ProductPage() {
|
||||
if (productQuery.isLoading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Skeleton variant="rectangular" height={525} />
|
||||
<Skeleton variant="rectangular" sx={{ width: '100%', aspectRatio: '3/4' }} />
|
||||
<Skeleton variant="text" width="60%" />
|
||||
<Skeleton variant="text" width="40%" />
|
||||
<Skeleton variant="text" />
|
||||
@@ -72,121 +72,128 @@ export function ProductPage() {
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{imageUrls.length > 0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: '20px 20px 12px 12px',
|
||||
overflow: 'hidden',
|
||||
border: 'none',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.08)',
|
||||
bgcolor: 'background.paper',
|
||||
}}
|
||||
>
|
||||
<Swiper modules={[Navigation]} navigation style={{ width: '100%', height: 525 }}>
|
||||
{imageUrls.map((url, idx) => (
|
||||
<SwiperSlide key={url}>
|
||||
<Box
|
||||
onClick={() => {
|
||||
setViewerIndex(idx)
|
||||
setViewerOpen(true)
|
||||
}}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: 525,
|
||||
cursor: 'zoom-in',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<OptimizedImage
|
||||
src={url}
|
||||
alt={p.title}
|
||||
sizes="(max-width: 600px) 320px, (max-width: 1024px) 640px, 1024px"
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={{ xs: 3, md: 4 }}>
|
||||
<Box sx={{ flex: { md: '1 1 50%' }, minWidth: 0 }}>
|
||||
{imageUrls.length > 0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: '20px 20px 12px 12px',
|
||||
overflow: 'hidden',
|
||||
border: 'none',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.08)',
|
||||
bgcolor: 'background.paper',
|
||||
width: '100%',
|
||||
aspectRatio: '3/4',
|
||||
}}
|
||||
>
|
||||
<Swiper modules={[Navigation]} navigation style={{ width: '100%', height: '100%' }}>
|
||||
{imageUrls.map((url, idx) => (
|
||||
<SwiperSlide key={url}>
|
||||
<Box
|
||||
onClick={() => {
|
||||
setViewerIndex(idx)
|
||||
setViewerOpen(true)
|
||||
}}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
cursor: 'zoom-in',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
height: 525,
|
||||
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} />}
|
||||
{p.quantity > 0 && <Chip label="В наличии" color="success" />}
|
||||
{p.quantity === 0 && <Chip label="Нет в наличии" color="default" />}
|
||||
>
|
||||
<OptimizedImage
|
||||
src={url}
|
||||
alt={p.title}
|
||||
sizes="(max-width: 900px) 100vw, 50vw"
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
aspectRatio: '3/4',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'grey.100',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography color="text.secondary">Нет фото</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{(p.materials?.length ?? 0) > 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Материалы
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{(p.materials ?? []).map((m) => (
|
||||
<Chip key={m} label={m} variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ flex: { md: '1 1 50%' }, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{p.category?.name && <Chip label={p.category.name} />}
|
||||
{p.quantity > 0 && <Chip label="В наличии" color="success" />}
|
||||
{p.quantity === 0 && <Chip label="Нет в наличии" color="default" />}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Typography variant="h3" component="h1" sx={{ fontWeight: 700, letterSpacing: '-0.75px' }}>
|
||||
{p.title}
|
||||
</Typography>
|
||||
<Typography variant="h4" color="primary" sx={{ fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{formatPriceRub(p.priceCents)}
|
||||
</Typography>
|
||||
{(p.materials?.length ?? 0) > 0 && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Материалы
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{(p.materials ?? []).map((m) => (
|
||||
<Chip key={m} label={m} variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!isAdmin && p.quantity > 0 ? <ToggleCartIcon productId={p.id} size="medium" /> : null}
|
||||
<Typography variant="h3" component="h1" sx={{ fontWeight: 700, letterSpacing: '-0.75px' }}>
|
||||
{p.title}
|
||||
</Typography>
|
||||
<Typography variant="h4" color="primary" sx={{ fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{formatPriceRub(p.priceCents)}
|
||||
</Typography>
|
||||
|
||||
{p.description || p.shortDescription ? (
|
||||
<Typography sx={{ whiteSpace: 'pre-wrap' }}>{p.description}</Typography>
|
||||
) : (
|
||||
<Typography color="text.secondary">Описание появится позже.</Typography>
|
||||
)}
|
||||
{!isAdmin && p.quantity > 0 ? <ToggleCartIcon productId={p.id} size="medium" /> : null}
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
{p.description || p.shortDescription ? (
|
||||
<Typography sx={{ whiteSpace: 'pre-wrap' }}>{p.description}</Typography>
|
||||
) : (
|
||||
<Typography color="text.secondary">Описание появится позже.</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
Отзывы
|
||||
</Typography>
|
||||
{p.reviewsSummary && p.reviewsSummary.approvedReviewCount > 0 && (
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', mb: 2 }}>
|
||||
<Rating
|
||||
value={p.reviewsSummary.avgRating ?? 0}
|
||||
readOnly
|
||||
precision={0.25}
|
||||
icon={<Star fontSize="inherit" />}
|
||||
emptyIcon={<Star fontSize="inherit" />}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{reviewsCountRu(p.reviewsSummary.approvedReviewCount)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
<Divider sx={{ my: { xs: 3, md: 4 } }} />
|
||||
|
||||
<ProductReviewsList productId={id} />
|
||||
</Box>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
Отзывы
|
||||
</Typography>
|
||||
{p.reviewsSummary && p.reviewsSummary.approvedReviewCount > 0 && (
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', mb: 2 }}>
|
||||
<Rating
|
||||
value={p.reviewsSummary.avgRating ?? 0}
|
||||
readOnly
|
||||
precision={0.25}
|
||||
icon={<Star fontSize="inherit" />}
|
||||
emptyIcon={<Star fontSize="inherit" />}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{reviewsCountRu(p.reviewsSummary.approvedReviewCount)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<ProductReviewsList productId={id} />
|
||||
|
||||
<Dialog fullScreen open={viewerOpen} onClose={() => setViewerOpen(false)}>
|
||||
<Box sx={{ position: 'relative', height: '100%', bgcolor: 'black' }}>
|
||||
|
||||
Reference in New Issue
Block a user