first front + redux

main
Stepan Pilipenko 1 month ago
parent ebe207ec83
commit f4fd3916b8

@ -11,13 +11,18 @@
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fontsource/roboto": "^5.2.8",
"@hookform/resolvers": "^5.2.2",
"@mui/icons-material": "^7.3.4",
"@mui/material": "^7.3.4",
"@reduxjs/toolkit": "^2.9.2",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-hook-form": "^7.66.0",
"react-redux": "^9.2.0",
"redux-thunk": "^3.1.0"
"react-router-dom": "^7.9.5",
"react-toastify": "^11.0.5",
"redux-thunk": "^3.1.0",
"yup": "^1.7.1"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
@ -1109,6 +1114,18 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@hookform/resolvers": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
"integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==",
"license": "MIT",
"dependencies": {
"@standard-schema/utils": "^0.3.0"
},
"peerDependencies": {
"react-hook-form": "^7.55.0"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@ -2576,6 +2593,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/cosmiconfig": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
@ -3899,6 +3925,12 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/property-expr": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz",
"integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -3953,6 +3985,23 @@
"react": "^19.2.0"
}
},
"node_modules/react-hook-form": {
"version": "7.66.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-is": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz",
@ -3993,6 +4042,57 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.9.5",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.5.tgz",
"integrity": "sha512-JmxqrnBZ6E9hWmf02jzNn9Jm3UqyeimyiwzD69NjxGySG6lIz/1LVPsoTCwN7NBX2XjCEa1LIX5EMz1j2b6u6A==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.9.5",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.5.tgz",
"integrity": "sha512-mkEmq/K8tKN63Ae2M7Xgz3c9l9YNbY+NHH6NNeUmLA3kDkhKXRsNb/ZpxaEunvGo2/3YXdk5EJU3Hxp3ocaBPw==",
"license": "MIT",
"dependencies": {
"react-router": "7.9.5"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-toastify": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.0.5.tgz",
"integrity": "sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1"
},
"peerDependencies": {
"react": "^18 || ^19",
"react-dom": "^18 || ^19"
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
@ -4177,6 +4277,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -4349,6 +4455,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tiny-case": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
"integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@ -4411,6 +4523,12 @@
"node": ">=8.0"
}
},
"node_modules/toposort": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
"integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==",
"license": "MIT"
},
"node_modules/ts-api-utils": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
@ -4437,6 +4555,18 @@
"node": ">= 0.8.0"
}
},
"node_modules/type-fest": {
"version": "2.19.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
"integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=12.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@ -4748,6 +4878,18 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yup": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz",
"integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==",
"license": "MIT",
"dependencies": {
"property-expr": "^2.0.5",
"tiny-case": "^1.0.3",
"toposort": "^2.0.2",
"type-fest": "^2.19.0"
}
}
}
}

@ -15,13 +15,18 @@
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fontsource/roboto": "^5.2.8",
"@hookform/resolvers": "^5.2.2",
"@mui/icons-material": "^7.3.4",
"@mui/material": "^7.3.4",
"@reduxjs/toolkit": "^2.9.2",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-hook-form": "^7.66.0",
"react-redux": "^9.2.0",
"redux-thunk": "^3.1.0"
"react-router-dom": "^7.9.5",
"react-toastify": "^11.0.5",
"redux-thunk": "^3.1.0",
"yup": "^1.7.1"
},
"devDependencies": {
"@eslint/js": "^9.36.0",

@ -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 { 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 = () => {
return (
<>
<CssBaseline />
<ThemeProvider theme={theme}>
<Provider store={store}>
<Container maxWidth="md" sx={{ bgcolor: blue }}>
<Shop />
</Container>
<RouterProvider router={router} />
</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,51 @@
import { forwardRef, memo, type HTMLAttributes } from 'react'
import { Badge, Button, Stack } from '@mui/material'
import type { CleanComponentType } from '../types'
export type CartButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, 'onClick'> & {
text?: string
count?: number
onClick?: (num: number) => void
}
export type CartButtonType = CleanComponentType<HTMLButtonElement, CartButtonProps>
export const CartButton = memo(
forwardRef<HTMLButtonElement>(
({ text = 'В корзину', count, onClick }: CartButtonProps, ref) => {
return (
<Badge badgeContent={count} color="warning" sx={{ mr: 1 }}>
{!count ? (
<Button
ref={ref}
color="success"
variant="contained"
onClick={() => onClick?.(1)}
>
{text}
</Button>
) : (
<Stack direction="row" alignItems="center" spacing={0.2} sx={{ pb: 2 }}>
<Button
ref={ref}
color="success"
variant="contained"
onClick={() => onClick?.(1)}
>
+
</Button>
<Button
ref={ref}
color="success"
variant="contained"
onClick={() => onClick?.(-1)}
>
-
</Button>
</Stack>
)}
</Badge>
)
}
)
) as CartButtonType

@ -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,34 @@
import { type ComponentType, type FC } from 'react'
import { Navigate, useLocation } from 'react-router-dom'
export const withProtection = <P extends object>(WrappedComponent: ComponentType<P>) => {
const ReturnedComponent: FC<P> = props => {
// Достаем accessToken из redux'a
const accessToken = '23123123' //useAppSelector(authSelector.accessTokenSelector)
// Объект location на понадобиться для задания состояния при redirect'e
const location = useLocation()
// Если токен пустой, то нужно отправить пользователя на странице входа в систему
if (!accessToken) {
return (
<Navigate
to="/login"
// при этом мы передаем состояние, в котором указываем, какую
// страницу хотел посетить пользователь. И если он в дальнейшем
// войдет в систему, то мы его автоматически перебросим на желаемую страницу
state={{
from: location.pathname,
}}
/>
)
}
return <WrappedComponent {...props} />
}
// У каждого компонента должно быть имя. Это поможет нам, когда будем использовать
// dev tools'ы реакта
ReturnedComponent.displayName = `withProtection${WrappedComponent.displayName}`
return ReturnedComponent
}

@ -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,28 @@
import { Box, Container, Typography } from '@mui/material'
import type { JSX } from 'react'
export const FOOTER_TESTID = 'HEADER_TESTID'
export const Footer = (): JSX.Element => {
return (
<Box
component="footer"
sx={{
py: 3,
px: 2,
mt: 'auto',
backgroundColor: theme =>
theme.palette.mode === 'light'
? theme.palette.grey[200]
: theme.palette.grey[800],
}}
>
<Container maxWidth="lg">
<Typography data-testid={FOOTER_TESTID} variant="body1">
Пилипенко Степан Андреевич
</Typography>
{new Date().getFullYear()}
</Container>
</Box>
)
}

@ -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,12 +1,29 @@
const DEV_URL = '/dev_api'
const PROD_URL = 'https://shop.softwarrior.ru:8090'
export type ResponseData = {
error?: string
success?: Array<object>
}
type HeaderType = HeadersInit & {
authorization: string
}
const DEFAULT_HEADERS: HeaderType = {
'content-type': 'application/json',
authorization: '',
}
export interface NetworkApi {
getStringURL: (suffix: string) => string
setAccessToken: (token: string) => void
onResponse: <T>(res: Response) => Promise<T>
request: <T>(endpoint: string, options?: RequestInit) => Promise<T>
}
class Network implements NetworkApi {
private baseUrl: string
private headers: HeaderType
constructor() {
if (import.meta.env.DEV) {
@ -14,11 +31,29 @@ class Network implements NetworkApi {
} else {
this.baseUrl = PROD_URL
}
this.headers = DEFAULT_HEADERS
}
getStringURL(suffix: string): string {
const urlString = `${this.baseUrl}/${suffix.replace(/^\//, '')}`
return urlString
private getStringURL(endpoint: string): string {
return `${this.baseUrl}${endpoint.replace(/^\//, '')}`
}
setAccessToken(token: string): void {
this.headers.authorization = token
}
async onResponse<T>(res: Response): Promise<T> {
return res.ok ? res.json() : res.json().then(data => Promise.reject(data))
}
async request<T>(endpoint: string, options?: RequestInit) {
const res = await fetch(this.getStringURL(endpoint), {
method: 'GET',
...options,
headers: { ...this.headers, ...options?.headers },
})
return await this.onResponse<T>(res)
}
}

@ -1 +1,8 @@
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,130 @@
import type { JSX } from 'react'
import { Avatar, Box, Container, TextField, Typography, Button, Link } from '@mui/material'
import KeyIcon from '@mui/icons-material/Key'
import { Controller, type SubmitHandler, useForm } from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup'
import { toast } from 'react-toastify'
import { Link as RouterLink, useLocation, useNavigate } from 'react-router-dom'
import * as yup from 'yup'
import { useAppDispatch } from '../storage'
import type { ErrorType } from '../types'
export interface LoginFormValues {
email: string
password: string
}
export const loginFormSchema = yup.object({
email: yup.string().email().required(),
password: yup.string().min(6).max(24).required(),
})
export const LoginPage = (): JSX.Element => {
const dispatch = useAppDispatch()
const { state } = useLocation()
const navigate = useNavigate()
// инициализируем react-hook-form
const {
// control понадобиться, чтобы подружить react-hook-form и компоненты из MUI
control,
handleSubmit,
formState: { errors, isValid, isSubmitted, isSubmitting },
// с помощью generic подсказываем react-hook-form, какие поля содержит наша форма
} = useForm<LoginFormValues>({
defaultValues: {
email: '',
password: '',
},
// react-hook-form умеет работать со многими библиотеками
// валидации, мы используем yup
resolver: yupResolver(loginFormSchema),
})
const submitHandler: SubmitHandler<LoginFormValues> = async values => {
try {
// TODO: логинемся и запрашиваем все данные
toast.success('Вы успешно вошли в систему!')
// переходит туда откуда выпали на логин
navigate(state.from)
} catch (error: unknown) {
// Если произошла ошибка, то выводим уведомление
const errorText = (error as ErrorType).error
toast.error(errorText || 'Не известная ошибка при аутентификации пользователя')
}
}
return (
<Container component="main" maxWidth="xs">
<Box
sx={{
marginTop: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Avatar sx={{ m: 1, bgcolor: 'info.main' }}>
<KeyIcon />
</Avatar>
<Typography component="h1" variant="h5">
Авторизация
</Typography>
<Box
component="form"
onSubmit={handleSubmit(submitHandler)}
noValidate
sx={{ mt: 1 }}
>
<Controller
name="email"
control={control}
render={({ field }) => (
<TextField
margin="normal"
label="Почта"
type="email"
fullWidth
required
autoComplete="email"
error={!!errors.email?.message}
helperText={errors.email?.message}
{...field}
/>
)}
/>
<Controller
name="password"
control={control}
render={({ field }) => (
<TextField
label="Пароль"
type="password"
error={!!errors.password?.message}
helperText={errors.password?.message}
margin="normal"
fullWidth
required
{...field}
/>
)}
/>
<Button
type="submit"
disabled={isSubmitting || (isSubmitted && (!isValid || isSubmitting))}
variant="contained"
fullWidth
sx={{ mt: 3, mb: 2 }}
>
Авторизация
</Button>
<Link component={RouterLink} to="/register">
Регистрация
</Link>
</Box>
</Box>
</Container>
)
}

@ -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,22 @@
import { Button, Stack, Typography } from '@mui/material'
import type { JSX } from 'react'
import { Link } from 'react-router-dom'
export const NotFoundPage = (): JSX.Element => {
return (
<Stack alignItems="center" height="100%" spacing={2}>
<Typography variant="h1" align="center">
404
</Typography>
<Typography variant="h3" align="center">
Страница не найдена
</Typography>
<Typography variant="caption" align="center">
Возможно, она была перемещена, или вы просто неверно указали адрес страницы.
</Typography>
<Button component={Link} to="/">
Перейти на главную
</Button>
</Stack>
)
}

@ -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,149 @@
import type { JSX } from 'react'
import { Avatar, Box, Container, TextField, Typography, Button, Link } from '@mui/material'
import KeyIcon from '@mui/icons-material/Key'
import { Controller, type SubmitHandler, useForm } from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup'
import { toast } from 'react-toastify'
import { Link as RouterLink, useLocation, useNavigate } from 'react-router-dom'
import * as yup from 'yup'
import { useAppDispatch } from '../storage'
import type { ErrorType } from '../types'
export interface RegistrFormValues {
email: string
nickname: string
password: string
}
export const registrFormSchema = yup.object({
email: yup.string().email().required(),
nickname: yup.string().required().strict(),
password: yup.string().min(6).max(24).required(),
})
export const RegisterPage = (): JSX.Element => {
const dispatch = useAppDispatch()
const { state } = useLocation()
const navigate = useNavigate()
// инициализируем react-hook-form
const {
// control понадобиться, чтобы подружить react-hook-form и компоненты из MUI
control,
handleSubmit,
formState: { errors, isValid, isSubmitted, isSubmitting },
// с помощью generic подсказываем react-hook-form, какие поля содержит наша форма
} = useForm<RegistrFormValues>({
defaultValues: {
email: '',
nickname: '',
password: '',
},
// react-hook-form умеет работать со многими библиотеками
// валидации, мы используем yup
resolver: yupResolver(registrFormSchema),
})
const submitHandler: SubmitHandler<RegistrFormValues> = async values => {
try {
// TODO: логинемся и запрашиваем все данные
toast.success('Вы успешно вошли в систему!')
// переходит туда откуда выпали на логин
navigate(state.from)
} catch (error: unknown) {
// Если произошла ошибка, то выводим уведомление
const errorText = (error as ErrorType).error
toast.error(errorText || 'Не известная ошибка при аутентификации пользователя')
}
}
return (
<Container component="main" maxWidth="xs">
<Box
sx={{
marginTop: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Avatar sx={{ m: 1, bgcolor: 'info.main' }}>
<KeyIcon />
</Avatar>
<Typography component="h1" variant="h5">
Авторизация
</Typography>
<Box
component="form"
onSubmit={handleSubmit(submitHandler)}
noValidate
sx={{ mt: 1 }}
>
<Controller
name="email"
control={control}
render={({ field }) => (
<TextField
margin="normal"
label="Почта"
type="email"
fullWidth
required
autoComplete="email"
error={!!errors.email?.message}
helperText={errors.email?.message}
{...field}
/>
)}
/>
<Controller
name="nickname"
control={control}
render={({ field }) => (
<TextField
margin="normal"
label="Ник"
type="text"
fullWidth
required
error={!!errors.nickname?.message}
helperText={errors.nickname?.message}
{...field}
/>
)}
/>
<Controller
name="password"
control={control}
render={({ field }) => (
<TextField
label="Пароль"
type="password"
error={!!errors.password?.message}
helperText={errors.password?.message}
margin="normal"
fullWidth
required
{...field}
/>
)}
/>
<Button
type="submit"
disabled={isSubmitting || (isSubmitted && (!isValid || isSubmitting))}
variant="contained"
fullWidth
sx={{ mt: 3, mb: 2 }}
>
Авторизация
</Button>
<Link component={RouterLink} to="/login">
Логин
</Link>
</Box>
</Box>
</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>
)
})

@ -0,0 +1,39 @@
import { Avatar, Button, Grid, Stack } from '@mui/material'
import { Container } from '@mui/system'
import { withProtection } from '../hocs'
import { BackButton } from '../components'
export const UserPage = withProtection(() => {
const currentUser = {
name: 'Петя',
avatarPath: '',
about: '',
}
return (
<Container maxWidth="lg">
<BackButton title="Профиль" text="< Главная" path="/" />
<Grid container spacing={2}>
<Grid size={{ xs: 12, md: 4 }}>
<Avatar
alt={currentUser?.name}
src={
currentUser?.avatarPath
? currentUser.avatarPath
: '/static/images/avatar/1.jpg'
}
sx={{ width: 150, height: 150 }}
/>
<p>{currentUser?.name}</p>
<p>{currentUser?.about}</p>
<Stack>
<Button variant="outlined">Редактировать профиль</Button>
<Button variant="outlined" color="info">
Изменить аватар
</Button>
</Stack>
</Grid>
</Grid>
</Container>
)
})

@ -1,6 +1,5 @@
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 useAppSelector = useSelector.withTypes<RootState>()

@ -1,2 +1,2 @@
export * from './shop'
export * from './shop-slice'
export * from './hooks'

@ -1,17 +1,13 @@
import { createAsyncThunk, createSlice, type PayloadAction } from '@reduxjs/toolkit'
import type { RootState, ThunkApi } from '../../store'
import type { Product } from '../../types'
import { createAsyncThunk, createSlice, type PayloadAction, createSelector } from '@reduxjs/toolkit'
import type { RootState, ThunkApi } from '../store'
import type { Product } from '../types'
import type { ResponseData } from '../network'
interface AddBasketAction {
id: number
count: number
}
type ResponseData = {
error?: string
success?: Array<object>
}
interface ShopState {
products: Product[]
loading: boolean
@ -57,32 +53,25 @@ export const shopSlice = createSlice({
export const { addToBasket } = shopSlice.actions
export const selectShopLoading = (state: RootState) => state.shop.loading
export const selectShopError = (state: RootState) => state.shop.error
export const selectProducts = (state: RootState) => state.shop.products
export const selectProduct = (productId: number) =>
createSelector([selectProducts], products => {
return products.find(product => product.id === productId)
})
export const shopReducer = shopSlice.reducer
export const fetchProducts = createAsyncThunk<ResponseData, void, ThunkApi>(
'products/fetchProducts',
async (_, { extra: networkApi }) => {
async (_, { fulfillWithValue, rejectWithValue, extra: networkApi }) => {
try {
const response = await fetch(networkApi.getStringURL('shop/'), {
headers: {
//Authorization: `Bearer ${localStorage.getItem('token')}`,
//'Content-Type': 'application/json',
},
mode: 'cors',
})
if (!response.ok) {
throw new Error('Server error!')
}
const responseData: ResponseData = await response.json()
return responseData
const data = await networkApi.request<ResponseData>('/shop/')
return fulfillWithValue(data)
} catch (error) {
return {
error: error instanceof Error ? error.message : 'Unknown error',
}
return rejectWithValue(error)
}
}
)

@ -1,6 +1,6 @@
import { configureStore } from '@reduxjs/toolkit'
import { shopReducer } from './pages'
import { networkApi, type NetworkApi } from './network'
import { shopReducer } from './storage'
export const store = configureStore({
reducer: {

@ -1,4 +1,4 @@
import { createTheme } from '@mui/material'
import { createTheme, useTheme } from '@mui/material'
// import { lime, purple } from '@mui/material/colors'
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 './error'
export * from './clean-component'

@ -5,6 +5,6 @@ export interface Product {
reserved: number
picture_url: string
description: string
type: 'skin' | 'avatar'
type: 'skin' | 'avatar' | 'unknown'
cost: string // Decimal сохраняем как string, но можно конвертировать в number
}

@ -22,5 +22,6 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
"include": ["vite.config.ts"],
"typeRoots": ["./node_modules/@types", "./src/types"]
}

Loading…
Cancel
Save