Files
shop-server/client/src/pages/home/ui/HomePage.tsx
T
@kirill.komarov 6885e39017 base commit
2026-05-03 20:30:21 +05:00

408 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Collapse from '@mui/material/Collapse'
import Divider from '@mui/material/Divider'
import FormControl from '@mui/material/FormControl'
import Grid from '@mui/material/Grid'
import InputLabel from '@mui/material/InputLabel'
import MenuItem from '@mui/material/MenuItem'
import Pagination from '@mui/material/Pagination'
import Paper from '@mui/material/Paper'
import Select from '@mui/material/Select'
import type { SelectChangeEvent } from '@mui/material/Select'
import Skeleton from '@mui/material/Skeleton'
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 { useQuery } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { fetchCategories, fetchPublicProducts } from '@/entities/product/api/product-api'
import { ProductCard } from '@/entities/product/ui/ProductCard'
import { ToggleCartIcon } from '@/features/cart/toggle-cart-icon'
import { $user } from '@/shared/model/auth'
import { ReviewsBlock } from '@/widgets/reviews-block'
export function HomePage() {
const user = useUnit($user)
const isAdmin = Boolean(user?.isAdmin)
const [categorySlug, setCategorySlug] = useState<string>('')
const [availability, setAvailability] = useState<'all' | 'in_stock' | 'made_to_order'>('all')
const [qInput, setQInput] = useState('')
const [q, setQ] = useState('')
const [moreOpen, setMoreOpen] = useState(false)
const [sort, setSort] = useState<'price_asc' | 'price_desc' | ''>('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(12)
const [priceMinRub, setPriceMinRub] = useState('')
const [priceMaxRub, setPriceMaxRub] = useState('')
const [cardScale, setCardScale] = useState<70 | 90 | 110 | 130>(90)
const categoriesQuery = useQuery({
queryKey: ['categories'],
queryFn: () => fetchCategories(),
})
useEffect(() => {
const t = window.setTimeout(() => {
setQ(qInput.trim())
setPage(1)
}, 250)
return () => window.clearTimeout(t)
}, [qInput])
const productsQuery = useQuery({
queryKey: [
'products',
'public',
{
categorySlug: categorySlug || 'all',
availability,
q,
sort,
page,
pageSize,
priceMinRub,
priceMaxRub,
},
],
queryFn: () => {
const toCents = (v: string) => {
const n = Number(String(v).trim().replace(',', '.'))
return Number.isFinite(n) && n >= 0 ? Math.round(n * 100) : undefined
}
return fetchPublicProducts({
categorySlug: categorySlug || undefined,
availability: availability === 'all' ? undefined : availability,
q: q || undefined,
sort: sort || '',
page,
pageSize,
priceMinCents: toCents(priceMinRub),
priceMaxCents: toCents(priceMaxRub),
})
},
})
const handleCategoryChange = (e: SelectChangeEvent<string>) => {
setCategorySlug(e.target.value)
setPage(1)
}
const handleSortChange = (e: SelectChangeEvent<string>) => {
const v = e.target.value
if (v === '' || v === 'price_asc' || v === 'price_desc') {
setSort(v)
setPage(1)
}
}
const handlePageSizeChange = (e: SelectChangeEvent<string>) => {
const n = Number(e.target.value)
if (Number.isFinite(n) && n > 0) {
setPageSize(n)
setPage(1)
}
}
const title = useMemo(
() =>
categorySlug ? `Категория: ${categoriesQuery.data?.find((c) => c.slug === categorySlug)?.name ?? ''}` : 'Каталог',
[categorySlug, categoriesQuery.data],
)
const products = productsQuery.data?.items ?? []
const total = productsQuery.data?.total ?? 0
const totalPages = Math.max(1, Math.ceil(total / pageSize))
const mediaHeight = Math.round(200 * (cardScale / 100))
return (
<Box>
<Typography variant="h4" component="h1" gutterBottom>
{title}
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Игрушки, сувениры и другие изделия ручной работы.
</Typography>
<Stack spacing={2} sx={{ mb: 3 }}>
<Stack
direction={{ xs: 'column', md: 'row' }}
spacing={2}
sx={{ alignItems: { md: 'center' }, flexWrap: { md: 'wrap' } }}
>
<FormControl sx={{ minWidth: 220 }} size="small">
<InputLabel id="category-filter-label">Категория</InputLabel>
<Select<string>
labelId="category-filter-label"
label="Категория"
value={categorySlug}
onChange={handleCategoryChange}
disabled={categoriesQuery.isLoading}
>
<MenuItem value="">
<em>Все</em>
</MenuItem>
{(categoriesQuery.data ?? []).map((c) => (
<MenuItem key={c.id} value={c.slug}>
{c.name}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
size="small"
label="Поиск"
value={qInput}
onChange={(e) => setQInput(e.target.value)}
sx={{ flexGrow: 1, minWidth: { xs: '100%', md: 360 } }}
/>
</Stack>
<Paper
variant="outlined"
sx={{
p: 1.5,
borderRadius: 2,
bgcolor: 'background.paper',
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
gap: 1.5,
alignItems: { sm: 'center' },
justifyContent: 'space-between',
}}
>
<Box>
<Typography variant="subtitle2">Наличие</Typography>
<Typography variant="caption" color="text.secondary">
Быстрый фильтр по наличию
</Typography>
</Box>
<ToggleButtonGroup
exclusive
size="small"
value={availability}
onChange={(_, v) => {
if (v === 'all' || v === 'in_stock' || v === 'made_to_order') {
setAvailability(v)
setPage(1)
}
}}
sx={{
alignSelf: { xs: 'flex-start', sm: 'auto' },
'& .MuiToggleButton-root': { px: 2, fontWeight: 700, letterSpacing: 0.2, textTransform: 'none' },
'& .MuiToggleButton-root.Mui-selected': {
bgcolor: 'primary.main',
color: 'primary.contrastText',
'&:hover': { bgcolor: 'primary.dark' },
},
}}
>
<ToggleButton value="all">Все</ToggleButton>
<ToggleButton value="in_stock">В наличии</ToggleButton>
<ToggleButton value="made_to_order">Под заказ</ToggleButton>
</ToggleButtonGroup>
</Paper>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={1.5}
sx={{ alignItems: { sm: 'center' }, justifyContent: 'space-between', flexWrap: 'wrap' }}
>
<Button variant="text" onClick={() => setMoreOpen((v) => !v)} sx={{ alignSelf: { xs: 'flex-start' } }}>
{moreOpen ? 'Скрыть фильтры' : 'Фильтры и сортировка'}
</Button>
<Button
variant="outlined"
onClick={() => {
setCategorySlug('')
setAvailability('all')
setQInput('')
setSort('')
setPriceMinRub('')
setPriceMaxRub('')
setPageSize(12)
setCardScale(90)
setMoreOpen(false)
}}
sx={{ alignSelf: { xs: 'flex-start' } }}
>
Сбросить
</Button>
</Stack>
<Collapse in={moreOpen} unmountOnExit>
<Stack
direction={{ xs: 'column', md: 'row' }}
spacing={2}
sx={{ mt: 2, alignItems: { md: 'center' }, flexWrap: { md: 'wrap' } }}
>
<FormControl sx={{ minWidth: 220 }} size="small">
<InputLabel id="sort-label">Сортировка</InputLabel>
<Select<string> labelId="sort-label" label="Сортировка" value={sort} onChange={handleSortChange}>
<MenuItem value="">
<em>Сначала новые</em>
</MenuItem>
<MenuItem value="price_asc">Цена: по возрастанию</MenuItem>
<MenuItem value="price_desc">Цена: по убыванию</MenuItem>
</Select>
</FormControl>
<TextField
size="small"
label="Цена от, ₽"
value={priceMinRub}
onChange={(e) => {
setPriceMinRub(e.target.value)
setPage(1)
}}
sx={{ width: { xs: '100%', md: 180 } }}
/>
<TextField
size="small"
label="Цена до, ₽"
value={priceMaxRub}
onChange={(e) => {
setPriceMaxRub(e.target.value)
setPage(1)
}}
sx={{ width: { xs: '100%', md: 180 } }}
/>
<FormControl sx={{ minWidth: 220 }} size="small">
<InputLabel id="page-size-label">На странице</InputLabel>
<Select<string>
labelId="page-size-label"
label="На странице"
value={String(pageSize)}
onChange={handlePageSizeChange}
>
{[6, 12, 18, 24].map((n) => (
<MenuItem key={n} value={String(n)}>
{n}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
<Divider sx={{ my: 2 }} />
<Paper
variant="outlined"
sx={{
p: 1.5,
borderRadius: 2,
bgcolor: 'background.paper',
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
gap: 1.5,
alignItems: { sm: 'center' },
justifyContent: 'space-between',
}}
>
<Box>
<Typography variant="subtitle2">Масштаб карточек</Typography>
<Typography variant="caption" color="text.secondary">
Выберите размер карточек в каталоге
</Typography>
</Box>
<ToggleButtonGroup
exclusive
size="small"
value={cardScale}
onChange={(_, v) => {
if (v === 70 || v === 90 || v === 110 || v === 130) setCardScale(v)
}}
sx={{
alignSelf: { xs: 'flex-start', sm: 'auto' },
'& .MuiToggleButton-root': {
px: 2,
fontWeight: 700,
letterSpacing: 0.2,
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>
</Paper>
</Collapse>
</Stack>
{productsQuery.isLoading && (
<Grid container spacing={2}>
{[1, 2, 3].map((i) => (
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={i}>
<Skeleton variant="rectangular" height={360} />
</Grid>
))}
</Grid>
)}
{productsQuery.isError && (
<Alert severity="error">Не удалось загрузить товары. Проверьте, что API запущен.</Alert>
)}
{productsQuery.isSuccess && products.length === 0 && (
<Typography color="text.secondary">Пока нет опубликованных товаров.</Typography>
)}
{productsQuery.isSuccess && products.length > 0 && (
<>
<Grid container spacing={2}>
{products.map((p) => (
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={p.id}>
<ProductCard
product={p}
mediaHeight={mediaHeight}
actions={
!isAdmin ? (
<ToggleCartIcon
productId={p.id}
disabledReason={p.inStock && p.quantity === 0 ? 'Нет в наличии' : null}
/>
) : undefined
}
/>
</Grid>
))}
</Grid>
{totalPages > 1 && (
<Stack direction="row" sx={{ mt: 3, justifyContent: 'center' }}>
<Pagination
page={page}
count={totalPages}
onChange={(_, v) => setPage(v)}
color="primary"
shape="rounded"
showFirstButton
showLastButton
/>
</Stack>
)}
<Box sx={{ mt: 4 }}>
<ReviewsBlock />
</Box>
</>
)}
</Box>
)
}