main
Stepan Pilipenko 1 month ago
parent 84da23519c
commit 39fcf39d71

@ -74,6 +74,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@ -389,6 +390,7 @@
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.18.3", "@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5", "@emotion/babel-plugin": "^11.13.5",
@ -432,6 +434,7 @@
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.18.3", "@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5", "@emotion/babel-plugin": "^11.13.5",
@ -1262,6 +1265,7 @@
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.4.tgz", "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.4.tgz",
"integrity": "sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw==", "integrity": "sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4", "@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.4", "@mui/core-downloads-tracker": "^7.3.4",
@ -1920,6 +1924,7 @@
"integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==", "integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
@ -1941,6 +1946,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
@ -2016,6 +2022,7 @@
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2", "@typescript-eslint/types": "8.46.2",
@ -2268,6 +2275,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -2430,6 +2438,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.19", "baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751", "caniuse-lite": "^1.0.30001751",
@ -2779,6 +2788,7 @@
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@ -3957,6 +3967,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -3966,6 +3977,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@ -3978,6 +3990,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==", "integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
@ -4000,6 +4013,7 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/use-sync-external-store": "^0.0.6", "@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0" "use-sync-external-store": "^1.4.0"
@ -4099,7 +4113,8 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/redux-thunk": { "node_modules/redux-thunk": {
"version": "3.1.0", "version": "3.1.0",
@ -4487,6 +4502,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -4557,6 +4573,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -4652,6 +4669,7 @@
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@ -4745,6 +4763,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },

@ -17,7 +17,7 @@ import {
UserPage, UserPage,
} from './pages' } from './pages'
export const routers = createRoutesFromElements( const routers = createRoutesFromElements(
<Route path="/" id="root" element={<Layout />}> <Route path="/" id="root" element={<Layout />}>
<Route index element={<ShopPage />} /> <Route index element={<ShopPage />} />
<Route <Route
@ -27,7 +27,7 @@ export const routers = createRoutesFromElements(
errorElement={<NotFoundPage />} errorElement={<NotFoundPage />}
/> />
<Route path="/user" element={<UserPage />} /> <Route path="/user" element={<UserPage />} />
<Route path="/registr" element={<RegisterPage />} /> <Route path="/register" element={<RegisterPage />} />
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/logout" element={<LogoutPage />} /> <Route path="/logout" element={<LogoutPage />} />
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />

@ -1,17 +1,7 @@
import { import { Avatar, Card, CardContent, CardHeader, CardMedia, Grid, Typography } from '@mui/material'
Avatar,
Card,
CardActions,
CardContent,
CardHeader,
CardMedia,
Grid,
Typography,
} from '@mui/material'
import { type SyntheticEvent, useCallback } from 'react' import { type SyntheticEvent } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { CartButton } from './cart-button'
import { useAppSelector, shop } from '../storage' import { useAppSelector, shop } from '../storage'
export type ProductCardProps = { export type ProductCardProps = {
@ -20,27 +10,15 @@ export type ProductCardProps = {
export const ProductCard = ({ productId }: ProductCardProps) => { export const ProductCard = ({ productId }: ProductCardProps) => {
const product = useAppSelector(shop.selector.product(productId)) const product = useAppSelector(shop.selector.product(productId))
const { id, name, count, reserved, picture_url, description, type, cost } = product || { const { id, name, count, picture_url, description, cost } = product || {
id: productId, id: productId,
name: '', name: '',
count: 0, count: 0,
reserved: 0,
picture_url: '', picture_url: '',
description: '', description: '',
type: 'unknown',
cost: '', cost: '',
} }
console.debug({reserved, type})
//const dispatch = useAppDispatch()
const quantity = 5
const handleCart = useCallback((num: number) => {
console.debug({num})
}, [])
return ( return (
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 4 }}> <Grid size={{ xs: 12, sm: 6, md: 4, lg: 4 }}>
<Card elevation={3}> <Card elevation={3}>
@ -76,12 +54,6 @@ export const ProductCard = ({ productId }: ProductCardProps) => {
{`Доступно: ${count} шт.`} {`Доступно: ${count} шт.`}
</Typography> </Typography>
</CardContent> </CardContent>
<CardActions
sx={{ mt: 'auto', display: 'flex', justifyContent: 'space-between' }}
disableSpacing
>
<CartButton count={quantity} onClick={handleCart} />
</CardActions>
</Card> </Card>
</Grid> </Grid>
) )

@ -11,30 +11,26 @@ import {
import { useCallback } from 'react' import { useCallback } from 'react'
import { CartButton } from './cart-button' import { CartButton } from './cart-button'
import { useAppSelector, shop } from '../storage' import { useAppSelector, shop } from '../storage'
import { withProtection } from '../hocs'
export type ProductDetailProps = { export type ProductDetailProps = {
productId: number productId: number
} }
export const ProductDetail = ({ productId }: ProductDetailProps) => { export const ProductDetail = withProtection(({ productId }: ProductDetailProps) => {
const product = useAppSelector(shop.selector.product(productId)) const product = useAppSelector(shop.selector.product(productId))
const { id, name, count, reserved, picture_url, description, type, cost } = product || { const { name, count, picture_url, description, cost } = product || {
id: productId,
name: '', name: '',
count: 0, count: 0,
reserved: 0,
picture_url: '', picture_url: '',
description: '', description: '',
type: 'unknown',
cost: '', cost: '',
} }
console.debug({id, reserved, type})
const quantity = 5 const quantity = 5
const handleCart = useCallback((num: number) => { const handleCart = useCallback((num: number) => {
console.debug({num}) console.debug({ num })
}, []) }, [])
return ( return (
@ -57,7 +53,7 @@ export const ProductDetail = ({ productId }: ProductDetailProps) => {
</Avatar> </Avatar>
} }
title={name} title={name}
subheader={'subheader'} subheader={'2025 год'}
/> />
<CardMedia <CardMedia
component="img" component="img"
@ -86,4 +82,4 @@ export const ProductDetail = ({ productId }: ProductDetailProps) => {
</Card> </Card>
</Grid> </Grid>
) )
} })

@ -1 +1,2 @@
export * from './with-protection' export * from './with-protection'
export * from './with-response'

@ -1,10 +1,11 @@
import { type ComponentType, type FC } from 'react' import { type ComponentType, type FC } from 'react'
import { Navigate, useLocation } from 'react-router-dom' import { Navigate, useLocation } from 'react-router-dom'
import { auth, useAppSelector } from '../storage'
export const withProtection = <P extends object>(WrappedComponent: ComponentType<P>) => { export const withProtection = <P extends object>(WrappedComponent: ComponentType<P>) => {
const ReturnedComponent: FC<P> = props => { const ReturnedComponent: FC<P> = props => {
// Достаем accessToken из redux'a // Достаем accessToken из redux'a
const accessToken = '23123123' //useAppSelector(authSelector.accessTokenSelector) const accessToken = useAppSelector(auth.selector.accessToken)
// Объект location на понадобиться для задания состояния при redirect'e // Объект location на понадобиться для задания состояния при redirect'e
const location = useLocation() const location = useLocation()

@ -0,0 +1,67 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useEffect, type ComponentType, type FC } from 'react'
import { Navigate, useLocation } from 'react-router-dom'
import { auth, useAppDispatch, useAppSelector } from '../storage'
import { toast } from 'react-toastify'
import { Box } from '@mui/material'
import { Spinner } from '../components'
type StorageType = {
selector: {
loading: any
error: any
}
}
export const withResponse = <P extends object, T extends StorageType>(
WrappedComponent: ComponentType<P>,
storage: T,
fecthFunc: () => any
) => {
const ReturnedComponent: FC<P> = props => {
const accessToken = useAppSelector(auth.selector.accessToken)
const loading: boolean = useAppSelector(storage.selector.loading)
const error: string = useAppSelector(storage.selector.error)
const dispatch = useAppDispatch()
const location = useLocation()
useEffect(() => {
if (accessToken) {
dispatch(fecthFunc())
}
}, [accessToken])
// Если ошибка, то нужно отправить пользователя на странице входа в систему
if (error) {
toast.error(error || 'Не известная ошибка при аутентификации пользователя')
return (
<Navigate
to="/login"
// при этом мы передаем состояние, в котором указываем, какую
// страницу хотел посетить пользователь. И если он в дальнейшем
// войдет в систему, то мы его автоматически перебросим на желаемую страницу
state={{
from: location.pathname,
}}
/>
)
}
if (loading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="100vh">
<Spinner />
</Box>
)
}
return <WrappedComponent {...props} />
}
// У каждого компонента должно быть имя. Это поможет нам, когда будем использовать
// dev tools'ы реакта
ReturnedComponent.displayName = `withResponse${WrappedComponent.displayName}`
return ReturnedComponent
}

@ -12,38 +12,24 @@ import MenuItem from '@mui/material/MenuItem'
import PagesIcon from '@mui/icons-material/Pages' import PagesIcon from '@mui/icons-material/Pages'
import { type JSX, type MouseEvent, useState } from 'react' import { type JSX, type MouseEvent, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { Badge } from '@mui/material' import { useAppSelector, user } from '../storage'
const EXIT_NAME = 'Выйти' const pages = [
const CART_NAME = 'Корзина'
export const HEADER_TESTID = 'HEADER_TESTID'
export const pages = [
{ name: 'Продукты', to: '/' }, { name: 'Продукты', to: '/' },
{ name: CART_NAME, to: '/basket' }, { name: 'Корзина', to: '/basket' },
]
export const settings = [
{ name: 'Профиль', to: '/user' },
{ name: EXIT_NAME, to: '/' },
] ]
export const Header = (): JSX.Element => { export const Header = (): JSX.Element => {
const [anchorElNav, setAnchorElNav] = useState<null | HTMLElement>(null) const [anchorElNav, setAnchorElNav] = useState<null | HTMLElement>(null)
const [anchorElUser, setAnchorElUser] = useState<null | HTMLElement>(null)
// TODO: брать из storage const userName = useAppSelector(user.selector.user)?.nickname || 'Профиль'
const quantitiesSum = 3 const userAvatar = ''
const currentUser = {
name: 'Петя',
avatarPath: '',
}
const handleOpenNavMenu = (event: MouseEvent<HTMLElement>) => { const handleOpenNavMenu = (event: MouseEvent<HTMLElement>) => {
setAnchorElNav(event.currentTarget) setAnchorElNav(event.currentTarget)
} }
const handleOpenUserMenu = (event: MouseEvent<HTMLElement>) => { const handleOpenUser = () => {
setAnchorElUser(event.currentTarget) navigate('/user')
} }
const handleCloseNavMenu = () => { const handleCloseNavMenu = () => {
@ -52,15 +38,6 @@ export const Header = (): JSX.Element => {
const navigate = useNavigate() const navigate = useNavigate()
const handleCloseUserMenu =
(name = '') =>
() => {
if (name === EXIT_NAME) {
navigate('/logout')
}
setAnchorElUser(null)
}
return ( return (
<AppBar position="sticky"> <AppBar position="sticky">
<Container maxWidth="lg"> <Container maxWidth="lg">
@ -78,7 +55,6 @@ export const Header = (): JSX.Element => {
<MenuIcon /> <MenuIcon />
</IconButton> </IconButton>
<Menu <Menu
data-testid={HEADER_TESTID}
id="menu-appbar" id="menu-appbar"
anchorEl={anchorElNav} anchorEl={anchorElNav}
anchorOrigin={{ anchorOrigin={{
@ -98,93 +74,45 @@ export const Header = (): JSX.Element => {
> >
{pages.map(page => ( {pages.map(page => (
<MenuItem key={page.name} onClick={handleCloseNavMenu}> <MenuItem key={page.name} onClick={handleCloseNavMenu}>
<Badge <Typography
badgeContent={ component={Link}
page.name === CART_NAME ? quantitiesSum : null to={page.to}
} sx={{
color="warning" color: 'inherit',
sx={{ mr: 1 }} textDecoration: 'none',
}}
textAlign="center"
> >
<Typography {page.name}
component={Link} </Typography>
to={page.to}
sx={{
color: 'inherit',
textDecoration: 'none',
}}
textAlign="center"
>
{page.name}
</Typography>
</Badge>
</MenuItem> </MenuItem>
))} ))}
</Menu> </Menu>
</Box> </Box>
<Box sx={{ flexGrow: 1, display: { xs: 'none', md: 'flex' } }}> <Box sx={{ flexGrow: 1, display: { xs: 'none', md: 'flex' } }}>
{pages.map(page => ( {pages.map(page => (
<Badge <Typography
key={page.name} key={page.name}
badgeContent={page.name === CART_NAME ? quantitiesSum : null} onClick={handleCloseNavMenu}
color="warning" sx={{
sx={{ mr: 1 }} mr: 2,
color: 'white',
display: 'block',
textDecoration: 'none',
}}
component={Link}
to={page.to}
> >
<Typography {page.name}
key={page.name} </Typography>
onClick={handleCloseNavMenu}
sx={{
mr: 2,
color: 'white',
display: 'block',
textDecoration: 'none',
}}
component={Link}
to={page.to}
>
{page.name}
</Typography>
</Badge>
))} ))}
</Box> </Box>
<Box sx={{ flexGrow: 0 }}> <Box sx={{ flexGrow: 0 }}>
<Tooltip title="Open settings"> <Tooltip title={userName}>
<IconButton onClick={handleOpenUserMenu} sx={{ p: 0 }}> <IconButton onClick={handleOpenUser} sx={{ p: 0 }}>
<Avatar alt={currentUser?.name} src={currentUser?.avatarPath} /> <Avatar alt={userName} src={userAvatar} />
</IconButton> </IconButton>
</Tooltip> </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> </Box>
</Toolbar> </Toolbar>
</Container> </Container>

@ -14,7 +14,7 @@ export const Layout = (): JSX.Element => {
useEffect(() => { useEffect(() => {
dispatch(shop.fetch.products()) dispatch(shop.fetch.products())
}, []) }, [dispatch])
return ( return (
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>

@ -1,3 +1,5 @@
import { token } from './utils'
const DEV_URL = '/dev_api' const DEV_URL = '/dev_api'
const PROD_URL = 'https://shop.softwarrior.ru:8091' const PROD_URL = 'https://shop.softwarrior.ru:8091'
@ -15,15 +17,12 @@ const DEFAULT_HEADERS: HeaderType = {
} }
export interface NetworkApi { export interface NetworkApi {
get token(): string
set token(value: string)
request: <T>(endpoint: string, options?: RequestInit) => Promise<T> request: <T>(endpoint: string, options?: RequestInit) => Promise<T>
} }
class Network implements NetworkApi { class Network implements NetworkApi {
private _baseUrl: string private _baseUrl: string
private _headers: HeaderType private _headers: HeaderType
private _token: string
constructor() { constructor() {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
@ -32,31 +31,25 @@ class Network implements NetworkApi {
this._baseUrl = PROD_URL this._baseUrl = PROD_URL
} }
this._headers = DEFAULT_HEADERS this._headers = DEFAULT_HEADERS
this._token = ''
} }
private getStringURL(endpoint: string): string { private getStringURL(endpoint: string): string {
return `${this._baseUrl}/${endpoint.replace(/^\//, '')}` return `${this._baseUrl}/${endpoint.replace(/^\//, '')}`
} }
get token(): string { private async onResponse<T>(res: Response): Promise<T> {
return this._token return res.ok ? res.json() : res.json().then(data => Promise.reject(data))
} }
set token(value: string) { async request<T>(endpoint: string, options?: RequestInit) {
if (value) { const tokenStr = token.load()
this._headers.authorization = `Bearer ${value}`
if (tokenStr) {
this._headers.authorization = `Bearer ${tokenStr}`
} else { } else {
this._headers.authorization = undefined this._headers.authorization = undefined
} }
this._token = value
}
private 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), { const res = await fetch(this.getStringURL(endpoint), {
method: 'GET', method: 'GET',
...options, ...options,

@ -4,7 +4,7 @@ import KeyIcon from '@mui/icons-material/Key'
import { Controller, type SubmitHandler, useForm } from 'react-hook-form' import { Controller, type SubmitHandler, useForm } from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup' import { yupResolver } from '@hookform/resolvers/yup'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { Link as RouterLink, useLocation, useNavigate } from 'react-router-dom' import { Link as RouterLink, useNavigate } from 'react-router-dom'
import * as yup from 'yup' import * as yup from 'yup'
import { auth, useAppDispatch, useAppSelector } from '../storage' import { auth, useAppDispatch, useAppSelector } from '../storage'
import type { ErrorType } from '../types' import type { ErrorType } from '../types'
@ -14,29 +14,28 @@ export interface LoginFormValues {
password: string password: string
} }
export const loginFormSchema = yup.object({ const loginFormSchema = yup.object({
email: yup.string().email().required(), email: yup.string().email().required(),
password: yup.string().min(6).max(24).required(), password: yup.string().min(6).max(24).required(),
}) })
export const LoginPage = (): JSX.Element => { export const LoginPage = (): JSX.Element => {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const token = useAppSelector(auth.selector.accessToken) const token = useAppSelector(auth.selector.accessToken)
const error = useAppSelector(auth.selector.error) const error = useAppSelector(auth.selector.error)
const { state } = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
useEffect(() => { useEffect(() => {
if (token && !error) { if (token && !error) {
toast.success('Вы успешно вошли в систему!') toast.success('Вы успешно вошли в систему!')
// переходит туда откуда выпали на логин navigate('/')
navigate(state.from)
} }
if (error) { if (error) {
toast.error(error) toast.error(error)
} }
}, [token, error]) }, [token, error, navigate])
// инициализируем react-hook-form // инициализируем react-hook-form
const { const {
@ -47,8 +46,8 @@ export const LoginPage = (): JSX.Element => {
// с помощью generic подсказываем react-hook-form, какие поля содержит наша форма // с помощью generic подсказываем react-hook-form, какие поля содержит наша форма
} = useForm<LoginFormValues>({ } = useForm<LoginFormValues>({
defaultValues: { defaultValues: {
email: '', email: 'stepan.pilipenko@xmail.ru',
password: '', password: 'password_123',
}, },
// react-hook-form умеет работать со многими библиотеками // react-hook-form умеет работать со многими библиотеками
// валидации, мы используем yup // валидации, мы используем yup

@ -1,12 +1,13 @@
import type { JSX } from 'react' import { useEffect, type JSX } from 'react'
import { Avatar, Box, Container, TextField, Typography, Button, Link } from '@mui/material' import { Avatar, Box, Container, TextField, Typography, Button, Link } from '@mui/material'
import KeyIcon from '@mui/icons-material/Key' import LockOutlinedIcon from '@mui/icons-material/LockOutlined'
import { Controller, type SubmitHandler, useForm } from 'react-hook-form' import { Controller, type SubmitHandler, useForm } from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup' import { yupResolver } from '@hookform/resolvers/yup'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { Link as RouterLink, useLocation, useNavigate } from 'react-router-dom' import { Link as RouterLink, useLocation, useNavigate } from 'react-router-dom'
import * as yup from 'yup' import * as yup from 'yup'
import type { ErrorType } from '../types' import type { ErrorType } from '../types'
import { auth, useAppDispatch, useAppSelector } from '../storage'
export interface RegistrFormValues { export interface RegistrFormValues {
email: string email: string
@ -14,16 +15,33 @@ export interface RegistrFormValues {
password: string password: string
} }
export const registrFormSchema = yup.object({ const registrFormSchema = yup.object({
email: yup.string().email().required(), email: yup.string().email().required(),
nickname: yup.string().required().strict(), nickname: yup.string().required().strict(),
password: yup.string().min(6).max(24).required(), password: yup.string().min(6).max(24).required(),
}) })
export const RegisterPage = (): JSX.Element => { export const RegisterPage = (): JSX.Element => {
const dispatch = useAppDispatch()
const token = useAppSelector(auth.selector.accessToken)
const error = useAppSelector(auth.selector.error)
const { state } = useLocation() const { state } = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
useEffect(() => {
if (token && !error) {
toast.success('Вы успешно зарегистрировались!')
// переходит туда откуда выпали на логин
if (state?.from) {
navigate(state?.from)
}
}
if (error) {
toast.error(error)
}
}, [token, error, navigate, state])
// инициализируем react-hook-form // инициализируем react-hook-form
const { const {
// control понадобиться, чтобы подружить react-hook-form и компоненты из MUI // control понадобиться, чтобы подружить react-hook-form и компоненты из MUI
@ -44,11 +62,13 @@ export const RegisterPage = (): JSX.Element => {
const submitHandler: SubmitHandler<RegistrFormValues> = async values => { const submitHandler: SubmitHandler<RegistrFormValues> = async values => {
try { try {
console.debug({values}) dispatch(
// TODO: логинемся и запрашиваем все данные auth.fetch.register({
toast.success('Вы успешно вошли в систему!') email: values.email,
// переходит туда откуда выпали на логин nickname: values.nickname,
navigate(state.from) password: values.password,
})
)
} catch (error: unknown) { } catch (error: unknown) {
// Если произошла ошибка, то выводим уведомление // Если произошла ошибка, то выводим уведомление
const errorText = (error as ErrorType).error const errorText = (error as ErrorType).error
@ -67,10 +87,10 @@ export const RegisterPage = (): JSX.Element => {
}} }}
> >
<Avatar sx={{ m: 1, bgcolor: 'info.main' }}> <Avatar sx={{ m: 1, bgcolor: 'info.main' }}>
<KeyIcon /> <LockOutlinedIcon />
</Avatar> </Avatar>
<Typography component="h1" variant="h5"> <Typography component="h1" variant="h5">
Авторизация Регистрация
</Typography> </Typography>
<Box <Box
component="form" component="form"
@ -135,7 +155,7 @@ export const RegisterPage = (): JSX.Element => {
fullWidth fullWidth
sx={{ mt: 3, mb: 2 }} sx={{ mt: 3, mb: 2 }}
> >
Авторизация Регистрация
</Button> </Button>
<Link component={RouterLink} to="/login"> <Link component={RouterLink} to="/login">
Логин Логин

@ -1,35 +1,39 @@
import { Avatar, Button, Grid, Stack } from '@mui/material' import { Avatar, Button, Grid, Stack } from '@mui/material'
import { Container } from '@mui/system' import { Container } from '@mui/system'
import { withProtection } from '../hocs' import { withProtection, withResponse } from '../hocs'
import { BackButton } from '../components' import { BackButton } from '../components'
import { auth, useAppDispatch, useAppSelector, user } from '../storage'
import { useCallback } from 'react'
const UserWithProtection = withProtection(() => {
const userData = useAppSelector(user.selector.user)
const userAvatar = ''
const dispatch = useAppDispatch()
const handleClick = useCallback(() => {
dispatch(auth.fetch.logout())
}, [])
export const UserPage = withProtection(() => {
const currentUser = {
name: 'Петя',
avatarPath: '',
about: '',
}
return ( return (
<Container maxWidth="lg"> <Container maxWidth="lg">
<BackButton title="Профиль" text="< Главная" path="/" /> <BackButton title="Профиль" text="< Главная" path="/" />
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid size={{ xs: 12, md: 4 }}> <Grid size={{ xs: 12, md: 4 }}>
<Avatar <Avatar
alt={currentUser?.name} alt={userData?.nickname}
src={ src={userAvatar ? userAvatar : '/static/images/avatar/1.jpg'}
currentUser?.avatarPath
? currentUser.avatarPath
: '/static/images/avatar/1.jpg'
}
sx={{ width: 150, height: 150 }} sx={{ width: 150, height: 150 }}
/> />
<p>{currentUser?.name}</p> <p>{userData?.nickname}</p>
<p>{currentUser?.about}</p> <p>{userData?.email}</p>
<Stack> <Stack>
<Button variant="outlined">Редактировать профиль</Button> <Button variant="outlined" onClick={handleClick}>
<Button variant="outlined" color="info"> Выйти
Изменить аватар </Button>
<Button variant="outlined" color="error">
Удалить аккаунт
</Button> </Button>
</Stack> </Stack>
</Grid> </Grid>
@ -37,3 +41,5 @@ export const UserPage = withProtection(() => {
</Container> </Container>
) )
}) })
export const UserPage = withResponse(UserWithProtection, user, user.fetch.user)

@ -1,7 +1,8 @@
import { createSlice } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit'
import type { PayloadAction } from '@reduxjs/toolkit' import type { PayloadAction } from '@reduxjs/toolkit'
import { createAppAsyncThunk } from './hooks' import { createAppAsyncThunk } from './hooks'
import { networkApi, type ResponseData } from '../network' import { type ResponseData } from '../network'
import { token } from '../utils'
interface AuthState { interface AuthState {
accessToken: string accessToken: string
@ -10,7 +11,7 @@ interface AuthState {
} }
const initialState: AuthState = { const initialState: AuthState = {
accessToken: '', accessToken: token.load(),
loading: false, loading: false,
error: undefined, error: undefined,
} }
@ -21,8 +22,10 @@ const authSlice = createSlice({
reducers: { reducers: {
setAccessToken(state, action: PayloadAction<AuthState>) { setAccessToken(state, action: PayloadAction<AuthState>) {
state.accessToken = action.payload.accessToken state.accessToken = action.payload.accessToken
token.save(state.accessToken)
}, },
clearToken() { clearToken() {
token.save('')
return initialState return initialState
}, },
}, },
@ -33,10 +36,10 @@ const authSlice = createSlice({
state.error = undefined state.error = undefined
}) })
.addCase(fetchLogin.fulfilled, (state, action) => { .addCase(fetchLogin.fulfilled, (state, action) => {
state.accessToken = String(action.payload.success?.[0]) || '' state.accessToken = String(action.payload.success) || ''
state.loading = false state.loading = false
state.error = undefined state.error = undefined
networkApi.token = state.accessToken token.save(state.accessToken)
}) })
.addCase(fetchLogin.rejected, (state, action) => { .addCase(fetchLogin.rejected, (state, action) => {
state.loading = false state.loading = false
@ -54,7 +57,7 @@ const authSlice = createSlice({
state.accessToken = '' state.accessToken = ''
state.loading = false state.loading = false
state.error = undefined state.error = undefined
networkApi.token = '' token.save('')
}) })
.addCase(fetchLogout.rejected, (state, action) => { .addCase(fetchLogout.rejected, (state, action) => {
state.loading = false state.loading = false
@ -64,6 +67,24 @@ const authSlice = createSlice({
state.error = action.error.message state.error = action.error.message
} }
}) })
.addCase(fetchRegister.pending, state => {
state.loading = true
state.error = undefined
})
.addCase(fetchRegister.fulfilled, (state, action) => {
state.accessToken = String(action.payload.success) || ''
state.loading = false
state.error = undefined
token.save(state.accessToken)
})
.addCase(fetchRegister.rejected, (state, action) => {
state.loading = false
if (action.payload) {
state.error = (action.payload as ResponseData).error
} else {
state.error = action.error.message
}
})
}, },
selectors: { selectors: {
accessToken: (state: AuthState) => state.accessToken, accessToken: (state: AuthState) => state.accessToken,
@ -72,12 +93,33 @@ const authSlice = createSlice({
}, },
}) })
type RegisterProps = {
email: string
nickname: string
password: string
}
const fetchRegister = createAppAsyncThunk<ResponseData, RegisterProps>(
`${authSlice.name}/fetchRegister`,
async (props, { fulfillWithValue, rejectWithValue, extra: networkApi }) => {
try {
const data = await networkApi.request<ResponseData>('/register/', {
method: 'POST',
body: JSON.stringify(props),
})
return fulfillWithValue(data)
} catch (error) {
return rejectWithValue(error)
}
}
)
type LoginProps = { type LoginProps = {
email: string email: string
password: string password: string
} }
export const fetchLogin = createAppAsyncThunk<ResponseData, LoginProps>( const fetchLogin = createAppAsyncThunk<ResponseData, LoginProps>(
`${authSlice.name}/fetchLogin`, `${authSlice.name}/fetchLogin`,
async (props, { fulfillWithValue, rejectWithValue, extra: networkApi }) => { async (props, { fulfillWithValue, rejectWithValue, extra: networkApi }) => {
try { try {
@ -92,7 +134,7 @@ export const fetchLogin = createAppAsyncThunk<ResponseData, LoginProps>(
} }
) )
export const fetchLogout = createAppAsyncThunk<ResponseData>( const fetchLogout = createAppAsyncThunk<ResponseData>(
`${authSlice.name}/fetchLogout`, `${authSlice.name}/fetchLogout`,
async (_, { fulfillWithValue, rejectWithValue, extra: networkApi }) => { async (_, { fulfillWithValue, rejectWithValue, extra: networkApi }) => {
try { try {
@ -109,7 +151,7 @@ class Auth {
action = authSlice.actions action = authSlice.actions
reducer = authSlice.reducer reducer = authSlice.reducer
name = authSlice.name name = authSlice.name
fetch = { login: fetchLogin, logout: fetchLogout } fetch = { login: fetchLogin, logout: fetchLogout, register: fetchRegister }
} }
export const auth = new Auth() export const auth = new Auth()

@ -64,7 +64,7 @@ const selectProduct = (productId: number) =>
return products.find(product => product.id === productId) return products.find(product => product.id === productId)
}) })
export const fetchProducts = createAppAsyncThunk<ResponseData>( const fetchProducts = createAppAsyncThunk<ResponseData>(
`${shopSlice.name}/fetchProducts`, `${shopSlice.name}/fetchProducts`,
async (_, { fulfillWithValue, rejectWithValue, extra: networkApi }) => { async (_, { fulfillWithValue, rejectWithValue, extra: networkApi }) => {
try { try {

@ -15,10 +15,8 @@ const initialState: UserState = {
error: undefined, error: undefined,
} }
export const userSliceName = 'user'
const userSlice = createSlice({ const userSlice = createSlice({
name: userSliceName, name: 'user',
initialState, initialState,
reducers: { reducers: {
setUser: (state, action: PayloadAction<UserType>) => { setUser: (state, action: PayloadAction<UserType>) => {
@ -54,11 +52,13 @@ const userSlice = createSlice({
}, },
}) })
export const fetchUser = createAppAsyncThunk<ResponseData>( const fetchUser = createAppAsyncThunk<ResponseData>(
`${userSliceName}/fetchUser`, `${userSlice.name}/fetchUser`,
async (_, { fulfillWithValue, rejectWithValue, extra: networkApi }) => { async (_, { fulfillWithValue, rejectWithValue, extra: networkApi }) => {
try { try {
const data = await networkApi.request<ResponseData>('/user/') const data = await networkApi.request<ResponseData>('/user/', {
method: 'POST',
})
return fulfillWithValue(data) return fulfillWithValue(data)
} catch (error) { } catch (error) {
return rejectWithValue(error) return rejectWithValue(error)

@ -0,0 +1 @@
export * from './token'

@ -0,0 +1,16 @@
const TOKEN_KEY = 'accessToken'
class Token {
save = (token: string) => {
if (token) {
localStorage.setItem(TOKEN_KEY, token)
} else {
localStorage.removeItem(TOKEN_KEY)
}
}
load = (): string => {
return localStorage.getItem(TOKEN_KEY) || ''
}
}
export const token = new Token()
Loading…
Cancel
Save