base commit

This commit is contained in:
@kirill.komarov
2026-04-29 20:23:30 +05:00
parent f26223091a
commit 123d86091d
25 changed files with 525 additions and 159 deletions
+2 -8
View File
@@ -1,10 +1,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 { AdminOrdersPage } from '@/pages/admin-orders'
import { AdminReviewsPage } from '@/pages/admin-reviews'
import { AdminUsersPage } from '@/pages/admin-users'
import { AdminLayoutPage } from '@/pages/admin-layout'
import { AuthPage } from '@/pages/auth'
import { CartPage } from '@/pages/cart'
import { CheckoutPage } from '@/pages/checkout'
@@ -19,10 +16,7 @@ export function App() {
<MainLayout>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/admin" element={<AdminPage />} />
<Route path="/admin/orders" element={<AdminOrdersPage />} />
<Route path="/admin/reviews" element={<AdminReviewsPage />} />
<Route path="/admin/users" element={<AdminUsersPage />} />
<Route path="/admin/*" element={<AdminLayoutPage />} />
<Route path="/auth" element={<AuthPage />} />
<Route path="/cart" element={<CartPage />} />
<Route path="/checkout" element={<CheckoutPage />} />
+39 -1
View File
@@ -3,6 +3,7 @@ import AccountCircleOutlinedIcon from '@mui/icons-material/AccountCircleOutlined
import DarkModeOutlinedIcon from '@mui/icons-material/DarkModeOutlined'
import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined'
import MenuOutlinedIcon from '@mui/icons-material/MenuOutlined'
import ShoppingCartOutlinedIcon from '@mui/icons-material/ShoppingCartOutlined'
import AppBar from '@mui/material/AppBar'
import Badge from '@mui/material/Badge'
import Box from '@mui/material/Box'
@@ -19,12 +20,15 @@ import Select from '@mui/material/Select'
import type { SelectChangeEvent } from '@mui/material/Select'
import { useTheme } from '@mui/material/styles'
import Toolbar from '@mui/material/Toolbar'
import Tooltip from '@mui/material/Tooltip'
import Typography from '@mui/material/Typography'
import useMediaQuery from '@mui/material/useMediaQuery'
import { useQuery } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { Link as RouterLink, useNavigate } from 'react-router-dom'
import type { ColorScheme } from '@/app/providers/theme-controller'
import { useThemeController } from '@/app/providers/theme-controller'
import { fetchMyCart } from '@/entities/cart/api/cart-api'
import { STORE_NAME } from '@/shared/config'
import { $user, logout, tokenSet } from '@/shared/model/auth'
import { BearLogo } from '@/shared/ui/BearLogo'
@@ -33,7 +37,6 @@ type NavItem = { label: string; to: string }
const navItems: NavItem[] = [
{ label: 'Каталог', to: '/' },
{ label: 'Корзина', to: '/cart' },
{ label: 'Админка', to: '/admin' },
]
@@ -166,6 +169,14 @@ export function AppHeader() {
const user = useUnit($user)
const navigate = useNavigate()
const cartQuery = useQuery({
queryKey: ['me', 'cart'],
queryFn: fetchMyCart,
enabled: Boolean(user),
})
const cartCount = cartQuery.data?.items?.length ?? 0
const [userAnchorEl, setUserAnchorEl] = useState<null | HTMLElement>(null)
const userMenuOpen = Boolean(userAnchorEl)
@@ -238,6 +249,25 @@ export function AppHeader() {
</Button>
))}
<Tooltip title={user ? 'Корзина' : 'Авторизуйтесь для совершения покупок'}>
<span>
<IconButton
color="inherit"
sx={{ ml: 1 }}
disabled={!user}
onClick={() => {
if (!user) navigate('/auth')
else navigate('/cart')
}}
aria-label="Корзина"
>
<Badge color="secondary" badgeContent={user ? cartCount : 0} invisible={!user || cartCount === 0}>
<ShoppingCartOutlinedIcon />
</Badge>
</IconButton>
</span>
</Tooltip>
<IconButton color="inherit" onClick={openUserMenu} sx={{ ml: 1 }} aria-label="Пользователь">
<Badge
variant="dot"
@@ -300,6 +330,14 @@ export function AppHeader() {
{i.label}
</Button>
))}
<Button
variant="text"
onClick={() => go(user ? '/cart' : '/auth')}
sx={{ justifyContent: 'flex-start' }}
disabled={!user}
>
Корзина
</Button>
<Button variant="text" onClick={() => go(user ? '/me' : '/auth')} sx={{ justifyContent: 'flex-start' }}>
{user ? 'Профиль' : 'Вход / регистрация'}
</Button>
+1 -1
View File
@@ -11,7 +11,7 @@ export type Product = {
slug: string
shortDescription: string | null
description: string | null
quantity?: number | null
quantity: number
materials?: string[]
priceCents: number
imageUrl: string | null
@@ -117,6 +117,14 @@ export function ProductCard({ product, mediaHeight = 200, actions }: Props) {
<CardContent sx={{ flexGrow: 1 }}>
<Stack spacing={1}>
{product.category && <Chip label={product.category.name} size="small" />}
{!product.inStock && (
<Chip
label={`Под заказ · ${product.leadTimeDays ?? '—'} дн.`}
size="small"
variant="outlined"
color="warning"
/>
)}
<Typography
variant="h6"
component={RouterLink}
@@ -126,7 +134,7 @@ export function ProductCard({ product, mediaHeight = 200, actions }: Props) {
{product.title}
</Typography>
{(product.materials?.length ?? 0) > 0 && (
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap">
<Stack direction="row" spacing={1} useFlexGap sx={{ flexWrap: 'wrap' }}>
{materials.map((m) => (
<Chip key={m} label={m} size="small" variant="outlined" />
))}
@@ -0,0 +1 @@
export { ToggleCartIcon } from './ui/ToggleCartIcon'
@@ -0,0 +1,62 @@
import AddShoppingCartOutlinedIcon from '@mui/icons-material/AddShoppingCartOutlined'
import ShoppingCartOutlinedIcon from '@mui/icons-material/ShoppingCartOutlined'
import ShoppingCartRoundedIcon from '@mui/icons-material/ShoppingCartRounded'
import IconButton from '@mui/material/IconButton'
import Tooltip from '@mui/material/Tooltip'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
import { useNavigate } from 'react-router-dom'
import { addToCart, fetchMyCart, removeCartItem } from '@/entities/cart/api/cart-api'
import { $user } from '@/shared/model/auth'
export function ToggleCartIcon(props: { productId: string; size?: 'small' | 'medium' }) {
const { productId, size = 'small' } = props
const user = useUnit($user)
const qc = useQueryClient()
const navigate = useNavigate()
const cartQuery = useQuery({
queryKey: ['me', 'cart'],
queryFn: fetchMyCart,
enabled: Boolean(user),
})
const existing = cartQuery.data?.items.find((x) => x.product.id === productId) ?? null
const inCart = Boolean(existing)
const addMut = useMutation({
mutationFn: () => addToCart({ productId, qty: 1 }),
onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'cart'] }),
})
const removeMut = useMutation({
mutationFn: () => removeCartItem(existing!.id),
onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'cart'] }),
})
const disabled = !user
const busy = addMut.isPending || removeMut.isPending
const onClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (!user) {
navigate('/auth')
return
}
if (inCart) removeMut.mutate()
else addMut.mutate()
}
const tooltip = !user ? 'Авторизуйтесь для совершения покупок' : inCart ? 'Убрать из корзины' : 'В корзину'
return (
<Tooltip title={tooltip}>
<span>
<IconButton size={size} onClick={onClick} disabled={disabled || busy} aria-label={tooltip}>
{user ? inCart ? <ShoppingCartRoundedIcon /> : <AddShoppingCartOutlinedIcon /> : <ShoppingCartOutlinedIcon />}
</IconButton>
</span>
</Tooltip>
)
}
+1
View File
@@ -0,0 +1 @@
export { AdminLayoutPage } from './ui/AdminLayoutPage'
@@ -0,0 +1,127 @@
import type { ReactNode } from 'react'
import { useMemo, useState } from 'react'
import AssignmentOutlinedIcon from '@mui/icons-material/AssignmentOutlined'
import MenuOutlinedIcon from '@mui/icons-material/MenuOutlined'
import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined'
import RateReviewOutlinedIcon from '@mui/icons-material/RateReviewOutlined'
import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined'
import Box from '@mui/material/Box'
import Divider from '@mui/material/Divider'
import Drawer from '@mui/material/Drawer'
import IconButton from '@mui/material/IconButton'
import List from '@mui/material/List'
import ListItemButton from '@mui/material/ListItemButton'
import ListItemIcon from '@mui/material/ListItemIcon'
import ListItemText from '@mui/material/ListItemText'
import Stack from '@mui/material/Stack'
import { useTheme } from '@mui/material/styles'
import Typography from '@mui/material/Typography'
import useMediaQuery from '@mui/material/useMediaQuery'
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
import { AdminPage } from '@/pages/admin'
import { AdminOrdersPage } from '@/pages/admin-orders'
import { AdminReviewsPage } from '@/pages/admin-reviews'
import { AdminUsersPage } from '@/pages/admin-users'
type NavItem = {
to: string
label: string
icon: ReactNode
}
export function AdminLayoutPage() {
const navigate = useNavigate()
const location = useLocation()
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
const [mobileOpen, setMobileOpen] = useState(false)
const navItems: NavItem[] = useMemo(
() => [
{ to: '/admin', label: 'Товары', icon: <StorefrontOutlinedIcon /> },
{ to: '/admin/orders', label: 'Заказы', icon: <AssignmentOutlinedIcon /> },
{ to: '/admin/reviews', label: 'Отзывы', icon: <RateReviewOutlinedIcon /> },
{ to: '/admin/users', label: 'Пользователи', icon: <PeopleOutlinedIcon /> },
],
[],
)
const activeTo =
navItems.find((x) => location.pathname === x.to)?.to ??
navItems.find((x) => location.pathname.startsWith(`${x.to}/`))?.to ??
null
const nav = (
<Box sx={{ width: 280, maxWidth: '85vw' }}>
<Box sx={{ p: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
Админка
</Typography>
<Typography variant="body2" color="text.secondary">
Управление магазином
</Typography>
</Box>
<Divider />
<List disablePadding>
{navItems.map((i) => (
<ListItemButton
key={i.to}
selected={activeTo === i.to}
onClick={() => {
navigate(i.to)
setMobileOpen(false)
}}
>
<ListItemIcon>{i.icon}</ListItemIcon>
<ListItemText primary={i.label} />
</ListItemButton>
))}
</List>
</Box>
)
return (
<Stack direction={{ xs: 'column', md: 'row' }} spacing={3} sx={{ alignItems: 'flex-start' }}>
{isMobile ? (
<>
<Stack direction="row" spacing={1} sx={{ width: '100%', alignItems: 'center' }}>
<IconButton onClick={() => setMobileOpen(true)} aria-label="Открыть меню админки">
<MenuOutlinedIcon />
</IconButton>
<Typography variant="h5" sx={{ fontWeight: 700 }}>
Админка
</Typography>
</Stack>
<Drawer open={mobileOpen} onClose={() => setMobileOpen(false)} ModalProps={{ keepMounted: true }}>
{nav}
</Drawer>
</>
) : (
<Box
sx={{
position: 'sticky',
top: 88,
alignSelf: 'flex-start',
border: 1,
borderColor: 'divider',
borderRadius: 2,
bgcolor: 'background.paper',
overflow: 'hidden',
}}
>
{nav}
</Box>
)}
<Box sx={{ flexGrow: 1, minWidth: 0, width: '100%' }}>
<Routes>
<Route index element={<AdminPage />} />
<Route path="orders" element={<AdminOrdersPage />} />
<Route path="reviews" element={<AdminReviewsPage />} />
<Route path="users" element={<AdminUsersPage />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
</Routes>
</Box>
</Stack>
)
}
@@ -20,7 +20,6 @@ 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 {
fetchAdminOrder,
fetchAdminOrders,
@@ -104,17 +103,9 @@ export function AdminOrdersPage() {
return (
<Box>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}>
<Typography variant="h4" sx={{ flexGrow: 1 }}>
Админка заказы
<Typography variant="h4" sx={{ mb: 2 }}>
Заказы
</Typography>
<Button component={RouterLink} to="/admin" variant="outlined">
Товары
</Button>
<Button component={RouterLink} to="/admin/reviews" variant="outlined">
Отзывы
</Button>
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Введите API-токен из <code>ADMIN_API_TOKEN</code> (сохраняется в sessionStorage).
@@ -222,7 +213,7 @@ export function AdminOrdersPage() {
#{detail.id.slice(-8)} · {detail.user.email} · {detail.status} · {formatPriceRub(detail.totalCents)}
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems={{ sm: 'center' }}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'center' } }}>
<FormControl size="small" sx={{ minWidth: 240 }}>
<InputLabel id="next-status-label">Сменить статус</InputLabel>
<Select
@@ -264,7 +255,7 @@ export function AdminOrdersPage() {
{detail.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
</Stack>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems={{ sm: 'flex-end' }}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
<TextField
label="Ответ админа"
value={msg}
@@ -13,7 +13,6 @@ 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 { fetchAdminReviews, moderateReview } from '@/entities/review/api/admin-review-api'
import { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token'
@@ -57,17 +56,9 @@ export function AdminReviewsPage() {
return (
<Box>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}>
<Typography variant="h4" sx={{ flexGrow: 1 }}>
Админка отзывы
<Typography variant="h4" sx={{ mb: 2 }}>
Отзывы
</Typography>
<Button component={RouterLink} to="/admin" variant="outlined">
Товары
</Button>
<Button component={RouterLink} to="/admin/orders" variant="outlined">
Заказы
</Button>
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Введите API-токен из <code>ADMIN_API_TOKEN</code>.
-10
View File
@@ -22,7 +22,6 @@ 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,
@@ -328,15 +327,6 @@ export function AdminPage() {
<Button variant="outlined" onClick={() => setCatOpen(true)}>
Новая категория
</Button>
<Button component={RouterLink} to="/admin/orders" variant="outlined">
Заказы
</Button>
<Button component={RouterLink} to="/admin/reviews" variant="outlined">
Отзывы
</Button>
<Button component={RouterLink} to="/admin/users" variant="outlined">
Пользователи
</Button>
</Stack>
{productsQuery.isError && (
+58 -10
View File
@@ -7,6 +7,7 @@ import Button from '@mui/material/Button'
import Divider from '@mui/material/Divider'
import IconButton from '@mui/material/IconButton'
import Stack from '@mui/material/Stack'
import Tooltip from '@mui/material/Tooltip'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useUnit } from 'effector-react'
@@ -62,57 +63,104 @@ export function CartPage() {
{items.length > 0 && (
<Stack spacing={2}>
{items.map((x) => (
{items.map((x) =>
(() => {
const available = x.product.inStock ? x.product.quantity : 1
const canInc = x.qty < available
const over = x.qty > available
return (
<Box
key={x.id}
sx={{
border: 1,
borderColor: 'divider',
borderColor: over ? 'error.light' : 'divider',
borderRadius: 2,
p: 2,
bgcolor: 'background.paper',
bgcolor: over ? 'error.50' : 'background.paper',
}}
>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems={{ sm: 'center' }}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'center' } }}>
<Box sx={{ flexGrow: 1 }}>
<Typography sx={{ fontWeight: 700 }}>{x.product.title}</Typography>
<Typography color="text.secondary" variant="body2">
{formatPriceRub(x.product.priceCents)} · {x.qty} шт.
{formatPriceRub(x.product.priceCents)} · {x.qty} шт. · Доступно: {available}
</Typography>
{!x.product.inStock && (
<Typography color="text.secondary" variant="caption">
Под заказ доставка после изготовления
</Typography>
)}
{over && (
<Typography color="error" variant="caption" sx={{ display: 'block', mt: 0.5 }}>
Недостаточно товара. Уменьшите количество до {available}.
</Typography>
)}
</Box>
<Stack direction="row" spacing={1} alignItems="center">
<Stack
direction="row"
spacing={0.5}
sx={{
bgcolor: 'background.default',
borderRadius: 999,
px: 0.75,
height: 40,
alignItems: 'center',
}}
>
<IconButton
onClick={() => qtyMut.mutate({ id: x.id, qty: Math.max(0, x.qty - 1) })}
disabled={qtyMut.isPending}
aria-label="Уменьшить количество"
size="small"
sx={{ width: 32, height: 32 }}
>
<RemoveIcon />
</IconButton>
<Typography sx={{ minWidth: 24, textAlign: 'center' }}>{x.qty}</Typography>
<Typography
sx={{
minWidth: 28,
textAlign: 'center',
lineHeight: 1,
fontWeight: 700,
fontVariantNumeric: 'tabular-nums',
}}
>
{x.qty}
</Typography>
<Tooltip title={!canInc ? `Доступно: ${available}` : 'Увеличить количество'}>
<span>
<IconButton
onClick={() => qtyMut.mutate({ id: x.id, qty: x.qty + 1 })}
disabled={qtyMut.isPending}
disabled={qtyMut.isPending || !canInc}
aria-label="Увеличить количество"
size="small"
sx={{ width: 32, height: 32 }}
>
<AddIcon />
</IconButton>
</span>
</Tooltip>
<IconButton
onClick={() => removeMut.mutate(x.id)}
disabled={removeMut.isPending}
aria-label="Удалить"
size="small"
sx={{ width: 32, height: 32 }}
>
<DeleteOutlineOutlinedIcon />
</IconButton>
</Stack>
</Stack>
</Box>
))}
)
})(),
)}
<Divider />
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems={{ sm: 'center' }}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'center' } }}>
<Typography variant="h6" sx={{ flexGrow: 1 }}>
Итого: {formatPriceRub(total)}
</Typography>
+43 -5
View File
@@ -37,8 +37,11 @@ export function CheckoutPage() {
enabled: Boolean(user),
})
const defaultAddressId = addressesQuery.data?.items?.find((a) => a.isDefault)?.id ?? ''
const selectedAddressId = addressId || defaultAddressId
const createMut = useMutation({
mutationFn: () => createOrder({ addressId, comment: comment.trim() || null }),
mutationFn: () => createOrder({ addressId: selectedAddressId, comment: comment.trim() || null }),
onSuccess: async (res) => {
await qc.invalidateQueries({ queryKey: ['me', 'cart'] })
navigate(`/me/orders/${res.orderId}`, { replace: true })
@@ -60,8 +63,8 @@ export function CheckoutPage() {
const items = cartQuery.data?.items ?? []
const total = items.reduce((s, x) => s + x.product.priceCents * x.qty, 0)
const addresses = addressesQuery.data?.items ?? []
const defaultAddr = addresses.find((a) => a.isDefault)
const hasOverLimit = items.some((x) => x.qty > (x.product.inStock ? x.product.quantity : 1))
const hasMadeToOrder = items.some((x) => !x.product.inStock)
return (
<Box>
@@ -80,12 +83,45 @@ export function CheckoutPage() {
)}
<Stack spacing={2} sx={{ maxWidth: 720 }}>
{items.length > 0 && (
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
<Typography sx={{ fontWeight: 700, mb: 1 }}>Позиции</Typography>
<Stack spacing={0.5}>
{items.map((x) => {
const available = x.product.inStock ? x.product.quantity : 1
const over = x.qty > available
return (
<Typography key={x.id} color={over ? 'error' : 'text.primary'}>
{x.product.title}: {x.qty} шт. (доступно {available})
</Typography>
)
})}
</Stack>
</Box>
)}
{hasOverLimit && (
<Alert severity="warning">
Некоторые позиции превышают доступное количество. Вернитесь в{' '}
<Typography component={RouterLink} to="/cart" sx={{ textDecoration: 'underline' }}>
корзину
</Typography>{' '}
и скорректируйте количество.
</Alert>
)}
{hasMadeToOrder && (
<Alert severity="info">
В заказе есть товары «под заказ». Доставка будет после изготовления (срок указан в карточке товара).
</Alert>
)}
<FormControl size="small" fullWidth>
<InputLabel id="addr-label">Адрес доставки</InputLabel>
<Select
labelId="addr-label"
label="Адрес доставки"
value={addressId || (defaultAddr?.id ?? '')}
value={selectedAddressId}
onChange={(e) => setAddressId(String(e.target.value))}
>
{addresses.map((a) => (
@@ -119,7 +155,9 @@ export function CheckoutPage() {
<Button
variant="contained"
disabled={items.length === 0 || addresses.length === 0 || createMut.isPending}
disabled={
items.length === 0 || addresses.length === 0 || !selectedAddressId || hasOverLimit || createMut.isPending
}
onClick={() => createMut.mutate()}
>
Создать заказ
+6 -14
View File
@@ -21,7 +21,7 @@ import Typography from '@mui/material/Typography'
import { useQuery } from '@tanstack/react-query'
import { fetchCategories, fetchPublicProducts } from '@/entities/product/api/product-api'
import { ProductCard } from '@/entities/product/ui/ProductCard'
import { AddToCartButton } from '@/features/cart/add-to-cart'
import { ToggleCartIcon } from '@/features/cart/toggle-cart-icon'
export function HomePage() {
const [categorySlug, setCategorySlug] = useState<string>('')
@@ -124,8 +124,7 @@ export function HomePage() {
<Stack
direction={{ xs: 'column', md: 'row' }}
spacing={2}
alignItems={{ md: 'center' }}
sx={{ flexWrap: { md: 'wrap' } }}
sx={{ alignItems: { md: 'center' }, flexWrap: { md: 'wrap' } }}
>
<FormControl sx={{ minWidth: 220 }} size="small">
<InputLabel id="category-filter-label">Категория</InputLabel>
@@ -159,9 +158,7 @@ export function HomePage() {
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={1.5}
alignItems={{ sm: 'center' }}
justifyContent="space-between"
flexWrap="wrap"
sx={{ alignItems: { sm: 'center' }, justifyContent: 'space-between', flexWrap: 'wrap' }}
>
<Button variant="text" onClick={() => setMoreOpen((v) => !v)} sx={{ alignSelf: { xs: 'flex-start' } }}>
{moreOpen ? 'Скрыть фильтры' : 'Фильтры и сортировка'}
@@ -188,8 +185,7 @@ export function HomePage() {
<Stack
direction={{ xs: 'column', md: 'row' }}
spacing={2}
alignItems={{ md: 'center' }}
sx={{ mt: 2, flexWrap: { md: 'wrap' } }}
sx={{ mt: 2, alignItems: { md: 'center' }, flexWrap: { md: 'wrap' } }}
>
<FormControl sx={{ minWidth: 220 }} size="small">
<InputLabel id="sort-label">Сортировка</InputLabel>
@@ -316,16 +312,12 @@ export function HomePage() {
<Grid container spacing={2}>
{products.map((p) => (
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={p.id}>
<ProductCard
product={p}
mediaHeight={mediaHeight}
actions={<AddToCartButton productId={p.id} variant="contained" size="small" />}
/>
<ProductCard product={p} mediaHeight={mediaHeight} actions={<ToggleCartIcon productId={p.id} />} />
</Grid>
))}
</Grid>
<Stack direction="row" justifyContent="center" sx={{ mt: 3 }}>
<Stack direction="row" sx={{ mt: 3, justifyContent: 'center' }}>
<Pagination
page={page}
count={totalPages}
+1 -1
View File
@@ -93,7 +93,7 @@ export function MeLayoutPage() {
<Stack direction={{ xs: 'column', md: 'row' }} spacing={3} sx={{ alignItems: 'flex-start' }}>
{isMobile ? (
<>
<Stack direction="row" spacing={1} alignItems="center" sx={{ width: '100%' }}>
<Stack direction="row" spacing={1} sx={{ width: '100%', alignItems: 'center' }}>
<IconButton onClick={() => setMobileOpen(true)} aria-label="Открыть меню профиля">
<MenuOutlinedIcon />
</IconButton>
@@ -182,7 +182,7 @@ export function AddressesPage() {
bgcolor: 'background.paper',
}}
>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} alignItems={{ sm: 'center' }}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ alignItems: { sm: 'center' } }}>
<Typography sx={{ fontWeight: 700, flexGrow: 1 }}>{a.label?.trim() ? a.label : 'Адрес'}</Typography>
{a.isDefault && <Chip label="По умолчанию" color="primary" size="small" />}
</Stack>
@@ -60,7 +60,7 @@ export function OrderDetailPage() {
return (
<Box>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems={{ sm: 'center' }} sx={{ mb: 2 }}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}>
<Box sx={{ flexGrow: 1 }}>
<Typography variant="h4">Заказ #{order.id.slice(-6)}</Typography>
<Typography color="text.secondary">Статус: {order.status}</Typography>
@@ -77,7 +77,7 @@ export function OrderDetailPage() {
</Typography>
<Stack spacing={1}>
{order.items.map((i) => (
<Stack key={i.id} direction="row" spacing={2} alignItems="center">
<Stack key={i.id} direction="row" spacing={2} sx={{ alignItems: 'center' }}>
<Box sx={{ flexGrow: 1 }}>
<Typography sx={{ fontWeight: 700 }}>{i.titleSnapshot}</Typography>
<Typography color="text.secondary" variant="body2">
@@ -155,7 +155,7 @@ export function OrderDetailPage() {
{order.messages.length === 0 && <Typography color="text.secondary">Пока сообщений нет.</Typography>}
</Stack>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems={{ sm: 'flex-end' }}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
<TextField
label="Сообщение"
value={text}
@@ -40,7 +40,7 @@ export function OrdersPage() {
bgcolor: 'background.paper',
}}
>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} alignItems={{ sm: 'center' }}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ alignItems: { sm: 'center' } }}>
<Box sx={{ flexGrow: 1 }}>
<Typography sx={{ fontWeight: 700 }}>Заказ #{o.id.slice(-6)}</Typography>
<Typography color="text.secondary" variant="body2">
+8 -2
View File
@@ -14,7 +14,7 @@ import { Swiper, SwiperSlide } from 'swiper/react'
import 'swiper/css'
import 'swiper/css/navigation'
import { fetchPublicProduct } from '@/entities/product/api/product-api'
import { AddToCartButton } from '@/features/cart/add-to-cart'
import { ToggleCartIcon } from '@/features/cart/toggle-cart-icon'
import { formatPriceRub } from '@/shared/lib/format-price'
export function ProductPage() {
@@ -137,7 +137,13 @@ export function ProductPage() {
{formatPriceRub(p.priceCents)}
</Typography>
<AddToCartButton productId={p.id} variant="contained" sx={{ alignSelf: 'flex-start' }} />
<ToggleCartIcon productId={p.id} size="medium" />
{!p.inStock && (
<Alert severity="info">
Этот товар изготавливается под заказ. Доставка будет после изготовления (~{p.leadTimeDays ?? '—'} дн.).
</Alert>
)}
{p.description ? (
<Typography sx={{ whiteSpace: 'pre-wrap' }}>{p.description}</Typography>
+5
View File
@@ -74,8 +74,13 @@ sample({
target: $user,
})
let tokenPersistInitialized = false
$token.watch((t) => {
try {
if (!tokenPersistInitialized) {
tokenPersistInitialized = true
return
}
if (!t) localStorage.removeItem(TOKEN_KEY)
else localStorage.setItem(TOKEN_KEY, t)
} catch {
@@ -0,0 +1,27 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Product" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"shortDescription" TEXT,
"description" TEXT,
"quantity" INTEGER NOT NULL DEFAULT 0,
"materials" TEXT NOT NULL DEFAULT '[]',
"priceCents" INTEGER NOT NULL,
"imageUrl" TEXT,
"published" BOOLEAN NOT NULL DEFAULT false,
"inStock" BOOLEAN NOT NULL DEFAULT true,
"leadTimeDays" INTEGER,
"categoryId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Product_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_Product" ("categoryId", "createdAt", "description", "id", "imageUrl", "inStock", "leadTimeDays", "materials", "priceCents", "published", "quantity", "shortDescription", "slug", "title", "updatedAt") SELECT "categoryId", "createdAt", "description", "id", "imageUrl", "inStock", "leadTimeDays", "materials", "priceCents", "published", coalesce("quantity", 0) AS "quantity", "shortDescription", "slug", "title", "updatedAt" FROM "Product";
DROP TABLE "Product";
ALTER TABLE "new_Product" RENAME TO "Product";
CREATE UNIQUE INDEX "Product_slug_key" ON "Product"("slug");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
+2 -2
View File
@@ -22,8 +22,8 @@ model Product {
slug String @unique
shortDescription String?
description String?
/// Количество на складе (если null — не ведём учёт)
quantity Int?
/// Количество на складе
quantity Int @default(0)
/// Материалы (список, например: ["хлопок","дерево"])
materials String @default("[]")
/// Цена в копейках (целое число, без дробной части)
+14 -5
View File
@@ -89,8 +89,14 @@ export async function registerAdminProductRoutes(
return
}
let quantity = null
if (!(body.quantity === undefined || body.quantity === null || body.quantity === '')) {
let quantity = 0
if (!inStock) {
quantity = 1
} else {
if (body.quantity === undefined || body.quantity === null || body.quantity === '') {
reply.code(400).send({ error: 'Укажите количество' })
return
}
const n = Number(body.quantity)
if (!Number.isFinite(n) || n < 0) {
reply.code(400).send({ error: 'Некорректное количество (quantity ≥ 0)' })
@@ -162,8 +168,9 @@ export async function registerAdminProductRoutes(
if (body.quantity !== undefined) {
const v = body.quantity
if (v === null || v === '') {
data.quantity = null
} else {
reply.code(400).send({ error: 'Укажите количество' })
return
}
const n = Number(v)
if (!Number.isFinite(n) || n < 0) {
reply.code(400).send({ error: 'Некорректное количество (quantity ≥ 0)' })
@@ -171,7 +178,6 @@ export async function registerAdminProductRoutes(
}
data.quantity = Math.floor(n)
}
}
if (body.materials !== undefined) {
data.materials = JSON.stringify(parseMaterialsInput(body.materials))
}
@@ -209,6 +215,9 @@ export async function registerAdminProductRoutes(
if (nextInStock && data.leadTimeDays !== undefined) {
data.leadTimeDays = null
}
if (!nextInStock) {
data.quantity = 1
}
const imagesUpdate =
body.imageUrls !== undefined
+2 -2
View File
@@ -29,7 +29,7 @@ export async function registerPublicCatalogRoutes(fastify, { mapProductForApi }
const priceMaxParsed = typeof priceMaxRaw === 'string' ? Number(priceMaxRaw) : Number(priceMaxRaw)
const priceMax = Number.isFinite(priceMaxParsed) && priceMaxParsed >= 0 ? Math.floor(priceMaxParsed) : null
const where = { published: true }
const where = { published: true, quantity: { gt: 0 } }
if (typeof categorySlug === 'string' && categorySlug.length > 0) {
where.category = { slug: categorySlug }
}
@@ -70,7 +70,7 @@ export async function registerPublicCatalogRoutes(fastify, { mapProductForApi }
fastify.get('/api/products/:id', async (request, reply) => {
const { id } = request.params
const product = await prisma.product.findFirst({
where: { id, published: true },
where: { id, published: true, quantity: { gt: 0 } },
include: { category: true, images: { orderBy: { sort: 'asc' } } },
})
if (!product) {
+43 -5
View File
@@ -393,10 +393,15 @@ export async function registerAuthRoutes(fastify) {
const product = await prisma.product.findFirst({ where: { id: productId, published: true } })
if (!product) return reply.code(404).send({ error: 'Товар не найден' })
const available = product.inStock ? product.quantity : 1
const existing = await prisma.cartItem.findUnique({ where: { userId_productId: { userId, productId } } })
const nextQty = (existing?.qty ?? 0) + Math.floor(qty)
if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` })
const item = await prisma.cartItem.upsert({
where: { userId_productId: { userId, productId } },
update: { qty: { increment: Math.floor(qty) } },
create: { userId, productId, qty: Math.floor(qty) },
update: { qty: nextQty },
create: { userId, productId, qty: nextQty },
})
return reply.code(201).send({ item })
},
@@ -412,7 +417,7 @@ export async function registerAuthRoutes(fastify) {
const qty = Number(qtyRaw)
if (!Number.isFinite(qty) || qty < 0) return reply.code(400).send({ error: 'qty должен быть ≥ 0' })
const existing = await prisma.cartItem.findFirst({ where: { id, userId } })
const existing = await prisma.cartItem.findFirst({ where: { id, userId }, include: { product: true } })
if (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' })
if (qty === 0) {
@@ -420,7 +425,11 @@ export async function registerAuthRoutes(fastify) {
return reply.code(204).send()
}
const updated = await prisma.cartItem.update({ where: { id }, data: { qty: Math.floor(qty) } })
const available = existing.product.inStock ? existing.product.quantity : 1
const nextQty = Math.floor(qty)
if (nextQty > available) return reply.code(409).send({ error: `Доступно: ${available} шт.` })
const updated = await prisma.cartItem.update({ where: { id }, data: { qty: nextQty } })
return { item: updated }
},
)
@@ -460,6 +469,13 @@ export async function registerAuthRoutes(fastify) {
})
if (cartItems.length === 0) return reply.code(400).send({ error: 'Корзина пуста' })
for (const ci of cartItems) {
const available = ci.product.inStock ? ci.product.quantity : 1
if (ci.qty > available) {
return reply.code(409).send({ error: `Недостаточно товара: "${ci.product.title}". Доступно: ${available} шт.` })
}
}
const itemsPayload = cartItems.map((ci) => ({
productId: ci.productId,
qty: ci.qty,
@@ -479,7 +495,26 @@ export async function registerAuthRoutes(fastify) {
lng: address.lng,
})
const created = await prisma.$transaction(async (tx) => {
let created
try {
created = await prisma.$transaction(async (tx) => {
for (const ci of cartItems) {
if (!ci.product.inStock) continue
const res = await tx.product.updateMany({
where: { id: ci.productId, quantity: { gte: ci.qty } },
data: { quantity: { decrement: ci.qty } },
})
if (res.count !== 1) {
throw new Error(`Недостаточно товара: "${ci.product.title}"`)
}
const p = await tx.product.findUnique({ where: { id: ci.productId }, select: { quantity: true } })
if (p && p.quantity === 0) {
await tx.product.update({ where: { id: ci.productId }, data: { published: false } })
}
}
const order = await tx.order.create({
data: {
userId,
@@ -501,6 +536,9 @@ export async function registerAuthRoutes(fastify) {
await tx.cartItem.deleteMany({ where: { userId } })
return order
})
} catch (e) {
return reply.code(409).send({ error: (e instanceof Error && e.message) || 'Недостаточно товара' })
}
return reply.code(201).send({ orderId: created.id })
},