first front + redux
parent
ebe207ec83
commit
f4fd3916b8
@ -1,21 +1,45 @@
|
|||||||
import { Container, CssBaseline, ThemeProvider } from '@mui/material'
|
|
||||||
import { Shop } from './pages/shop'
|
|
||||||
import { blue } from '@mui/material/colors'
|
|
||||||
import { theme } from './theme'
|
|
||||||
import { Provider } from 'react-redux'
|
import { Provider } from 'react-redux'
|
||||||
import { store } from './store'
|
import { store } from './store'
|
||||||
|
import {
|
||||||
|
createBrowserRouter,
|
||||||
|
createRoutesFromElements,
|
||||||
|
Route,
|
||||||
|
RouterProvider,
|
||||||
|
} from 'react-router-dom'
|
||||||
|
import { Layout } from './layout'
|
||||||
|
import {
|
||||||
|
LoginPage,
|
||||||
|
LogoutPage,
|
||||||
|
NotFoundPage,
|
||||||
|
ProductPage,
|
||||||
|
RegisterPage,
|
||||||
|
ShopPage,
|
||||||
|
UserPage,
|
||||||
|
} from './pages'
|
||||||
|
|
||||||
|
export const routers = createRoutesFromElements(
|
||||||
|
<Route path="/" id="root" element={<Layout />}>
|
||||||
|
<Route index element={<ShopPage />} />
|
||||||
|
<Route
|
||||||
|
id="/product"
|
||||||
|
path="/product/:id"
|
||||||
|
element={<ProductPage />}
|
||||||
|
errorElement={<NotFoundPage />}
|
||||||
|
/>
|
||||||
|
<Route path="/user" element={<UserPage />} />
|
||||||
|
<Route path="/registr" element={<RegisterPage />} />
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/logout" element={<LogoutPage />} />
|
||||||
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
|
</Route>
|
||||||
|
)
|
||||||
|
|
||||||
|
const router = createBrowserRouter(routers)
|
||||||
|
|
||||||
export const App = () => {
|
export const App = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<Provider store={store}>
|
||||||
<CssBaseline />
|
<RouterProvider router={router} />
|
||||||
<ThemeProvider theme={theme}>
|
</Provider>
|
||||||
<Provider store={store}>
|
|
||||||
<Container maxWidth="md" sx={{ bgcolor: blue }}>
|
|
||||||
<Shop />
|
|
||||||
</Container>
|
|
||||||
</Provider>
|
|
||||||
</ThemeProvider>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,37 @@
|
|||||||
|
import { forwardRef, memo, useCallback } from 'react'
|
||||||
|
import { Link as LinkButton, Typography } from '@mui/material'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import type { CleanComponentType } from '../types'
|
||||||
|
|
||||||
|
export type BackButtonProps = React.HTMLProps<HTMLButtonElement> & {
|
||||||
|
text?: string
|
||||||
|
title?: string
|
||||||
|
path?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BackButtonType = CleanComponentType<HTMLButtonElement, BackButtonProps>
|
||||||
|
|
||||||
|
export const BackButton = memo(
|
||||||
|
forwardRef(({ title, text = '< Назад', path }: BackButtonProps, ref) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
if (path) {
|
||||||
|
navigate(path)
|
||||||
|
} else {
|
||||||
|
navigate(-1)
|
||||||
|
}
|
||||||
|
}, [navigate, path])
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LinkButton ref={ref} onClick={handleBack} tabIndex={0} component="button">
|
||||||
|
{text}
|
||||||
|
</LinkButton>
|
||||||
|
{title && (
|
||||||
|
<Typography variant="h2" sx={{ fontSize: 28, pt: 2, pb: 2 }}>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) as BackButtonType
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
export * from './spinner'
|
||||||
|
export * from './load-more'
|
||||||
|
export * from './product-list'
|
||||||
|
export * from './product-card'
|
||||||
|
export * from './product-detail'
|
||||||
|
export * from './cart-button'
|
||||||
|
export * from './back-button'
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
import { Alert, CircularProgress, Stack } from '@mui/material'
|
||||||
|
import { type FC, useLayoutEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
interface LoadMoreProps {
|
||||||
|
action: () => void
|
||||||
|
isLoading?: boolean
|
||||||
|
isEndOfList?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoadMore: FC<LoadMoreProps> = ({ action, isLoading, isEndOfList }) => {
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Используем useLayoutEffect для того, чтобы зарегистрировать
|
||||||
|
// observer до того момента, когда разметка отобразиться на экране
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
let observer: IntersectionObserver | undefined = undefined
|
||||||
|
|
||||||
|
// Если мы не достигли конца списка, то запускаем инициализацию
|
||||||
|
// IntersectionObserver
|
||||||
|
if (!isEndOfList) {
|
||||||
|
// Определяем настройка для будущего наблюдателя
|
||||||
|
// Читай доку https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/IntersectionObserver
|
||||||
|
const options: IntersectionObserverInit = {
|
||||||
|
threshold: 0.5,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция, которая будет вызываться каждый раз, когда элемент будет
|
||||||
|
// появляться на экране и исчезать
|
||||||
|
const callback: IntersectionObserverCallback = entries => {
|
||||||
|
// Для того чтобы отследить именно появление элемента в области видимости,
|
||||||
|
// используем свойство isIntersecting
|
||||||
|
if (entries[0].isIntersecting) {
|
||||||
|
// запускаем пользовательскую логику
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// создаем экземпляр класса IntersectionObserver
|
||||||
|
observer = new IntersectionObserver(callback, options)
|
||||||
|
|
||||||
|
// Если ссылка есть, то начинаем наблюдать за нашим элементом
|
||||||
|
ref.current && observer.observe(ref.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Перед последующем запуском useLayoutEffect перестаем следить
|
||||||
|
// за всеми элементами
|
||||||
|
observer && observer.disconnect()
|
||||||
|
}
|
||||||
|
}, [action, isEndOfList])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack ref={ref} direction="row" justifyContent="center" alignItems="center" sx={{ my: 5 }}>
|
||||||
|
{isLoading && <CircularProgress />}
|
||||||
|
{isEndOfList && <Alert severity="success">End of list!</Alert>}
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,84 @@
|
|||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Card,
|
||||||
|
CardActions,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardMedia,
|
||||||
|
Grid,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
|
||||||
|
import { type SyntheticEvent, useCallback } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { CartButton } from './cart-button'
|
||||||
|
import { useAppDispatch, useAppSelector, selectProduct } from '../storage'
|
||||||
|
|
||||||
|
export type ProductCardProps = {
|
||||||
|
productId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProductCard = ({ productId }: ProductCardProps) => {
|
||||||
|
const product = useAppSelector(selectProduct(productId))
|
||||||
|
const { id, name, count, reserved, picture_url, description, type, cost } = product || {
|
||||||
|
id: productId,
|
||||||
|
name: '',
|
||||||
|
count: 0,
|
||||||
|
reserved: 0,
|
||||||
|
picture_url: '',
|
||||||
|
description: '',
|
||||||
|
type: 'unknown',
|
||||||
|
cost: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
|
const quantity = 5
|
||||||
|
|
||||||
|
const handleCart = useCallback((num: number) => {}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 4 }}>
|
||||||
|
<Card elevation={3}>
|
||||||
|
<CardHeader
|
||||||
|
avatar={
|
||||||
|
<Avatar sx={{ bgcolor: 'info.main' }} aria-label="recipe">
|
||||||
|
{name[0]}
|
||||||
|
</Avatar>
|
||||||
|
}
|
||||||
|
title={name}
|
||||||
|
subheader={'subheader'}
|
||||||
|
/>
|
||||||
|
<Link to={`/product/${id}`}>
|
||||||
|
<CardMedia
|
||||||
|
component="img"
|
||||||
|
height="194"
|
||||||
|
onError={(e: SyntheticEvent<HTMLImageElement>) => {
|
||||||
|
e.currentTarget.src =
|
||||||
|
'https://react-learning.ru/image-compressed/default-image.jpg'
|
||||||
|
}}
|
||||||
|
image={picture_url}
|
||||||
|
alt={name}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<CardContent>
|
||||||
|
<Typography noWrap={true} variant="body2" color="text.secondary">
|
||||||
|
{description}
|
||||||
|
</Typography>
|
||||||
|
<Typography noWrap={true} variant="subtitle1" color="success.main">
|
||||||
|
{`Цена: ${cost} ₽`}
|
||||||
|
</Typography>
|
||||||
|
<Typography noWrap={true} variant="subtitle1" color="text.primary">
|
||||||
|
{`Доступно: ${count} шт.`}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
<CardActions
|
||||||
|
sx={{ mt: 'auto', display: 'flex', justifyContent: 'space-between' }}
|
||||||
|
disableSpacing
|
||||||
|
>
|
||||||
|
<CartButton count={quantity} onClick={handleCart} />
|
||||||
|
</CardActions>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Card,
|
||||||
|
CardActions,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardMedia,
|
||||||
|
Typography,
|
||||||
|
Grid,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { CartButton } from './cart-button'
|
||||||
|
import { useAppSelector, selectProduct } from '../storage'
|
||||||
|
|
||||||
|
export type ProductDetailProps = {
|
||||||
|
productId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProductDetail = ({ productId }: ProductDetailProps) => {
|
||||||
|
const product = useAppSelector(selectProduct(productId))
|
||||||
|
const { id, name, count, reserved, picture_url, description, type, cost } = product || {
|
||||||
|
id: productId,
|
||||||
|
name: '',
|
||||||
|
count: 0,
|
||||||
|
reserved: 0,
|
||||||
|
picture_url: '',
|
||||||
|
description: '',
|
||||||
|
type: 'unknown',
|
||||||
|
cost: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const quantity = 5
|
||||||
|
|
||||||
|
const handleCart = useCallback((num: number) => {}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid container direction="column" justifyContent="center" alignItems="center">
|
||||||
|
<Card
|
||||||
|
elevation={1}
|
||||||
|
sx={{
|
||||||
|
maxWidth: 750,
|
||||||
|
width: '100%',
|
||||||
|
minWidth: 345,
|
||||||
|
display: 'flex',
|
||||||
|
flexFlow: 'column',
|
||||||
|
height: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardHeader
|
||||||
|
avatar={
|
||||||
|
<Avatar sx={{ bgcolor: 'info.main' }} aria-label="recipe">
|
||||||
|
{name[0]}
|
||||||
|
</Avatar>
|
||||||
|
}
|
||||||
|
title={name}
|
||||||
|
subheader={'subheader'}
|
||||||
|
/>
|
||||||
|
<CardMedia
|
||||||
|
component="img"
|
||||||
|
height="300"
|
||||||
|
sx={{ objectPosition: 'top', objectFit: 'contain' }}
|
||||||
|
image={picture_url}
|
||||||
|
alt={name}
|
||||||
|
/>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{description}
|
||||||
|
</Typography>
|
||||||
|
<Typography noWrap={true} variant="subtitle1" color="success.main">
|
||||||
|
{`Цена: ${cost} ₽`}
|
||||||
|
</Typography>
|
||||||
|
<Typography noWrap={true} variant="subtitle1" color="text.primary">
|
||||||
|
{`Доступно: ${count} шт.`}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
<CardActions
|
||||||
|
sx={{ mt: 'auto', display: 'flex', justifyContent: 'space-between' }}
|
||||||
|
disableSpacing
|
||||||
|
>
|
||||||
|
<CartButton count={quantity} onClick={handleCart}></CartButton>
|
||||||
|
</CardActions>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
import { Grid } from '@mui/material'
|
||||||
|
import { useCallback, useState, type JSX } from 'react'
|
||||||
|
import { ProductCard } from './product-card'
|
||||||
|
import { type Product } from '../types'
|
||||||
|
import { LoadMore } from './load-more'
|
||||||
|
import { useAppSelector, selectProducts } from '../storage'
|
||||||
|
|
||||||
|
export type ProductListProps = {
|
||||||
|
products: Product[]
|
||||||
|
loading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProductList = ({ products, loading = false }: ProductListProps): JSX.Element => {
|
||||||
|
const [page, setPage] = useState<number>(1)
|
||||||
|
|
||||||
|
const productsLength = useAppSelector(selectProducts).length
|
||||||
|
const isEndOfList = products && products.length >= productsLength
|
||||||
|
|
||||||
|
const loadMorePosts = useCallback(() => {
|
||||||
|
if (!isEndOfList) {
|
||||||
|
setPage(page + 1)
|
||||||
|
}
|
||||||
|
}, [isEndOfList, page])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Grid container spacing={2} justifyContent="flex-start" alignItems="stretch">
|
||||||
|
{products.map(product => (
|
||||||
|
<ProductCard key={product.id} productId={product.id} />
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
{!!products?.length && (
|
||||||
|
<LoadMore isLoading={loading} action={loadMorePosts} isEndOfList={isEndOfList} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
import CircularProgress from '@mui/material/CircularProgress'
|
||||||
|
import Box from '@mui/material/Box'
|
||||||
|
|
||||||
|
export const Spinner = () => {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex' }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from './with-protection'
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
import { Box, Container } from '@mui/material'
|
||||||
|
import type { JSX } from 'react'
|
||||||
|
import { ToastContainer } from 'react-toastify'
|
||||||
|
import { Spinner } from '../components'
|
||||||
|
import { Outlet } from 'react-router-dom'
|
||||||
|
|
||||||
|
export type BodyProps = {
|
||||||
|
loading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Body = ({ loading }: BodyProps): JSX.Element => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ToastContainer
|
||||||
|
position="top-right"
|
||||||
|
autoClose={5000}
|
||||||
|
hideProgressBar={false}
|
||||||
|
pauseOnHover
|
||||||
|
theme="colored"
|
||||||
|
/>
|
||||||
|
{loading ? (
|
||||||
|
<Box display="flex" justifyContent="center" alignItems="center" minHeight="100vh">
|
||||||
|
<Spinner />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Container maxWidth="lg" sx={{ py: 8 }}>
|
||||||
|
<Outlet />
|
||||||
|
</Container>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,195 @@
|
|||||||
|
import AppBar from '@mui/material/AppBar'
|
||||||
|
import Box from '@mui/material/Box'
|
||||||
|
import Toolbar from '@mui/material/Toolbar'
|
||||||
|
import IconButton from '@mui/material/IconButton'
|
||||||
|
import Typography from '@mui/material/Typography'
|
||||||
|
import Menu from '@mui/material/Menu'
|
||||||
|
import MenuIcon from '@mui/icons-material/Menu'
|
||||||
|
import Container from '@mui/material/Container'
|
||||||
|
import Avatar from '@mui/material/Avatar'
|
||||||
|
import Tooltip from '@mui/material/Tooltip'
|
||||||
|
import MenuItem from '@mui/material/MenuItem'
|
||||||
|
import PagesIcon from '@mui/icons-material/Pages'
|
||||||
|
import { type JSX, type MouseEvent, useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { Badge } from '@mui/material'
|
||||||
|
import { useAppDispatch } from '../storage'
|
||||||
|
|
||||||
|
const EXIT_NAME = 'Выйти'
|
||||||
|
const CART_NAME = 'Корзина'
|
||||||
|
|
||||||
|
export const HEADER_TESTID = 'HEADER_TESTID'
|
||||||
|
|
||||||
|
export const pages = [
|
||||||
|
{ name: 'Продукты', to: '/' },
|
||||||
|
{ name: CART_NAME, to: '/basket' },
|
||||||
|
]
|
||||||
|
export const settings = [
|
||||||
|
{ name: 'Профиль', to: '/user' },
|
||||||
|
{ name: EXIT_NAME, to: '/' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const Header = (): JSX.Element => {
|
||||||
|
const [anchorElNav, setAnchorElNav] = useState<null | HTMLElement>(null)
|
||||||
|
const [anchorElUser, setAnchorElUser] = useState<null | HTMLElement>(null)
|
||||||
|
|
||||||
|
// TODO: брать из storage
|
||||||
|
const quantitiesSum = 3
|
||||||
|
const currentUser = {
|
||||||
|
name: 'Петя',
|
||||||
|
avatarPath: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenNavMenu = (event: MouseEvent<HTMLElement>) => {
|
||||||
|
setAnchorElNav(event.currentTarget)
|
||||||
|
}
|
||||||
|
const handleOpenUserMenu = (event: MouseEvent<HTMLElement>) => {
|
||||||
|
setAnchorElUser(event.currentTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCloseNavMenu = () => {
|
||||||
|
setAnchorElNav(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const handleCloseUserMenu =
|
||||||
|
(name = '') =>
|
||||||
|
() => {
|
||||||
|
if (name === EXIT_NAME) {
|
||||||
|
navigate('/logout')
|
||||||
|
}
|
||||||
|
setAnchorElUser(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBar position="sticky">
|
||||||
|
<Container maxWidth="lg">
|
||||||
|
<Toolbar disableGutters>
|
||||||
|
<PagesIcon sx={{ display: { xs: 'none', md: 'flex' }, mr: 1 }} />
|
||||||
|
<Box sx={{ display: { xs: 'flex', md: 'none' } }}>
|
||||||
|
<IconButton
|
||||||
|
size="large"
|
||||||
|
aria-label="account of current user"
|
||||||
|
aria-controls="menu-appbar"
|
||||||
|
aria-haspopup="true"
|
||||||
|
onClick={handleOpenNavMenu}
|
||||||
|
color="inherit"
|
||||||
|
>
|
||||||
|
<MenuIcon />
|
||||||
|
</IconButton>
|
||||||
|
<Menu
|
||||||
|
data-testid={HEADER_TESTID}
|
||||||
|
id="menu-appbar"
|
||||||
|
anchorEl={anchorElNav}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'left',
|
||||||
|
}}
|
||||||
|
keepMounted
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'left',
|
||||||
|
}}
|
||||||
|
open={Boolean(anchorElNav)}
|
||||||
|
onClose={handleCloseNavMenu}
|
||||||
|
sx={{
|
||||||
|
display: { xs: 'block', md: 'none' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pages.map(page => (
|
||||||
|
<MenuItem key={page.name} onClick={handleCloseNavMenu}>
|
||||||
|
<Badge
|
||||||
|
badgeContent={
|
||||||
|
page.name === CART_NAME ? quantitiesSum : null
|
||||||
|
}
|
||||||
|
color="warning"
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
component={Link}
|
||||||
|
to={page.to}
|
||||||
|
sx={{
|
||||||
|
color: 'inherit',
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
textAlign="center"
|
||||||
|
>
|
||||||
|
{page.name}
|
||||||
|
</Typography>
|
||||||
|
</Badge>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flexGrow: 1, display: { xs: 'none', md: 'flex' } }}>
|
||||||
|
{pages.map(page => (
|
||||||
|
<Badge
|
||||||
|
key={page.name}
|
||||||
|
badgeContent={page.name === CART_NAME ? quantitiesSum : null}
|
||||||
|
color="warning"
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
key={page.name}
|
||||||
|
onClick={handleCloseNavMenu}
|
||||||
|
sx={{
|
||||||
|
mr: 2,
|
||||||
|
color: 'white',
|
||||||
|
display: 'block',
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
component={Link}
|
||||||
|
to={page.to}
|
||||||
|
>
|
||||||
|
{page.name}
|
||||||
|
</Typography>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flexGrow: 0 }}>
|
||||||
|
<Tooltip title="Open settings">
|
||||||
|
<IconButton onClick={handleOpenUserMenu} sx={{ p: 0 }}>
|
||||||
|
<Avatar alt={currentUser?.name} src={currentUser?.avatarPath} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Menu
|
||||||
|
sx={{ mt: '45px' }}
|
||||||
|
id="menu-appbar"
|
||||||
|
anchorEl={anchorElUser}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'right',
|
||||||
|
}}
|
||||||
|
keepMounted
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'right',
|
||||||
|
}}
|
||||||
|
open={Boolean(anchorElUser)}
|
||||||
|
onClose={handleCloseUserMenu()}
|
||||||
|
>
|
||||||
|
{settings.map(setting => (
|
||||||
|
<MenuItem
|
||||||
|
key={setting.name}
|
||||||
|
onClick={handleCloseUserMenu(setting.name)}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
sx={{ textDecoration: 'none' }}
|
||||||
|
component={Link}
|
||||||
|
color="inherit"
|
||||||
|
to={setting.to}
|
||||||
|
textAlign="center"
|
||||||
|
>
|
||||||
|
{setting.name}
|
||||||
|
</Typography>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</Box>
|
||||||
|
</Toolbar>
|
||||||
|
</Container>
|
||||||
|
</AppBar>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
export * from './layout'
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
import { Box, CssBaseline, ThemeProvider } from '@mui/material'
|
||||||
|
import { theme } from '../theme'
|
||||||
|
import 'react-toastify/dist/ReactToastify.css'
|
||||||
|
import { useEffect, type JSX } from 'react'
|
||||||
|
import { Header } from './header'
|
||||||
|
import { Footer } from './footer'
|
||||||
|
import { Body } from './body'
|
||||||
|
import { fetchProducts, selectShopLoading, useAppDispatch, useAppSelector } from '../storage'
|
||||||
|
|
||||||
|
export const Layout = (): JSX.Element => {
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
|
const loading = useAppSelector(selectShopLoading)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchProducts())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
minHeight: '100vh',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Header />
|
||||||
|
<Body loading={loading} />
|
||||||
|
<Footer />
|
||||||
|
</Box>
|
||||||
|
</ThemeProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1 +1,8 @@
|
|||||||
export * from './shop'
|
export * from './shop'
|
||||||
|
export * from './not-found'
|
||||||
|
export * from './login'
|
||||||
|
export * from './logout'
|
||||||
|
export * from './not-found'
|
||||||
|
export * from './product'
|
||||||
|
export * from './register'
|
||||||
|
export * from './user'
|
||||||
|
|||||||
@ -0,0 +1,18 @@
|
|||||||
|
import { useEffect, type JSX } from 'react'
|
||||||
|
import { useAppDispatch } from '../storage'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
export const LogoutPage = (): JSX.Element | null => {
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// dispatch(userAction.clearUser())
|
||||||
|
// dispatch(authAction.clearToken())
|
||||||
|
// dispatch(productsAction.clearProducts())
|
||||||
|
// dispatch(singleProductAction.clearProduct())
|
||||||
|
navigate('/')
|
||||||
|
}, [dispatch, navigate])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
import { Container } from '@mui/material'
|
||||||
|
import { BackButton, ProductDetail } from '../components'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
|
||||||
|
export const ProductPage = () => {
|
||||||
|
const { id: productId = '' } = useParams<string>()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<BackButton title="Детали продукта" />
|
||||||
|
<ProductDetail productId={Number(productId)} />
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import { Container } from '@mui/material'
|
||||||
|
import { useAppSelector } from '../storage'
|
||||||
|
import { selectProducts } from '../storage/shop-slice'
|
||||||
|
import { ProductList } from '../components'
|
||||||
|
|
||||||
|
export const ShopPage = memo(() => {
|
||||||
|
const products = useAppSelector(selectProducts)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container disableGutters component="main">
|
||||||
|
<ProductList products={products} />
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
})
|
||||||
@ -1,26 +0,0 @@
|
|||||||
import { memo, useCallback, useEffect } from 'react'
|
|
||||||
import { Box, Button, Typography } from '@mui/material'
|
|
||||||
import { useAppDispatch, useAppSelector } from '../../hooks'
|
|
||||||
import { addToBasket, fetchProducts, selectProducts } from './shop-slice'
|
|
||||||
|
|
||||||
export const Shop = memo(() => {
|
|
||||||
const products = useAppSelector(selectProducts)
|
|
||||||
const dispatch = useAppDispatch()
|
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
|
||||||
dispatch(addToBasket({ id: 2, count: 3 }))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
dispatch(fetchProducts())
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Button variant="contained" onClick={handleClick}>
|
|
||||||
{'Добавить в корзину'}
|
|
||||||
</Button>
|
|
||||||
<Typography variant="body1">{JSON.stringify(products)}</Typography>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@ -1,6 +1,5 @@
|
|||||||
import { useDispatch, useSelector } from 'react-redux'
|
import { useDispatch, useSelector } from 'react-redux'
|
||||||
import type { AppDispatch, RootState } from './store'
|
import type { AppDispatch, RootState } from '../store'
|
||||||
|
|
||||||
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
|
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
|
||||||
|
|
||||||
export const useAppSelector = useSelector.withTypes<RootState>()
|
export const useAppSelector = useSelector.withTypes<RootState>()
|
||||||
@ -1,2 +1,2 @@
|
|||||||
export * from './shop'
|
|
||||||
export * from './shop-slice'
|
export * from './shop-slice'
|
||||||
|
export * from './hooks'
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { createTheme } from '@mui/material'
|
import { createTheme, useTheme } from '@mui/material'
|
||||||
// import { lime, purple } from '@mui/material/colors'
|
// import { lime, purple } from '@mui/material/colors'
|
||||||
|
|
||||||
export const theme = createTheme({})
|
export const theme = createTheme({})
|
||||||
|
|||||||
@ -0,0 +1,5 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export type CleanComponentType<T, P> = React.MemoExoticComponent<
|
||||||
|
React.ForwardRefExoticComponent<P & React.RefAttributes<T>>
|
||||||
|
>
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
export type ErrorType = {
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
@ -1 +1,3 @@
|
|||||||
export * from './product'
|
export * from './product'
|
||||||
|
export * from './error'
|
||||||
|
export * from './clean-component'
|
||||||
|
|||||||
Loading…
Reference in New Issue