diff --git a/client/src/app/App.tsx b/client/src/app/App.tsx index 342aca6..7cb4180 100644 --- a/client/src/app/App.tsx +++ b/client/src/app/App.tsx @@ -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() { } /> - } /> - } /> - } /> - } /> + } /> } /> } /> } /> diff --git a/client/src/app/layout/AppHeader.tsx b/client/src/app/layout/AppHeader.tsx index 716a24d..02a30f5 100644 --- a/client/src/app/layout/AppHeader.tsx +++ b/client/src/app/layout/AppHeader.tsx @@ -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) const userMenuOpen = Boolean(userAnchorEl) @@ -238,6 +249,25 @@ export function AppHeader() { ))} + + + { + if (!user) navigate('/auth') + else navigate('/cart') + }} + aria-label="Корзина" + > + + + + + + + ))} + diff --git a/client/src/entities/product/model/types.ts b/client/src/entities/product/model/types.ts index e0c6c9b..fa22da1 100644 --- a/client/src/entities/product/model/types.ts +++ b/client/src/entities/product/model/types.ts @@ -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 diff --git a/client/src/entities/product/ui/ProductCard.tsx b/client/src/entities/product/ui/ProductCard.tsx index f0fd060..2ecfbca 100644 --- a/client/src/entities/product/ui/ProductCard.tsx +++ b/client/src/entities/product/ui/ProductCard.tsx @@ -117,6 +117,14 @@ export function ProductCard({ product, mediaHeight = 200, actions }: Props) { {product.category && } + {!product.inStock && ( + + )} {(product.materials?.length ?? 0) > 0 && ( - + {materials.map((m) => ( ))} diff --git a/client/src/features/cart/toggle-cart-icon/index.ts b/client/src/features/cart/toggle-cart-icon/index.ts new file mode 100644 index 0000000..8a884df --- /dev/null +++ b/client/src/features/cart/toggle-cart-icon/index.ts @@ -0,0 +1 @@ +export { ToggleCartIcon } from './ui/ToggleCartIcon' diff --git a/client/src/features/cart/toggle-cart-icon/ui/ToggleCartIcon.tsx b/client/src/features/cart/toggle-cart-icon/ui/ToggleCartIcon.tsx new file mode 100644 index 0000000..4eb2ed8 --- /dev/null +++ b/client/src/features/cart/toggle-cart-icon/ui/ToggleCartIcon.tsx @@ -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 ( + + + + {user ? inCart ? : : } + + + + ) +} diff --git a/client/src/pages/admin-layout/index.ts b/client/src/pages/admin-layout/index.ts new file mode 100644 index 0000000..c7320cf --- /dev/null +++ b/client/src/pages/admin-layout/index.ts @@ -0,0 +1 @@ +export { AdminLayoutPage } from './ui/AdminLayoutPage' diff --git a/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx b/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx new file mode 100644 index 0000000..7ce779b --- /dev/null +++ b/client/src/pages/admin-layout/ui/AdminLayoutPage.tsx @@ -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: }, + { to: '/admin/orders', label: 'Заказы', icon: }, + { to: '/admin/reviews', label: 'Отзывы', icon: }, + { to: '/admin/users', label: 'Пользователи', icon: }, + ], + [], + ) + + const activeTo = + navItems.find((x) => location.pathname === x.to)?.to ?? + navItems.find((x) => location.pathname.startsWith(`${x.to}/`))?.to ?? + null + + const nav = ( + + + + Админка + + + Управление магазином + + + + + {navItems.map((i) => ( + { + navigate(i.to) + setMobileOpen(false) + }} + > + {i.icon} + + + ))} + + + ) + + return ( + + {isMobile ? ( + <> + + setMobileOpen(true)} aria-label="Открыть меню админки"> + + + + Админка + + + setMobileOpen(false)} ModalProps={{ keepMounted: true }}> + {nav} + + + ) : ( + + {nav} + + )} + + + + } /> + } /> + } /> + } /> + } /> + + + + ) +} diff --git a/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx b/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx index dc512fc..135ea5c 100644 --- a/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx +++ b/client/src/pages/admin-orders/ui/AdminOrdersPage.tsx @@ -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 ( - - - Админка — заказы - - - - + + Заказы + Введите API-токен из ADMIN_API_TOKEN (сохраняется в sessionStorage). @@ -222,7 +213,7 @@ export function AdminOrdersPage() { #{detail.id.slice(-8)} · {detail.user.email} · {detail.status} · {formatPriceRub(detail.totalCents)} - + Сменить статус setAddressId(String(e.target.value))} > {addresses.map((a) => ( @@ -119,7 +155,9 @@ export function CheckoutPage() {