base commit
This commit is contained in:
@@ -1,10 +1,7 @@
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { MainLayout } from '@/app/layout/MainLayout'
|
||||
import { AppProviders } from '@/app/providers/AppProviders'
|
||||
import { AdminPage } from '@/pages/admin'
|
||||
import { AdminOrdersPage } from '@/pages/admin-orders'
|
||||
import { AdminReviewsPage } from '@/pages/admin-reviews'
|
||||
import { AdminUsersPage } from '@/pages/admin-users'
|
||||
import { AdminLayoutPage } from '@/pages/admin-layout'
|
||||
import { AuthPage } from '@/pages/auth'
|
||||
import { CartPage } from '@/pages/cart'
|
||||
import { CheckoutPage } from '@/pages/checkout'
|
||||
@@ -19,10 +16,7 @@ export function App() {
|
||||
<MainLayout>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
<Route path="/admin/orders" element={<AdminOrdersPage />} />
|
||||
<Route path="/admin/reviews" element={<AdminReviewsPage />} />
|
||||
<Route path="/admin/users" element={<AdminUsersPage />} />
|
||||
<Route path="/admin/*" element={<AdminLayoutPage />} />
|
||||
<Route path="/auth" element={<AuthPage />} />
|
||||
<Route path="/cart" element={<CartPage />} />
|
||||
<Route path="/checkout" element={<CheckoutPage />} />
|
||||
|
||||
@@ -3,6 +3,7 @@ import AccountCircleOutlinedIcon from '@mui/icons-material/AccountCircleOutlined
|
||||
import DarkModeOutlinedIcon from '@mui/icons-material/DarkModeOutlined'
|
||||
import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined'
|
||||
import MenuOutlinedIcon from '@mui/icons-material/MenuOutlined'
|
||||
import ShoppingCartOutlinedIcon from '@mui/icons-material/ShoppingCartOutlined'
|
||||
import AppBar from '@mui/material/AppBar'
|
||||
import Badge from '@mui/material/Badge'
|
||||
import Box from '@mui/material/Box'
|
||||
@@ -19,12 +20,15 @@ import Select from '@mui/material/Select'
|
||||
import type { SelectChangeEvent } from '@mui/material/Select'
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
import Toolbar from '@mui/material/Toolbar'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useUnit } from 'effector-react'
|
||||
import { Link as RouterLink, useNavigate } from 'react-router-dom'
|
||||
import type { ColorScheme } from '@/app/providers/theme-controller'
|
||||
import { useThemeController } from '@/app/providers/theme-controller'
|
||||
import { fetchMyCart } from '@/entities/cart/api/cart-api'
|
||||
import { STORE_NAME } from '@/shared/config'
|
||||
import { $user, logout, tokenSet } from '@/shared/model/auth'
|
||||
import { BearLogo } from '@/shared/ui/BearLogo'
|
||||
@@ -33,7 +37,6 @@ type NavItem = { label: string; to: string }
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ label: 'Каталог', to: '/' },
|
||||
{ label: 'Корзина', to: '/cart' },
|
||||
{ label: 'Админка', to: '/admin' },
|
||||
]
|
||||
|
||||
@@ -166,6 +169,14 @@ export function AppHeader() {
|
||||
const user = useUnit($user)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const cartQuery = useQuery({
|
||||
queryKey: ['me', 'cart'],
|
||||
queryFn: fetchMyCart,
|
||||
enabled: Boolean(user),
|
||||
})
|
||||
|
||||
const cartCount = cartQuery.data?.items?.length ?? 0
|
||||
|
||||
const [userAnchorEl, setUserAnchorEl] = useState<null | HTMLElement>(null)
|
||||
const userMenuOpen = Boolean(userAnchorEl)
|
||||
|
||||
@@ -238,6 +249,25 @@ export function AppHeader() {
|
||||
</Button>
|
||||
))}
|
||||
|
||||
<Tooltip title={user ? 'Корзина' : 'Авторизуйтесь для совершения покупок'}>
|
||||
<span>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
sx={{ ml: 1 }}
|
||||
disabled={!user}
|
||||
onClick={() => {
|
||||
if (!user) navigate('/auth')
|
||||
else navigate('/cart')
|
||||
}}
|
||||
aria-label="Корзина"
|
||||
>
|
||||
<Badge color="secondary" badgeContent={user ? cartCount : 0} invisible={!user || cartCount === 0}>
|
||||
<ShoppingCartOutlinedIcon />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<IconButton color="inherit" onClick={openUserMenu} sx={{ ml: 1 }} aria-label="Пользователь">
|
||||
<Badge
|
||||
variant="dot"
|
||||
@@ -300,6 +330,14 @@ export function AppHeader() {
|
||||
{i.label}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={() => go(user ? '/cart' : '/auth')}
|
||||
sx={{ justifyContent: 'flex-start' }}
|
||||
disabled={!user}
|
||||
>
|
||||
Корзина
|
||||
</Button>
|
||||
<Button variant="text" onClick={() => go(user ? '/me' : '/auth')} sx={{ justifyContent: 'flex-start' }}>
|
||||
{user ? 'Профиль' : 'Вход / регистрация'}
|
||||
</Button>
|
||||
|
||||
@@ -11,7 +11,7 @@ export type Product = {
|
||||
slug: string
|
||||
shortDescription: string | null
|
||||
description: string | null
|
||||
quantity?: number | null
|
||||
quantity: number
|
||||
materials?: string[]
|
||||
priceCents: number
|
||||
imageUrl: string | null
|
||||
|
||||
@@ -117,6 +117,14 @@ export function ProductCard({ product, mediaHeight = 200, actions }: Props) {
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<Stack spacing={1}>
|
||||
{product.category && <Chip label={product.category.name} size="small" />}
|
||||
{!product.inStock && (
|
||||
<Chip
|
||||
label={`Под заказ · ${product.leadTimeDays ?? '—'} дн.`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
/>
|
||||
)}
|
||||
<Typography
|
||||
variant="h6"
|
||||
component={RouterLink}
|
||||
@@ -126,7 +134,7 @@ export function ProductCard({ product, mediaHeight = 200, actions }: Props) {
|
||||
{product.title}
|
||||
</Typography>
|
||||
{(product.materials?.length ?? 0) > 0 && (
|
||||
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap">
|
||||
<Stack direction="row" spacing={1} useFlexGap sx={{ flexWrap: 'wrap' }}>
|
||||
{materials.map((m) => (
|
||||
<Chip key={m} label={m} size="small" variant="outlined" />
|
||||
))}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { ToggleCartIcon } from './ui/ToggleCartIcon'
|
||||
@@ -0,0 +1,62 @@
|
||||
import AddShoppingCartOutlinedIcon from '@mui/icons-material/AddShoppingCartOutlined'
|
||||
import ShoppingCartOutlinedIcon from '@mui/icons-material/ShoppingCartOutlined'
|
||||
import ShoppingCartRoundedIcon from '@mui/icons-material/ShoppingCartRounded'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useUnit } from 'effector-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { addToCart, fetchMyCart, removeCartItem } from '@/entities/cart/api/cart-api'
|
||||
import { $user } from '@/shared/model/auth'
|
||||
|
||||
export function ToggleCartIcon(props: { productId: string; size?: 'small' | 'medium' }) {
|
||||
const { productId, size = 'small' } = props
|
||||
const user = useUnit($user)
|
||||
const qc = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const cartQuery = useQuery({
|
||||
queryKey: ['me', 'cart'],
|
||||
queryFn: fetchMyCart,
|
||||
enabled: Boolean(user),
|
||||
})
|
||||
|
||||
const existing = cartQuery.data?.items.find((x) => x.product.id === productId) ?? null
|
||||
const inCart = Boolean(existing)
|
||||
|
||||
const addMut = useMutation({
|
||||
mutationFn: () => addToCart({ productId, qty: 1 }),
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'cart'] }),
|
||||
})
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: () => removeCartItem(existing!.id),
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'cart'] }),
|
||||
})
|
||||
|
||||
const disabled = !user
|
||||
const busy = addMut.isPending || removeMut.isPending
|
||||
|
||||
const onClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!user) {
|
||||
navigate('/auth')
|
||||
return
|
||||
}
|
||||
if (inCart) removeMut.mutate()
|
||||
else addMut.mutate()
|
||||
}
|
||||
|
||||
const tooltip = !user ? 'Авторизуйтесь для совершения покупок' : inCart ? 'Убрать из корзины' : 'В корзину'
|
||||
|
||||
return (
|
||||
<Tooltip title={tooltip}>
|
||||
<span>
|
||||
<IconButton size={size} onClick={onClick} disabled={disabled || busy} aria-label={tooltip}>
|
||||
{user ? inCart ? <ShoppingCartRoundedIcon /> : <AddShoppingCartOutlinedIcon /> : <ShoppingCartOutlinedIcon />}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { AdminLayoutPage } from './ui/AdminLayoutPage'
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import AssignmentOutlinedIcon from '@mui/icons-material/AssignmentOutlined'
|
||||
import MenuOutlinedIcon from '@mui/icons-material/MenuOutlined'
|
||||
import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined'
|
||||
import RateReviewOutlinedIcon from '@mui/icons-material/RateReviewOutlined'
|
||||
import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined'
|
||||
import Box from '@mui/material/Box'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import Drawer from '@mui/material/Drawer'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import List from '@mui/material/List'
|
||||
import ListItemButton from '@mui/material/ListItemButton'
|
||||
import ListItemIcon from '@mui/material/ListItemIcon'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { AdminPage } from '@/pages/admin'
|
||||
import { AdminOrdersPage } from '@/pages/admin-orders'
|
||||
import { AdminReviewsPage } from '@/pages/admin-reviews'
|
||||
import { AdminUsersPage } from '@/pages/admin-users'
|
||||
|
||||
type NavItem = {
|
||||
to: string
|
||||
label: string
|
||||
icon: ReactNode
|
||||
}
|
||||
|
||||
export function AdminLayoutPage() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const theme = useTheme()
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
|
||||
const navItems: NavItem[] = useMemo(
|
||||
() => [
|
||||
{ to: '/admin', label: 'Товары', icon: <StorefrontOutlinedIcon /> },
|
||||
{ to: '/admin/orders', label: 'Заказы', icon: <AssignmentOutlinedIcon /> },
|
||||
{ to: '/admin/reviews', label: 'Отзывы', icon: <RateReviewOutlinedIcon /> },
|
||||
{ to: '/admin/users', label: 'Пользователи', icon: <PeopleOutlinedIcon /> },
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const activeTo =
|
||||
navItems.find((x) => location.pathname === x.to)?.to ??
|
||||
navItems.find((x) => location.pathname.startsWith(`${x.to}/`))?.to ??
|
||||
null
|
||||
|
||||
const nav = (
|
||||
<Box sx={{ width: 280, maxWidth: '85vw' }}>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
Админка
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Управление магазином
|
||||
</Typography>
|
||||
</Box>
|
||||
<Divider />
|
||||
<List disablePadding>
|
||||
{navItems.map((i) => (
|
||||
<ListItemButton
|
||||
key={i.to}
|
||||
selected={activeTo === i.to}
|
||||
onClick={() => {
|
||||
navigate(i.to)
|
||||
setMobileOpen(false)
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>{i.icon}</ListItemIcon>
|
||||
<ListItemText primary={i.label} />
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)
|
||||
|
||||
return (
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={3} sx={{ alignItems: 'flex-start' }}>
|
||||
{isMobile ? (
|
||||
<>
|
||||
<Stack direction="row" spacing={1} sx={{ width: '100%', alignItems: 'center' }}>
|
||||
<IconButton onClick={() => setMobileOpen(true)} aria-label="Открыть меню админки">
|
||||
<MenuOutlinedIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>
|
||||
Админка
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Drawer open={mobileOpen} onClose={() => setMobileOpen(false)} ModalProps={{ keepMounted: true }}>
|
||||
{nav}
|
||||
</Drawer>
|
||||
</>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'sticky',
|
||||
top: 88,
|
||||
alignSelf: 'flex-start',
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
bgcolor: 'background.paper',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{nav}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0, width: '100%' }}>
|
||||
<Routes>
|
||||
<Route index element={<AdminPage />} />
|
||||
<Route path="orders" element={<AdminOrdersPage />} />
|
||||
<Route path="reviews" element={<AdminReviewsPage />} />
|
||||
<Route path="users" element={<AdminUsersPage />} />
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
</Routes>
|
||||
</Box>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -20,7 +20,6 @@ import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { Link as RouterLink } from 'react-router-dom'
|
||||
import {
|
||||
fetchAdminOrder,
|
||||
fetchAdminOrders,
|
||||
@@ -104,17 +103,9 @@ export function AdminOrdersPage() {
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}>
|
||||
<Typography variant="h4" sx={{ flexGrow: 1 }}>
|
||||
Админка — заказы
|
||||
</Typography>
|
||||
<Button component={RouterLink} to="/admin" variant="outlined">
|
||||
Товары
|
||||
</Button>
|
||||
<Button component={RouterLink} to="/admin/reviews" variant="outlined">
|
||||
Отзывы
|
||||
</Button>
|
||||
</Stack>
|
||||
<Typography variant="h4" sx={{ mb: 2 }}>
|
||||
Заказы
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Введите API-токен из <code>ADMIN_API_TOKEN</code> (сохраняется в sessionStorage).
|
||||
@@ -222,7 +213,7 @@ export function AdminOrdersPage() {
|
||||
#{detail.id.slice(-8)} · {detail.user.email} · {detail.status} · {formatPriceRub(detail.totalCents)}
|
||||
</Typography>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems={{ sm: 'center' }}>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'center' } }}>
|
||||
<FormControl size="small" sx={{ minWidth: 240 }}>
|
||||
<InputLabel id="next-status-label">Сменить статус</InputLabel>
|
||||
<Select
|
||||
@@ -264,7 +255,7 @@ export function AdminOrdersPage() {
|
||||
{detail.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems={{ sm: 'flex-end' }}>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
|
||||
<TextField
|
||||
label="Ответ админа"
|
||||
value={msg}
|
||||
|
||||
@@ -13,7 +13,6 @@ import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { Link as RouterLink } from 'react-router-dom'
|
||||
import { fetchAdminReviews, moderateReview } from '@/entities/review/api/admin-review-api'
|
||||
import { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token'
|
||||
|
||||
@@ -57,17 +56,9 @@ export function AdminReviewsPage() {
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}>
|
||||
<Typography variant="h4" sx={{ flexGrow: 1 }}>
|
||||
Админка — отзывы
|
||||
</Typography>
|
||||
<Button component={RouterLink} to="/admin" variant="outlined">
|
||||
Товары
|
||||
</Button>
|
||||
<Button component={RouterLink} to="/admin/orders" variant="outlined">
|
||||
Заказы
|
||||
</Button>
|
||||
</Stack>
|
||||
<Typography variant="h4" sx={{ mb: 2 }}>
|
||||
Отзывы
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Введите API-токен из <code>ADMIN_API_TOKEN</code>.
|
||||
|
||||
@@ -22,7 +22,6 @@ import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { Link as RouterLink } from 'react-router-dom'
|
||||
import {
|
||||
createCategory,
|
||||
createProduct,
|
||||
@@ -328,15 +327,6 @@ export function AdminPage() {
|
||||
<Button variant="outlined" onClick={() => setCatOpen(true)}>
|
||||
Новая категория
|
||||
</Button>
|
||||
<Button component={RouterLink} to="/admin/orders" variant="outlined">
|
||||
Заказы
|
||||
</Button>
|
||||
<Button component={RouterLink} to="/admin/reviews" variant="outlined">
|
||||
Отзывы
|
||||
</Button>
|
||||
<Button component={RouterLink} to="/admin/users" variant="outlined">
|
||||
Пользователи
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{productsQuery.isError && (
|
||||
|
||||
@@ -7,6 +7,7 @@ import Button from '@mui/material/Button'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useUnit } from 'effector-react'
|
||||
@@ -62,57 +63,104 @@ export function CartPage() {
|
||||
|
||||
{items.length > 0 && (
|
||||
<Stack spacing={2}>
|
||||
{items.map((x) => (
|
||||
<Box
|
||||
key={x.id}
|
||||
sx={{
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
p: 2,
|
||||
bgcolor: 'background.paper',
|
||||
}}
|
||||
>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} 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} шт.
|
||||
</Typography>
|
||||
{items.map((x) =>
|
||||
(() => {
|
||||
const available = x.product.inStock ? x.product.quantity : 1
|
||||
const canInc = x.qty < available
|
||||
const over = x.qty > available
|
||||
return (
|
||||
<Box
|
||||
key={x.id}
|
||||
sx={{
|
||||
border: 1,
|
||||
borderColor: over ? 'error.light' : 'divider',
|
||||
borderRadius: 2,
|
||||
p: 2,
|
||||
bgcolor: over ? 'error.50' : 'background.paper',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
<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 />
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems={{ sm: 'center' }}>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'center' } }}>
|
||||
<Typography variant="h6" sx={{ flexGrow: 1 }}>
|
||||
Итого: {formatPriceRub(total)}
|
||||
</Typography>
|
||||
|
||||
@@ -37,8 +37,11 @@ export function CheckoutPage() {
|
||||
enabled: Boolean(user),
|
||||
})
|
||||
|
||||
const defaultAddressId = addressesQuery.data?.items?.find((a) => a.isDefault)?.id ?? ''
|
||||
const selectedAddressId = addressId || defaultAddressId
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () => createOrder({ addressId, comment: comment.trim() || null }),
|
||||
mutationFn: () => createOrder({ addressId: selectedAddressId, comment: comment.trim() || null }),
|
||||
onSuccess: async (res) => {
|
||||
await qc.invalidateQueries({ queryKey: ['me', 'cart'] })
|
||||
navigate(`/me/orders/${res.orderId}`, { replace: true })
|
||||
@@ -60,8 +63,8 @@ export function CheckoutPage() {
|
||||
const items = cartQuery.data?.items ?? []
|
||||
const total = items.reduce((s, x) => s + x.product.priceCents * x.qty, 0)
|
||||
const addresses = addressesQuery.data?.items ?? []
|
||||
|
||||
const defaultAddr = addresses.find((a) => a.isDefault)
|
||||
const hasOverLimit = items.some((x) => x.qty > (x.product.inStock ? x.product.quantity : 1))
|
||||
const hasMadeToOrder = items.some((x) => !x.product.inStock)
|
||||
|
||||
return (
|
||||
<Box>
|
||||
@@ -80,12 +83,45 @@ export function CheckoutPage() {
|
||||
)}
|
||||
|
||||
<Stack spacing={2} sx={{ maxWidth: 720 }}>
|
||||
{items.length > 0 && (
|
||||
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
|
||||
<Typography sx={{ fontWeight: 700, mb: 1 }}>Позиции</Typography>
|
||||
<Stack spacing={0.5}>
|
||||
{items.map((x) => {
|
||||
const available = x.product.inStock ? x.product.quantity : 1
|
||||
const over = x.qty > available
|
||||
return (
|
||||
<Typography key={x.id} color={over ? 'error' : 'text.primary'}>
|
||||
{x.product.title}: {x.qty} шт. (доступно {available})
|
||||
</Typography>
|
||||
)
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{hasOverLimit && (
|
||||
<Alert severity="warning">
|
||||
Некоторые позиции превышают доступное количество. Вернитесь в{' '}
|
||||
<Typography component={RouterLink} to="/cart" sx={{ textDecoration: 'underline' }}>
|
||||
корзину
|
||||
</Typography>{' '}
|
||||
и скорректируйте количество.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{hasMadeToOrder && (
|
||||
<Alert severity="info">
|
||||
В заказе есть товары «под заказ». Доставка будет после изготовления (срок указан в карточке товара).
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel id="addr-label">Адрес доставки</InputLabel>
|
||||
<Select
|
||||
labelId="addr-label"
|
||||
label="Адрес доставки"
|
||||
value={addressId || (defaultAddr?.id ?? '')}
|
||||
value={selectedAddressId}
|
||||
onChange={(e) => setAddressId(String(e.target.value))}
|
||||
>
|
||||
{addresses.map((a) => (
|
||||
@@ -119,7 +155,9 @@ export function CheckoutPage() {
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={items.length === 0 || addresses.length === 0 || createMut.isPending}
|
||||
disabled={
|
||||
items.length === 0 || addresses.length === 0 || !selectedAddressId || hasOverLimit || createMut.isPending
|
||||
}
|
||||
onClick={() => createMut.mutate()}
|
||||
>
|
||||
Создать заказ
|
||||
|
||||
@@ -21,7 +21,7 @@ import Typography from '@mui/material/Typography'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchCategories, fetchPublicProducts } from '@/entities/product/api/product-api'
|
||||
import { ProductCard } from '@/entities/product/ui/ProductCard'
|
||||
import { AddToCartButton } from '@/features/cart/add-to-cart'
|
||||
import { ToggleCartIcon } from '@/features/cart/toggle-cart-icon'
|
||||
|
||||
export function HomePage() {
|
||||
const [categorySlug, setCategorySlug] = useState<string>('')
|
||||
@@ -124,8 +124,7 @@ export function HomePage() {
|
||||
<Stack
|
||||
direction={{ xs: 'column', md: 'row' }}
|
||||
spacing={2}
|
||||
alignItems={{ md: 'center' }}
|
||||
sx={{ flexWrap: { md: 'wrap' } }}
|
||||
sx={{ alignItems: { md: 'center' }, flexWrap: { md: 'wrap' } }}
|
||||
>
|
||||
<FormControl sx={{ minWidth: 220 }} size="small">
|
||||
<InputLabel id="category-filter-label">Категория</InputLabel>
|
||||
@@ -159,9 +158,7 @@ export function HomePage() {
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
spacing={1.5}
|
||||
alignItems={{ sm: 'center' }}
|
||||
justifyContent="space-between"
|
||||
flexWrap="wrap"
|
||||
sx={{ alignItems: { sm: 'center' }, justifyContent: 'space-between', flexWrap: 'wrap' }}
|
||||
>
|
||||
<Button variant="text" onClick={() => setMoreOpen((v) => !v)} sx={{ alignSelf: { xs: 'flex-start' } }}>
|
||||
{moreOpen ? 'Скрыть фильтры' : 'Фильтры и сортировка'}
|
||||
@@ -188,8 +185,7 @@ export function HomePage() {
|
||||
<Stack
|
||||
direction={{ xs: 'column', md: 'row' }}
|
||||
spacing={2}
|
||||
alignItems={{ md: 'center' }}
|
||||
sx={{ mt: 2, flexWrap: { md: 'wrap' } }}
|
||||
sx={{ mt: 2, alignItems: { md: 'center' }, flexWrap: { md: 'wrap' } }}
|
||||
>
|
||||
<FormControl sx={{ minWidth: 220 }} size="small">
|
||||
<InputLabel id="sort-label">Сортировка</InputLabel>
|
||||
@@ -316,16 +312,12 @@ export function HomePage() {
|
||||
<Grid container spacing={2}>
|
||||
{products.map((p) => (
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={p.id}>
|
||||
<ProductCard
|
||||
product={p}
|
||||
mediaHeight={mediaHeight}
|
||||
actions={<AddToCartButton productId={p.id} variant="contained" size="small" />}
|
||||
/>
|
||||
<ProductCard product={p} mediaHeight={mediaHeight} actions={<ToggleCartIcon productId={p.id} />} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
<Stack direction="row" justifyContent="center" sx={{ mt: 3 }}>
|
||||
<Stack direction="row" sx={{ mt: 3, justifyContent: 'center' }}>
|
||||
<Pagination
|
||||
page={page}
|
||||
count={totalPages}
|
||||
|
||||
@@ -93,7 +93,7 @@ export function MeLayoutPage() {
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={3} sx={{ alignItems: 'flex-start' }}>
|
||||
{isMobile ? (
|
||||
<>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ width: '100%' }}>
|
||||
<Stack direction="row" spacing={1} sx={{ width: '100%', alignItems: 'center' }}>
|
||||
<IconButton onClick={() => setMobileOpen(true)} aria-label="Открыть меню профиля">
|
||||
<MenuOutlinedIcon />
|
||||
</IconButton>
|
||||
|
||||
@@ -182,7 +182,7 @@ export function AddressesPage() {
|
||||
bgcolor: 'background.paper',
|
||||
}}
|
||||
>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} alignItems={{ sm: 'center' }}>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ alignItems: { sm: 'center' } }}>
|
||||
<Typography sx={{ fontWeight: 700, flexGrow: 1 }}>{a.label?.trim() ? a.label : 'Адрес'}</Typography>
|
||||
{a.isDefault && <Chip label="По умолчанию" color="primary" size="small" />}
|
||||
</Stack>
|
||||
|
||||
@@ -60,7 +60,7 @@ export function OrderDetailPage() {
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems={{ sm: 'center' }} sx={{ mb: 2 }}>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h4">Заказ #{order.id.slice(-6)}</Typography>
|
||||
<Typography color="text.secondary">Статус: {order.status}</Typography>
|
||||
@@ -77,7 +77,7 @@ export function OrderDetailPage() {
|
||||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
{order.items.map((i) => (
|
||||
<Stack key={i.id} direction="row" spacing={2} alignItems="center">
|
||||
<Stack key={i.id} direction="row" spacing={2} sx={{ alignItems: 'center' }}>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography sx={{ fontWeight: 700 }}>{i.titleSnapshot}</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
@@ -155,7 +155,7 @@ export function OrderDetailPage() {
|
||||
{order.messages.length === 0 && <Typography color="text.secondary">Пока сообщений нет.</Typography>}
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems={{ sm: 'flex-end' }}>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
|
||||
<TextField
|
||||
label="Сообщение"
|
||||
value={text}
|
||||
|
||||
@@ -40,7 +40,7 @@ export function OrdersPage() {
|
||||
bgcolor: 'background.paper',
|
||||
}}
|
||||
>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} alignItems={{ sm: 'center' }}>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ alignItems: { sm: 'center' } }}>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography sx={{ fontWeight: 700 }}>Заказ #{o.id.slice(-6)}</Typography>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Swiper, SwiperSlide } from 'swiper/react'
|
||||
import 'swiper/css'
|
||||
import 'swiper/css/navigation'
|
||||
import { fetchPublicProduct } from '@/entities/product/api/product-api'
|
||||
import { AddToCartButton } from '@/features/cart/add-to-cart'
|
||||
import { ToggleCartIcon } from '@/features/cart/toggle-cart-icon'
|
||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||
|
||||
export function ProductPage() {
|
||||
@@ -137,7 +137,13 @@ export function ProductPage() {
|
||||
{formatPriceRub(p.priceCents)}
|
||||
</Typography>
|
||||
|
||||
<AddToCartButton productId={p.id} variant="contained" sx={{ alignSelf: 'flex-start' }} />
|
||||
<ToggleCartIcon productId={p.id} size="medium" />
|
||||
|
||||
{!p.inStock && (
|
||||
<Alert severity="info">
|
||||
Этот товар изготавливается под заказ. Доставка будет после изготовления (~{p.leadTimeDays ?? '—'} дн.).
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{p.description ? (
|
||||
<Typography sx={{ whiteSpace: 'pre-wrap' }}>{p.description}</Typography>
|
||||
|
||||
@@ -74,8 +74,13 @@ sample({
|
||||
target: $user,
|
||||
})
|
||||
|
||||
let tokenPersistInitialized = false
|
||||
$token.watch((t) => {
|
||||
try {
|
||||
if (!tokenPersistInitialized) {
|
||||
tokenPersistInitialized = true
|
||||
return
|
||||
}
|
||||
if (!t) localStorage.removeItem(TOKEN_KEY)
|
||||
else localStorage.setItem(TOKEN_KEY, t)
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user