base commit
This commit is contained in:
@@ -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 />} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>.
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()}
|
||||||
>
|
>
|
||||||
Создать заказ
|
Создать заказ
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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("[]")
|
||||||
/// Цена в копейках (целое число, без дробной части)
|
/// Цена в копейках (целое число, без дробной части)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 })
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user