base commit

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