408 lines
14 KiB
TypeScript
408 lines
14 KiB
TypeScript
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>
|
||
)
|
||
}
|