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 { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { MainLayout } from '@/app/layout/MainLayout' import { MainLayout } from '@/app/layout/MainLayout'
import { AppProviders } from '@/app/providers/AppProviders' import { AppProviders } from '@/app/providers/AppProviders'
import { AdminPage } from '@/pages/admin' import { AdminLayoutPage } from '@/pages/admin-layout'
import { AdminOrdersPage } from '@/pages/admin-orders'
import { AdminReviewsPage } from '@/pages/admin-reviews'
import { AdminUsersPage } from '@/pages/admin-users'
import { AuthPage } from '@/pages/auth' import { AuthPage } from '@/pages/auth'
import { CartPage } from '@/pages/cart' import { CartPage } from '@/pages/cart'
import { CheckoutPage } from '@/pages/checkout' import { CheckoutPage } from '@/pages/checkout'
@@ -19,10 +16,7 @@ export function App() {
<MainLayout> <MainLayout>
<Routes> <Routes>
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/admin" element={<AdminPage />} /> <Route path="/admin/*" element={<AdminLayoutPage />} />
<Route path="/admin/orders" element={<AdminOrdersPage />} />
<Route path="/admin/reviews" element={<AdminReviewsPage />} />
<Route path="/admin/users" element={<AdminUsersPage />} />
<Route path="/auth" element={<AuthPage />} /> <Route path="/auth" element={<AuthPage />} />
<Route path="/cart" element={<CartPage />} /> <Route path="/cart" element={<CartPage />} />
<Route path="/checkout" element={<CheckoutPage />} /> <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 DarkModeOutlinedIcon from '@mui/icons-material/DarkModeOutlined'
import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined' import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined'
import MenuOutlinedIcon from '@mui/icons-material/MenuOutlined' import MenuOutlinedIcon from '@mui/icons-material/MenuOutlined'
import ShoppingCartOutlinedIcon from '@mui/icons-material/ShoppingCartOutlined'
import AppBar from '@mui/material/AppBar' import AppBar from '@mui/material/AppBar'
import Badge from '@mui/material/Badge' import Badge from '@mui/material/Badge'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
@@ -19,12 +20,15 @@ import Select from '@mui/material/Select'
import type { SelectChangeEvent } from '@mui/material/Select' import type { SelectChangeEvent } from '@mui/material/Select'
import { useTheme } from '@mui/material/styles' import { useTheme } from '@mui/material/styles'
import Toolbar from '@mui/material/Toolbar' import Toolbar from '@mui/material/Toolbar'
import Tooltip from '@mui/material/Tooltip'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import useMediaQuery from '@mui/material/useMediaQuery' import useMediaQuery from '@mui/material/useMediaQuery'
import { useQuery } from '@tanstack/react-query'
import { useUnit } from 'effector-react' import { useUnit } from 'effector-react'
import { Link as RouterLink, useNavigate } from 'react-router-dom' import { Link as RouterLink, useNavigate } from 'react-router-dom'
import type { ColorScheme } from '@/app/providers/theme-controller' import type { ColorScheme } from '@/app/providers/theme-controller'
import { useThemeController } 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 { STORE_NAME } from '@/shared/config'
import { $user, logout, tokenSet } from '@/shared/model/auth' import { $user, logout, tokenSet } from '@/shared/model/auth'
import { BearLogo } from '@/shared/ui/BearLogo' import { BearLogo } from '@/shared/ui/BearLogo'
@@ -33,7 +37,6 @@ type NavItem = { label: string; to: string }
const navItems: NavItem[] = [ const navItems: NavItem[] = [
{ label: 'Каталог', to: '/' }, { label: 'Каталог', to: '/' },
{ label: 'Корзина', to: '/cart' },
{ label: 'Админка', to: '/admin' }, { label: 'Админка', to: '/admin' },
] ]
@@ -166,6 +169,14 @@ export function AppHeader() {
const user = useUnit($user) const user = useUnit($user)
const navigate = useNavigate() 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 [userAnchorEl, setUserAnchorEl] = useState<null | HTMLElement>(null)
const userMenuOpen = Boolean(userAnchorEl) const userMenuOpen = Boolean(userAnchorEl)
@@ -238,6 +249,25 @@ export function AppHeader() {
</Button> </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="Пользователь"> <IconButton color="inherit" onClick={openUserMenu} sx={{ ml: 1 }} aria-label="Пользователь">
<Badge <Badge
variant="dot" variant="dot"
@@ -300,6 +330,14 @@ export function AppHeader() {
{i.label} {i.label}
</Button> </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' }}> <Button variant="text" onClick={() => go(user ? '/me' : '/auth')} sx={{ justifyContent: 'flex-start' }}>
{user ? 'Профиль' : 'Вход / регистрация'} {user ? 'Профиль' : 'Вход / регистрация'}
</Button> </Button>
+1 -1
View File
@@ -11,7 +11,7 @@ export type Product = {
slug: string slug: string
shortDescription: string | null shortDescription: string | null
description: string | null description: string | null
quantity?: number | null quantity: number
materials?: string[] materials?: string[]
priceCents: number priceCents: number
imageUrl: string | null imageUrl: string | null
@@ -117,6 +117,14 @@ export function ProductCard({ product, mediaHeight = 200, actions }: Props) {
<CardContent sx={{ flexGrow: 1 }}> <CardContent sx={{ flexGrow: 1 }}>
<Stack spacing={1}> <Stack spacing={1}>
{product.category && <Chip label={product.category.name} size="small" />} {product.category && <Chip label={product.category.name} size="small" />}
{!product.inStock && (
<Chip
label={`Под заказ · ${product.leadTimeDays ?? '—'} дн.`}
size="small"
variant="outlined"
color="warning"
/>
)}
<Typography <Typography
variant="h6" variant="h6"
component={RouterLink} component={RouterLink}
@@ -126,7 +134,7 @@ export function ProductCard({ product, mediaHeight = 200, actions }: Props) {
{product.title} {product.title}
</Typography> </Typography>
{(product.materials?.length ?? 0) > 0 && ( {(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) => ( {materials.map((m) => (
<Chip key={m} label={m} size="small" variant="outlined" /> <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 Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Controller, useForm } from 'react-hook-form' import { Controller, useForm } from 'react-hook-form'
import { Link as RouterLink } from 'react-router-dom'
import { import {
fetchAdminOrder, fetchAdminOrder,
fetchAdminOrders, fetchAdminOrders,
@@ -104,17 +103,9 @@ export function AdminOrdersPage() {
return ( return (
<Box> <Box>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}> <Typography variant="h4" sx={{ mb: 2 }}>
<Typography variant="h4" sx={{ flexGrow: 1 }}> Заказы
Админка заказы </Typography>
</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 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Введите API-токен из <code>ADMIN_API_TOKEN</code> (сохраняется в sessionStorage). Введите 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)} #{detail.id.slice(-8)} · {detail.user.email} · {detail.status} · {formatPriceRub(detail.totalCents)}
</Typography> </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 }}> <FormControl size="small" sx={{ minWidth: 240 }}>
<InputLabel id="next-status-label">Сменить статус</InputLabel> <InputLabel id="next-status-label">Сменить статус</InputLabel>
<Select <Select
@@ -264,7 +255,7 @@ export function AdminOrdersPage() {
{detail.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>} {detail.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
</Stack> </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 <TextField
label="Ответ админа" label="Ответ админа"
value={msg} value={msg}
@@ -13,7 +13,6 @@ import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Controller, useForm } from 'react-hook-form' 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 { fetchAdminReviews, moderateReview } from '@/entities/review/api/admin-review-api'
import { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token' import { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token'
@@ -57,17 +56,9 @@ export function AdminReviewsPage() {
return ( return (
<Box> <Box>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}> <Typography variant="h4" sx={{ mb: 2 }}>
<Typography variant="h4" sx={{ flexGrow: 1 }}> Отзывы
Админка отзывы </Typography>
</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 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Введите API-токен из <code>ADMIN_API_TOKEN</code>. Введите 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 Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Controller, useForm } from 'react-hook-form' import { Controller, useForm } from 'react-hook-form'
import { Link as RouterLink } from 'react-router-dom'
import { import {
createCategory, createCategory,
createProduct, createProduct,
@@ -328,15 +327,6 @@ export function AdminPage() {
<Button variant="outlined" onClick={() => setCatOpen(true)}> <Button variant="outlined" onClick={() => setCatOpen(true)}>
Новая категория Новая категория
</Button> </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> </Stack>
{productsQuery.isError && ( {productsQuery.isError && (
+95 -47
View File
@@ -7,6 +7,7 @@ import Button from '@mui/material/Button'
import Divider from '@mui/material/Divider' import Divider from '@mui/material/Divider'
import IconButton from '@mui/material/IconButton' import IconButton from '@mui/material/IconButton'
import Stack from '@mui/material/Stack' import Stack from '@mui/material/Stack'
import Tooltip from '@mui/material/Tooltip'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useUnit } from 'effector-react' import { useUnit } from 'effector-react'
@@ -62,57 +63,104 @@ export function CartPage() {
{items.length > 0 && ( {items.length > 0 && (
<Stack spacing={2}> <Stack spacing={2}>
{items.map((x) => ( {items.map((x) =>
<Box (() => {
key={x.id} const available = x.product.inStock ? x.product.quantity : 1
sx={{ const canInc = x.qty < available
border: 1, const over = x.qty > available
borderColor: 'divider', return (
borderRadius: 2, <Box
p: 2, key={x.id}
bgcolor: 'background.paper', sx={{
}} border: 1,
> borderColor: over ? 'error.light' : 'divider',
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems={{ sm: 'center' }}> borderRadius: 2,
<Box sx={{ flexGrow: 1 }}> p: 2,
<Typography sx={{ fontWeight: 700 }}>{x.product.title}</Typography> bgcolor: over ? 'error.50' : 'background.paper',
<Typography color="text.secondary" variant="body2"> }}
{formatPriceRub(x.product.priceCents)} · {x.qty} шт. >
</Typography> <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} шт. · Доступно: {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={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: 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 || !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> </Box>
)
<Stack direction="row" spacing={1} alignItems="center"> })(),
<IconButton )}
onClick={() => qtyMut.mutate({ id: x.id, qty: Math.max(0, x.qty - 1) })}
disabled={qtyMut.isPending}
aria-label="Уменьшить количество"
>
<RemoveIcon />
</IconButton>
<Typography sx={{ minWidth: 24, textAlign: 'center' }}>{x.qty}</Typography>
<IconButton
onClick={() => qtyMut.mutate({ id: x.id, qty: x.qty + 1 })}
disabled={qtyMut.isPending}
aria-label="Увеличить количество"
>
<AddIcon />
</IconButton>
<IconButton
onClick={() => removeMut.mutate(x.id)}
disabled={removeMut.isPending}
aria-label="Удалить"
>
<DeleteOutlineOutlinedIcon />
</IconButton>
</Stack>
</Stack>
</Box>
))}
<Divider /> <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 }}> <Typography variant="h6" sx={{ flexGrow: 1 }}>
Итого: {formatPriceRub(total)} Итого: {formatPriceRub(total)}
</Typography> </Typography>
+43 -5
View File
@@ -37,8 +37,11 @@ export function CheckoutPage() {
enabled: Boolean(user), enabled: Boolean(user),
}) })
const defaultAddressId = addressesQuery.data?.items?.find((a) => a.isDefault)?.id ?? ''
const selectedAddressId = addressId || defaultAddressId
const createMut = useMutation({ const createMut = useMutation({
mutationFn: () => createOrder({ addressId, comment: comment.trim() || null }), mutationFn: () => createOrder({ addressId: selectedAddressId, comment: comment.trim() || null }),
onSuccess: async (res) => { onSuccess: async (res) => {
await qc.invalidateQueries({ queryKey: ['me', 'cart'] }) await qc.invalidateQueries({ queryKey: ['me', 'cart'] })
navigate(`/me/orders/${res.orderId}`, { replace: true }) navigate(`/me/orders/${res.orderId}`, { replace: true })
@@ -60,8 +63,8 @@ export function CheckoutPage() {
const items = cartQuery.data?.items ?? [] const items = cartQuery.data?.items ?? []
const total = items.reduce((s, x) => s + x.product.priceCents * x.qty, 0) const total = items.reduce((s, x) => s + x.product.priceCents * x.qty, 0)
const addresses = addressesQuery.data?.items ?? [] const addresses = addressesQuery.data?.items ?? []
const hasOverLimit = items.some((x) => x.qty > (x.product.inStock ? x.product.quantity : 1))
const defaultAddr = addresses.find((a) => a.isDefault) const hasMadeToOrder = items.some((x) => !x.product.inStock)
return ( return (
<Box> <Box>
@@ -80,12 +83,45 @@ export function CheckoutPage() {
)} )}
<Stack spacing={2} sx={{ maxWidth: 720 }}> <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> <FormControl size="small" fullWidth>
<InputLabel id="addr-label">Адрес доставки</InputLabel> <InputLabel id="addr-label">Адрес доставки</InputLabel>
<Select <Select
labelId="addr-label" labelId="addr-label"
label="Адрес доставки" label="Адрес доставки"
value={addressId || (defaultAddr?.id ?? '')} value={selectedAddressId}
onChange={(e) => setAddressId(String(e.target.value))} onChange={(e) => setAddressId(String(e.target.value))}
> >
{addresses.map((a) => ( {addresses.map((a) => (
@@ -119,7 +155,9 @@ export function CheckoutPage() {
<Button <Button
variant="contained" 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()} onClick={() => createMut.mutate()}
> >
Создать заказ Создать заказ
+6 -14
View File
@@ -21,7 +21,7 @@ import Typography from '@mui/material/Typography'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { fetchCategories, fetchPublicProducts } from '@/entities/product/api/product-api' import { fetchCategories, fetchPublicProducts } from '@/entities/product/api/product-api'
import { ProductCard } from '@/entities/product/ui/ProductCard' 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() { export function HomePage() {
const [categorySlug, setCategorySlug] = useState<string>('') const [categorySlug, setCategorySlug] = useState<string>('')
@@ -124,8 +124,7 @@ export function HomePage() {
<Stack <Stack
direction={{ xs: 'column', md: 'row' }} direction={{ xs: 'column', md: 'row' }}
spacing={2} spacing={2}
alignItems={{ md: 'center' }} sx={{ alignItems: { md: 'center' }, flexWrap: { md: 'wrap' } }}
sx={{ flexWrap: { md: 'wrap' } }}
> >
<FormControl sx={{ minWidth: 220 }} size="small"> <FormControl sx={{ minWidth: 220 }} size="small">
<InputLabel id="category-filter-label">Категория</InputLabel> <InputLabel id="category-filter-label">Категория</InputLabel>
@@ -159,9 +158,7 @@ export function HomePage() {
<Stack <Stack
direction={{ xs: 'column', sm: 'row' }} direction={{ xs: 'column', sm: 'row' }}
spacing={1.5} spacing={1.5}
alignItems={{ sm: 'center' }} sx={{ alignItems: { sm: 'center' }, justifyContent: 'space-between', flexWrap: 'wrap' }}
justifyContent="space-between"
flexWrap="wrap"
> >
<Button variant="text" onClick={() => setMoreOpen((v) => !v)} sx={{ alignSelf: { xs: 'flex-start' } }}> <Button variant="text" onClick={() => setMoreOpen((v) => !v)} sx={{ alignSelf: { xs: 'flex-start' } }}>
{moreOpen ? 'Скрыть фильтры' : 'Фильтры и сортировка'} {moreOpen ? 'Скрыть фильтры' : 'Фильтры и сортировка'}
@@ -188,8 +185,7 @@ export function HomePage() {
<Stack <Stack
direction={{ xs: 'column', md: 'row' }} direction={{ xs: 'column', md: 'row' }}
spacing={2} spacing={2}
alignItems={{ md: 'center' }} sx={{ mt: 2, alignItems: { md: 'center' }, flexWrap: { md: 'wrap' } }}
sx={{ mt: 2, flexWrap: { md: 'wrap' } }}
> >
<FormControl sx={{ minWidth: 220 }} size="small"> <FormControl sx={{ minWidth: 220 }} size="small">
<InputLabel id="sort-label">Сортировка</InputLabel> <InputLabel id="sort-label">Сортировка</InputLabel>
@@ -316,16 +312,12 @@ export function HomePage() {
<Grid container spacing={2}> <Grid container spacing={2}>
{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 }} key={p.id}>
<ProductCard <ProductCard product={p} mediaHeight={mediaHeight} actions={<ToggleCartIcon productId={p.id} />} />
product={p}
mediaHeight={mediaHeight}
actions={<AddToCartButton productId={p.id} variant="contained" size="small" />}
/>
</Grid> </Grid>
))} ))}
</Grid> </Grid>
<Stack direction="row" justifyContent="center" sx={{ mt: 3 }}> <Stack direction="row" sx={{ mt: 3, justifyContent: 'center' }}>
<Pagination <Pagination
page={page} page={page}
count={totalPages} 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' }}> <Stack direction={{ xs: 'column', md: 'row' }} spacing={3} sx={{ alignItems: 'flex-start' }}>
{isMobile ? ( {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="Открыть меню профиля"> <IconButton onClick={() => setMobileOpen(true)} aria-label="Открыть меню профиля">
<MenuOutlinedIcon /> <MenuOutlinedIcon />
</IconButton> </IconButton>
@@ -182,7 +182,7 @@ export function AddressesPage() {
bgcolor: 'background.paper', 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> <Typography sx={{ fontWeight: 700, flexGrow: 1 }}>{a.label?.trim() ? a.label : 'Адрес'}</Typography>
{a.isDefault && <Chip label="По умолчанию" color="primary" size="small" />} {a.isDefault && <Chip label="По умолчанию" color="primary" size="small" />}
</Stack> </Stack>
@@ -60,7 +60,7 @@ export function OrderDetailPage() {
return ( return (
<Box> <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 }}> <Box sx={{ flexGrow: 1 }}>
<Typography variant="h4">Заказ #{order.id.slice(-6)}</Typography> <Typography variant="h4">Заказ #{order.id.slice(-6)}</Typography>
<Typography color="text.secondary">Статус: {order.status}</Typography> <Typography color="text.secondary">Статус: {order.status}</Typography>
@@ -77,7 +77,7 @@ export function OrderDetailPage() {
</Typography> </Typography>
<Stack spacing={1}> <Stack spacing={1}>
{order.items.map((i) => ( {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 }}> <Box sx={{ flexGrow: 1 }}>
<Typography sx={{ fontWeight: 700 }}>{i.titleSnapshot}</Typography> <Typography sx={{ fontWeight: 700 }}>{i.titleSnapshot}</Typography>
<Typography color="text.secondary" variant="body2"> <Typography color="text.secondary" variant="body2">
@@ -155,7 +155,7 @@ export function OrderDetailPage() {
{order.messages.length === 0 && <Typography color="text.secondary">Пока сообщений нет.</Typography>} {order.messages.length === 0 && <Typography color="text.secondary">Пока сообщений нет.</Typography>}
</Stack> </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 <TextField
label="Сообщение" label="Сообщение"
value={text} value={text}
@@ -40,7 +40,7 @@ export function OrdersPage() {
bgcolor: 'background.paper', 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 }}> <Box sx={{ flexGrow: 1 }}>
<Typography sx={{ fontWeight: 700 }}>Заказ #{o.id.slice(-6)}</Typography> <Typography sx={{ fontWeight: 700 }}>Заказ #{o.id.slice(-6)}</Typography>
<Typography color="text.secondary" variant="body2"> <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'
import 'swiper/css/navigation' import 'swiper/css/navigation'
import { fetchPublicProduct } from '@/entities/product/api/product-api' 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' import { formatPriceRub } from '@/shared/lib/format-price'
export function ProductPage() { export function ProductPage() {
@@ -137,7 +137,13 @@ export function ProductPage() {
{formatPriceRub(p.priceCents)} {formatPriceRub(p.priceCents)}
</Typography> </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 ? ( {p.description ? (
<Typography sx={{ whiteSpace: 'pre-wrap' }}>{p.description}</Typography> <Typography sx={{ whiteSpace: 'pre-wrap' }}>{p.description}</Typography>
+5
View File
@@ -74,8 +74,13 @@ sample({
target: $user, target: $user,
}) })
let tokenPersistInitialized = false
$token.watch((t) => { $token.watch((t) => {
try { try {
if (!tokenPersistInitialized) {
tokenPersistInitialized = true
return
}
if (!t) localStorage.removeItem(TOKEN_KEY) if (!t) localStorage.removeItem(TOKEN_KEY)
else localStorage.setItem(TOKEN_KEY, t) else localStorage.setItem(TOKEN_KEY, t)
} catch { } 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 slug String @unique
shortDescription String? shortDescription String?
description String? description String?
/// Количество на складе (если null — не ведём учёт) /// Количество на складе
quantity Int? quantity Int @default(0)
/// Материалы (список, например: ["хлопок","дерево"]) /// Материалы (список, например: ["хлопок","дерево"])
materials String @default("[]") materials String @default("[]")
/// Цена в копейках (целое число, без дробной части) /// Цена в копейках (целое число, без дробной части)
+19 -10
View File
@@ -89,8 +89,14 @@ export async function registerAdminProductRoutes(
return return
} }
let quantity = null let quantity = 0
if (!(body.quantity === undefined || body.quantity === null || body.quantity === '')) { 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) const n = Number(body.quantity)
if (!Number.isFinite(n) || n < 0) { if (!Number.isFinite(n) || n < 0) {
reply.code(400).send({ error: 'Некорректное количество (quantity ≥ 0)' }) reply.code(400).send({ error: 'Некорректное количество (quantity ≥ 0)' })
@@ -162,15 +168,15 @@ export async function registerAdminProductRoutes(
if (body.quantity !== undefined) { if (body.quantity !== undefined) {
const v = body.quantity const v = body.quantity
if (v === null || v === '') { if (v === null || v === '') {
data.quantity = null reply.code(400).send({ error: 'Укажите количество' })
} else { return
const n = Number(v)
if (!Number.isFinite(n) || n < 0) {
reply.code(400).send({ error: 'Некорректное количество (quantity ≥ 0)' })
return
}
data.quantity = Math.floor(n)
} }
const n = Number(v)
if (!Number.isFinite(n) || n < 0) {
reply.code(400).send({ error: 'Некорректное количество (quantity ≥ 0)' })
return
}
data.quantity = Math.floor(n)
} }
if (body.materials !== undefined) { if (body.materials !== undefined) {
data.materials = JSON.stringify(parseMaterialsInput(body.materials)) data.materials = JSON.stringify(parseMaterialsInput(body.materials))
@@ -209,6 +215,9 @@ export async function registerAdminProductRoutes(
if (nextInStock && data.leadTimeDays !== undefined) { if (nextInStock && data.leadTimeDays !== undefined) {
data.leadTimeDays = null data.leadTimeDays = null
} }
if (!nextInStock) {
data.quantity = 1
}
const imagesUpdate = const imagesUpdate =
body.imageUrls !== undefined 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 priceMaxParsed = typeof priceMaxRaw === 'string' ? Number(priceMaxRaw) : Number(priceMaxRaw)
const priceMax = Number.isFinite(priceMaxParsed) && priceMaxParsed >= 0 ? Math.floor(priceMaxParsed) : null 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) { if (typeof categorySlug === 'string' && categorySlug.length > 0) {
where.category = { slug: categorySlug } where.category = { slug: categorySlug }
} }
@@ -70,7 +70,7 @@ export async function registerPublicCatalogRoutes(fastify, { mapProductForApi }
fastify.get('/api/products/:id', async (request, reply) => { fastify.get('/api/products/:id', async (request, reply) => {
const { id } = request.params const { id } = request.params
const product = await prisma.product.findFirst({ const product = await prisma.product.findFirst({
where: { id, published: true }, where: { id, published: true, quantity: { gt: 0 } },
include: { category: true, images: { orderBy: { sort: 'asc' } } }, include: { category: true, images: { orderBy: { sort: 'asc' } } },
}) })
if (!product) { if (!product) {
+62 -24
View File
@@ -393,10 +393,15 @@ export async function registerAuthRoutes(fastify) {
const product = await prisma.product.findFirst({ where: { id: productId, published: true } }) const product = await prisma.product.findFirst({ where: { id: productId, published: true } })
if (!product) return reply.code(404).send({ error: 'Товар не найден' }) 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({ const item = await prisma.cartItem.upsert({
where: { userId_productId: { userId, productId } }, where: { userId_productId: { userId, productId } },
update: { qty: { increment: Math.floor(qty) } }, update: { qty: nextQty },
create: { userId, productId, qty: Math.floor(qty) }, create: { userId, productId, qty: nextQty },
}) })
return reply.code(201).send({ item }) return reply.code(201).send({ item })
}, },
@@ -412,7 +417,7 @@ export async function registerAuthRoutes(fastify) {
const qty = Number(qtyRaw) const qty = Number(qtyRaw)
if (!Number.isFinite(qty) || qty < 0) return reply.code(400).send({ error: 'qty должен быть ≥ 0' }) 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 (!existing) return reply.code(404).send({ error: 'Позиция корзины не найдена' })
if (qty === 0) { if (qty === 0) {
@@ -420,7 +425,11 @@ export async function registerAuthRoutes(fastify) {
return reply.code(204).send() 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 } return { item: updated }
}, },
) )
@@ -460,6 +469,13 @@ export async function registerAuthRoutes(fastify) {
}) })
if (cartItems.length === 0) return reply.code(400).send({ error: 'Корзина пуста' }) 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) => ({ const itemsPayload = cartItems.map((ci) => ({
productId: ci.productId, productId: ci.productId,
qty: ci.qty, qty: ci.qty,
@@ -479,28 +495,50 @@ export async function registerAuthRoutes(fastify) {
lng: address.lng, lng: address.lng,
}) })
const created = await prisma.$transaction(async (tx) => { let created
const order = await tx.order.create({ try {
data: { created = await prisma.$transaction(async (tx) => {
userId, for (const ci of cartItems) {
status: 'PENDING_PAYMENT', if (!ci.product.inStock) continue
totalCents,
currency: 'RUB', const res = await tx.product.updateMany({
addressSnapshotJson, where: { id: ci.productId, quantity: { gte: ci.qty } },
comment: comment && comment.length ? comment : null, data: { quantity: { decrement: ci.qty } },
items: { })
create: itemsPayload.map((i) => ({ if (res.count !== 1) {
productId: i.productId, throw new Error(`Недостаточно товара: "${ci.product.title}"`)
qty: i.qty, }
titleSnapshot: i.titleSnapshot,
priceCentsSnapshot: i.priceCentsSnapshot, 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,
status: 'PENDING_PAYMENT',
totalCents,
currency: 'RUB',
addressSnapshotJson,
comment: comment && comment.length ? comment : null,
items: {
create: itemsPayload.map((i) => ({
productId: i.productId,
qty: i.qty,
titleSnapshot: i.titleSnapshot,
priceCentsSnapshot: i.priceCentsSnapshot,
})),
},
}, },
}, })
await tx.cartItem.deleteMany({ where: { userId } })
return order
}) })
await tx.cartItem.deleteMany({ where: { userId } }) } catch (e) {
return order return reply.code(409).send({ error: (e instanceof Error && e.message) || 'Недостаточно товара' })
}) }
return reply.code(201).send({ orderId: created.id }) return reply.code(201).send({ orderId: created.id })
}, },