base commit

This commit is contained in:
@kirill.komarov
2026-04-29 17:32:21 +05:00
parent 3f7fdb1e15
commit f6b6959268
16 changed files with 1251 additions and 48 deletions
+2
View File
@@ -2,6 +2,7 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { MainLayout } from '@/app/layout/MainLayout'
import { AppProviders } from '@/app/providers/AppProviders'
import { AdminPage } from '@/pages/admin'
import { AdminUsersPage } from '@/pages/admin-users'
import { AuthPage } from '@/pages/auth'
import { HomePage } from '@/pages/home'
import { MePage } from '@/pages/me/ui/MePage'
@@ -15,6 +16,7 @@ export function App() {
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/admin" element={<AdminPage />} />
<Route path="/admin/users" element={<AdminUsersPage />} />
<Route path="/auth" element={<AuthPage />} />
<Route path="/me" element={<MePage />} />
<Route path="/products/:id" element={<ProductPage />} />
+30 -3
View File
@@ -1,9 +1,32 @@
import type { Category, Product } from '@/entities/product/model/types'
import { apiClient } from '@/shared/api/client'
export async function fetchPublicProducts(categorySlug?: string): Promise<Product[]> {
const { data } = await apiClient.get<Product[]>('products', {
params: categorySlug ? { categorySlug } : undefined,
export type PublicProductsResponse = {
items: Product[]
total: number
page: number
pageSize: number
}
export async function fetchPublicProducts(params?: {
categorySlug?: string
q?: string
sort?: 'price_asc' | 'price_desc' | ''
page?: number
pageSize?: number
priceMinCents?: number
priceMaxCents?: number
}): Promise<PublicProductsResponse> {
const { data } = await apiClient.get<PublicProductsResponse>('products', {
params: {
categorySlug: params?.categorySlug || undefined,
q: params?.q || undefined,
sort: params?.sort || undefined,
page: params?.page || undefined,
pageSize: params?.pageSize || undefined,
priceMin: params?.priceMinCents ?? undefined,
priceMax: params?.priceMaxCents ?? undefined,
},
})
return data
}
@@ -32,6 +55,8 @@ export async function createProduct(
slug?: string
shortDescription?: string | null
description?: string | null
quantity?: number | null
materials?: string[]
priceCents: number
imageUrl?: string | null
imageUrls?: string[]
@@ -55,6 +80,8 @@ export async function updateProduct(
slug: string
shortDescription: string | null
description: string | null
quantity: number | null
materials: string[]
priceCents: number
imageUrl: string | null
imageUrls: string[]
@@ -11,6 +11,8 @@ export type Product = {
slug: string
shortDescription: string | null
description: string | null
quantity?: number | null
materials?: string[]
priceCents: number
imageUrl: string | null
imageUrls?: string[] // legacy-friendly (used only in admin payloads)
+20 -6
View File
@@ -14,16 +14,22 @@ 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 }
type Props = { product: Product; mediaHeight?: number }
export function ProductCard({ product }: Props) {
export function ProductCard({ product, mediaHeight = 200 }: 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 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 materials = (product.materials ?? []).slice(0, 3)
const moreMaterials = Math.max(0, (product.materials?.length ?? 0) - materials.length)
const onMouseMove = (e: React.MouseEvent<HTMLElement>) => {
if (!swiperRef.current) return
if (imageUrls.length <= 1) return
@@ -63,13 +69,13 @@ export function ProductCard({ product }: Props) {
sx={{ display: 'block' }}
>
{imageUrls.length ? (
<Box onMouseMove={onMouseMove} sx={{ height: 200 }}>
<Box onMouseMove={onMouseMove} sx={{ height: mediaHeight }}>
<Swiper
onSwiper={(s) => {
swiperRef.current = s
}}
allowTouchMove={false}
style={{ width: '100%', height: 200 }}
style={{ width: '100%', height: mediaHeight }}
>
{imageUrls.map((url) => (
<SwiperSlide key={url}>
@@ -80,7 +86,7 @@ export function ProductCard({ product }: Props) {
className="product-card__media"
sx={{
width: '100%',
height: 200,
height: mediaHeight,
objectFit: 'cover',
display: 'block',
transition: 'transform 240ms ease',
@@ -118,6 +124,14 @@ export function ProductCard({ product }: Props) {
>
{product.title}
</Typography>
{(product.materials?.length ?? 0) > 0 && (
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap">
{materials.map((m) => (
<Chip key={m} label={m} size="small" variant="outlined" />
))}
{moreMaterials > 0 && <Chip label={`+${moreMaterials}`} size="small" variant="outlined" />}
</Stack>
)}
<Typography
variant="body2"
color="text.secondary"
+47
View File
@@ -0,0 +1,47 @@
import type { AdminUser } from '@/entities/user/model/types'
import { apiClient } from '@/shared/api/client'
export type AdminUsersListResponse = {
items: AdminUser[]
total: number
page: number
pageSize: number
}
export async function fetchAdminUsers(
token: string,
params?: { q?: string; page?: number; pageSize?: number },
): Promise<AdminUsersListResponse> {
const { data } = await apiClient.get<AdminUsersListResponse>('admin/users', {
params,
headers: { Authorization: `Bearer ${token}` },
})
return data
}
export async function createAdminUser(
token: string,
body: { email: string; name?: string | null; password?: string },
): Promise<AdminUser> {
const { data } = await apiClient.post<AdminUser>('admin/users', body, {
headers: { Authorization: `Bearer ${token}` },
})
return data
}
export async function updateAdminUser(
token: string,
id: string,
body: Partial<{ email: string; name: string | null; password: string }>,
): Promise<AdminUser> {
const { data } = await apiClient.patch<AdminUser>(`admin/users/${id}`, body, {
headers: { Authorization: `Bearer ${token}` },
})
return data
}
export async function deleteAdminUser(token: string, id: string): Promise<void> {
await apiClient.delete(`admin/users/${id}`, {
headers: { Authorization: `Bearer ${token}` },
})
}
+8
View File
@@ -0,0 +1,8 @@
export type AdminUser = {
id: string
email: string
name: string | null
hasPassword: boolean
createdAt: string
updatedAt: string
}
+1
View File
@@ -0,0 +1 @@
export { AdminUsersPage } from './ui/AdminUsersPage'
@@ -0,0 +1,333 @@
import { useEffect, useState } from 'react'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions'
import DialogContent from '@mui/material/DialogContent'
import DialogTitle from '@mui/material/DialogTitle'
import Stack from '@mui/material/Stack'
import Table from '@mui/material/Table'
import TableBody from '@mui/material/TableBody'
import TableCell from '@mui/material/TableCell'
import TableHead from '@mui/material/TableHead'
import TablePagination from '@mui/material/TablePagination'
import TableRow from '@mui/material/TableRow'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Controller, useForm } from 'react-hook-form'
import { Link as RouterLink } from 'react-router-dom'
import { createAdminUser, deleteAdminUser, fetchAdminUsers, updateAdminUser } from '@/entities/user/api/user-api'
import type { AdminUser } from '@/entities/user/model/types'
import { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token'
type TokenFormState = { token: string }
type UserFormState = {
email: string
name: string
password: string
}
const emptyUserForm = (): UserFormState => ({ email: '', name: '', password: '' })
function formatDt(v: string) {
try {
const d = new Date(v)
if (Number.isNaN(d.getTime())) return '—'
return d.toLocaleString()
} catch {
return '—'
}
}
export function AdminUsersPage() {
const queryClient = useQueryClient()
const [token, setTokenState] = useState<string | null>(() => getAdminToken())
const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState<AdminUser | null>(null)
const [qInput, setQInput] = useState('')
const [q, setQ] = useState('')
const [page, setPage] = useState(0)
const [rowsPerPage, setRowsPerPage] = useState(20)
const tokenForm = useForm<TokenFormState>({
defaultValues: { token: '' },
mode: 'onChange',
})
const userForm = useForm<UserFormState>({
defaultValues: emptyUserForm(),
mode: 'onChange',
})
useEffect(() => {
tokenForm.reset({ token: '' })
}, [token, tokenForm])
useEffect(() => {
const t = window.setTimeout(() => {
setQ(qInput.trim())
setPage(0)
}, 250)
return () => window.clearTimeout(t)
}, [qInput])
const saveToken = () => {
const t = tokenForm.getValues('token').trim()
if (!t) {
clearAdminToken()
setTokenState(null)
return
}
setAdminToken(t)
setTokenState(t)
}
const usersQuery = useQuery({
queryKey: ['admin', 'users', token, { q, page, rowsPerPage }],
queryFn: () =>
fetchAdminUsers(token!, {
q: q || undefined,
page: page + 1,
pageSize: rowsPerPage,
}),
enabled: Boolean(token),
})
const createMut = useMutation({
mutationFn: async () => {
const v = userForm.getValues()
await createAdminUser(token!, {
email: v.email.trim(),
name: v.name.trim() || null,
password: v.password.trim() || undefined,
})
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
setDialogOpen(false)
},
})
const updateMut = useMutation({
mutationFn: async () => {
const v = userForm.getValues()
await updateAdminUser(token!, editing!.id, {
email: v.email.trim(),
name: v.name.trim() || null,
...(v.password.trim() ? { password: v.password.trim() } : {}),
})
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
setDialogOpen(false)
},
})
const deleteMut = useMutation({
mutationFn: async (id: string) => deleteAdminUser(token!, id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
},
})
const mutationError = createMut.error ?? updateMut.error ?? deleteMut.error
const openCreate = () => {
setEditing(null)
userForm.reset(emptyUserForm())
setDialogOpen(true)
}
const openEdit = (u: AdminUser) => {
setEditing(u)
userForm.reset({
email: u.email,
name: u.name ?? '',
password: '',
})
setDialogOpen(true)
}
const emailValue = userForm.watch('email')
const isSaveDisabled = !emailValue.trim() || createMut.isPending || updateMut.isPending
const users = usersQuery.data?.items ?? []
const total = usersQuery.data?.total ?? 0
return (
<Box>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={2}
sx={{ mb: 2, alignItems: { sm: 'center' }, justifyContent: 'space-between' }}
>
<Typography variant="h4">Админка пользователи</Typography>
<Button component={RouterLink} to="/admin" variant="outlined">
Товары
</Button>
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Введите API-токен из{' '}
<Typography component="span" sx={{ fontFamily: 'monospace' }}>
.env
</Typography>{' '}
сервера (<code>ADMIN_API_TOKEN</code>). Он сохраняется только в памяти браузера (sessionStorage).
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 3 }}>
<Controller
control={tokenForm.control}
name="token"
render={({ field }) => (
<TextField
label="Токен (Bearer)"
type="password"
fullWidth
{...field}
placeholder={token ? '••••••••' : ''}
/>
)}
/>
<Button variant="contained" onClick={saveToken} sx={{ minWidth: 140 }}>
Сохранить
</Button>
</Stack>
{!token && <Alert severity="info">После сохранения токена здесь появится список пользователей.</Alert>}
{token && (
<>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}>
<Button variant="contained" onClick={openCreate}>
Новый пользователь
</Button>
<TextField
size="small"
label="Поиск (email/имя)"
value={qInput}
onChange={(e) => setQInput(e.target.value)}
fullWidth
/>
</Stack>
{usersQuery.isError && (
<Alert severity="error" sx={{ mb: 2 }}>
Ошибка загрузки. Проверьте токен и что сервер запущен.
</Alert>
)}
{mutationError && (
<Alert severity="error" sx={{ mb: 2 }}>
{(mutationError as Error).message}
</Alert>
)}
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Почта</TableCell>
<TableCell>Имя</TableCell>
<TableCell>Пароль</TableCell>
<TableCell>Создан</TableCell>
<TableCell>Обновлён</TableCell>
<TableCell align="right">Действия</TableCell>
</TableRow>
</TableHead>
<TableBody>
{users.map((u) => (
<TableRow key={u.id} hover>
<TableCell>{u.email}</TableCell>
<TableCell>{u.name ?? '—'}</TableCell>
<TableCell>{u.hasPassword ? 'задан' : 'нет'}</TableCell>
<TableCell>{formatDt(u.createdAt)}</TableCell>
<TableCell>{formatDt(u.updatedAt)}</TableCell>
<TableCell align="right">
<Button size="small" onClick={() => openEdit(u)}>
Изменить
</Button>
<Button
size="small"
color="error"
disabled={deleteMut.isPending}
onClick={() => {
if (!confirm(`Удалить пользователя ${u.email}?`)) return
deleteMut.mutate(u.id)
}}
>
Удалить
</Button>
</TableCell>
</TableRow>
))}
{users.length === 0 && !usersQuery.isLoading && (
<TableRow>
<TableCell colSpan={6} sx={{ color: 'text.secondary' }}>
Пользователей пока нет.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<TablePagination
component="div"
count={total}
page={page}
onPageChange={(_, p) => setPage(p)}
rowsPerPage={rowsPerPage}
onRowsPerPageChange={(e) => {
setRowsPerPage(Number(e.target.value))
setPage(0)
}}
rowsPerPageOptions={[10, 20, 50, 100]}
/>
</>
)}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} fullWidth maxWidth="xs">
<DialogTitle>{editing ? 'Редактировать пользователя' : 'Новый пользователь'}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<Controller
control={userForm.control}
name="email"
render={({ field }) => <TextField label="Почта" fullWidth required {...field} />}
/>
<Controller
control={userForm.control}
name="name"
render={({ field }) => <TextField label="Имя/ник" fullWidth {...field} />}
/>
<Controller
control={userForm.control}
name="password"
render={({ field }) => (
<TextField
label={editing ? 'Новый пароль (необязательно)' : 'Пароль (необязательно)'}
type="password"
fullWidth
helperText="Минимум 8 символов. Для редактирования можно оставить пустым."
{...field}
/>
)}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>Отмена</Button>
<Button
variant="contained"
onClick={() => (editing ? updateMut.mutate() : createMut.mutate())}
disabled={isSaveDisabled}
>
{editing ? 'Сохранить' : 'Создать'}
</Button>
</DialogActions>
</Dialog>
</Box>
)
}
+51
View File
@@ -22,6 +22,7 @@ import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Controller, useForm } from 'react-hook-form'
import { Link as RouterLink } from 'react-router-dom'
import {
createCategory,
createProduct,
@@ -40,6 +41,8 @@ type FormState = {
slug: string
shortDescription: string
description: string
quantity: string
materials: string
priceRub: string
imageUrls: string[]
published: boolean
@@ -53,6 +56,8 @@ const emptyForm = (): FormState => ({
slug: '',
shortDescription: '',
description: '',
quantity: '',
materials: '',
priceRub: '',
imageUrls: [],
published: true,
@@ -131,6 +136,8 @@ export function AdminPage() {
slug: p.slug,
shortDescription: p.shortDescription ?? '',
description: p.description ?? '',
quantity: p.quantity === null || p.quantity === undefined ? '' : String(p.quantity),
materials: (p.materials ?? []).join(', '),
priceRub: String(p.priceCents / 100),
imageUrls: urls,
published: p.published,
@@ -152,11 +159,19 @@ export function AdminPage() {
throw new Error('Если "под заказ", укажите срок исполнения (дней) > 0')
}
}
const qty = form.quantity.trim() ? Number(form.quantity) : null
if (qty !== null && (!Number.isFinite(qty) || qty < 0)) throw new Error('Некорректное количество')
const materials = form.materials
.split(',')
.map((x) => x.trim())
.filter(Boolean)
await createProduct(token!, {
title: form.title.trim(),
slug: form.slug.trim() || undefined,
shortDescription: form.shortDescription.trim() || null,
description: form.description.trim() || null,
quantity: qty === null ? null : Math.floor(qty),
materials,
priceCents,
imageUrls: form.imageUrls,
published: form.published,
@@ -183,11 +198,19 @@ export function AdminPage() {
throw new Error('Если "под заказ", укажите срок исполнения (дней) > 0')
}
}
const qty = form.quantity.trim() ? Number(form.quantity) : null
if (qty !== null && (!Number.isFinite(qty) || qty < 0)) throw new Error('Некорректное количество')
const materials = form.materials
.split(',')
.map((x) => x.trim())
.filter(Boolean)
await updateProduct(token!, editing!.id, {
title: form.title.trim(),
slug: form.slug.trim(),
shortDescription: form.shortDescription.trim() || null,
description: form.description.trim() || null,
quantity: qty === null ? null : Math.floor(qty),
materials,
priceCents,
imageUrls: form.imageUrls,
published: form.published,
@@ -305,6 +328,9 @@ export function AdminPage() {
<Button variant="outlined" onClick={() => setCatOpen(true)}>
Новая категория
</Button>
<Button component={RouterLink} to="/admin/users" variant="outlined">
Пользователи
</Button>
</Stack>
{productsQuery.isError && (
@@ -384,6 +410,31 @@ export function AdminPage() {
name="description"
render={({ field }) => <TextField label="Описание" fullWidth multiline minRows={2} {...field} />}
/>
<Controller
control={productForm.control}
name="materials"
render={({ field }) => (
<TextField
label="Материалы"
fullWidth
{...field}
helperText="Список через запятую (например: хлопок, дерево, акрил)"
/>
)}
/>
<Controller
control={productForm.control}
name="quantity"
render={({ field }) => (
<TextField
label="Количество"
fullWidth
{...field}
inputMode="numeric"
helperText="Оставьте пустым, если не хотите вести учёт"
/>
)}
/>
<Controller
control={productForm.control}
name="priceRub"
+237 -30
View File
@@ -1,13 +1,20 @@
import { useMemo, useState } from 'react'
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 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 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 { fetchCategories, fetchPublicProducts } from '@/entities/product/api/product-api'
@@ -15,19 +22,79 @@ import { ProductCard } from '@/entities/product/ui/ProductCard'
export function HomePage() {
const [categorySlug, setCategorySlug] = useState<string>('')
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 || 'all'],
queryFn: () => fetchPublicProducts(categorySlug || undefined),
queryKey: [
'products',
'public',
{
categorySlug: categorySlug || 'all',
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,
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(
@@ -36,6 +103,11 @@ export function HomePage() {
[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>
@@ -45,25 +117,146 @@ export function HomePage() {
Игрушки, сувениры и другие изделия ручной работы.
</Typography>
<FormControl sx={{ minWidth: 220, mb: 3 }} size="small">
<InputLabel id="category-filter-label">Категория</InputLabel>
<Select<string>
labelId="category-filter-label"
label="Категория"
value={categorySlug}
onChange={handleCategoryChange}
disabled={categoriesQuery.isLoading}
<Stack spacing={2} sx={{ mb: 3 }}>
<Stack
direction={{ xs: 'column', md: 'row' }}
spacing={2}
alignItems={{ md: 'center' }}
sx={{ flexWrap: { md: 'wrap' } }}
>
<MenuItem value="">
<em>Все</em>
</MenuItem>
{(categoriesQuery.data ?? []).map((c) => (
<MenuItem key={c.id} value={c.slug}>
{c.name}
</MenuItem>
))}
</Select>
</FormControl>
<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>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={1.5}
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('')
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}
alignItems={{ md: 'center' }}
sx={{ mt: 2, 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>
<Box sx={{ flexGrow: 1, minWidth: { xs: '100%', md: 260 } }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
Масштаб карточек
</Typography>
<ToggleButtonGroup
exclusive
size="small"
value={cardScale}
onChange={(_, v) => {
if (v === 70 || v === 90 || v === 110 || v === 130) setCardScale(v)
}}
>
<ToggleButton value={70}>S</ToggleButton>
<ToggleButton value={90}>M</ToggleButton>
<ToggleButton value={110}>L</ToggleButton>
<ToggleButton value={130}>XL</ToggleButton>
</ToggleButtonGroup>
</Box>
</Stack>
</Collapse>
</Stack>
{productsQuery.isLoading && (
<Grid container spacing={2}>
@@ -79,18 +272,32 @@ export function HomePage() {
<Alert severity="error">Не удалось загрузить товары. Проверьте, что API запущен.</Alert>
)}
{productsQuery.isSuccess && productsQuery.data.length === 0 && (
{productsQuery.isSuccess && products.length === 0 && (
<Typography color="text.secondary">Пока нет опубликованных товаров.</Typography>
)}
{productsQuery.isSuccess && productsQuery.data.length > 0 && (
<Grid container spacing={2}>
{productsQuery.data.map((p) => (
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={p.id}>
<ProductCard product={p} />
</Grid>
))}
</Grid>
{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} />
</Grid>
))}
</Grid>
<Stack direction="row" justifyContent="center" sx={{ mt: 3 }}>
<Pagination
page={page}
count={totalPages}
onChange={(_, v) => setPage(v)}
color="primary"
shape="rounded"
showFirstButton
showLastButton
/>
</Stack>
</>
)}
</Box>
)
-1
View File
@@ -1,2 +1 @@
export { ProductPage } from './ui/ProductPage'
+17 -2
View File
@@ -30,7 +30,10 @@ export function ProductPage() {
const imageUrls = useMemo(() => {
const p = productQuery.data
if (!p) return []
const fromImages = (p.images ?? []).slice().sort((a, b) => a.sort - b.sort).map((x) => x.url)
const fromImages = (p.images ?? [])
.slice()
.sort((a, b) => a.sort - b.sort)
.map((x) => x.url)
const urls = fromImages.length ? fromImages : p.imageUrl ? [p.imageUrl] : []
return urls
}, [productQuery.data])
@@ -113,6 +116,19 @@ export function ProductPage() {
<Chip label={p.inStock ? 'В наличии' : `Под заказ · ${p.leadTimeDays ?? '—'} дн.`} color="default" />
</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>
)}
<Typography variant="h4" component="h1">
{p.title}
</Typography>
@@ -159,4 +175,3 @@ export function ProductPage() {
</Box>
)
}