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