diff --git a/frontend/shop/package-lock.json b/frontend/shop/package-lock.json index 0601ffb..ffe7c7e 100644 --- a/frontend/shop/package-lock.json +++ b/frontend/shop/package-lock.json @@ -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" + } } } } diff --git a/frontend/shop/package.json b/frontend/shop/package.json index fa1db0d..ad287e4 100644 --- a/frontend/shop/package.json +++ b/frontend/shop/package.json @@ -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", diff --git a/frontend/shop/src/app.tsx b/frontend/shop/src/app.tsx index f8a3e2a..dfc47ea 100644 --- a/frontend/shop/src/app.tsx +++ b/frontend/shop/src/app.tsx @@ -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( + }> + } /> + } + errorElement={} + /> + } /> + } /> + } /> + } /> + } /> + +) + +const router = createBrowserRouter(routers) export const App = () => { return ( - <> - - - - - - - - - + + + ) } diff --git a/frontend/shop/src/components/back-button.tsx b/frontend/shop/src/components/back-button.tsx new file mode 100644 index 0000000..6f3b9d1 --- /dev/null +++ b/frontend/shop/src/components/back-button.tsx @@ -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 & { + text?: string + title?: string + path?: string +} + +export type BackButtonType = CleanComponentType + +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 ( + <> + + {text} + + {title && ( + + {title} + + )} + + ) + }) +) as BackButtonType diff --git a/frontend/shop/src/components/cart-button.tsx b/frontend/shop/src/components/cart-button.tsx new file mode 100644 index 0000000..96eaaec --- /dev/null +++ b/frontend/shop/src/components/cart-button.tsx @@ -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, 'onClick'> & { + text?: string + count?: number + onClick?: (num: number) => void +} + +export type CartButtonType = CleanComponentType + +export const CartButton = memo( + forwardRef( + ({ text = 'В корзину', count, onClick }: CartButtonProps, ref) => { + return ( + + {!count ? ( + + ) : ( + + + + + )} + + ) + } + ) +) as CartButtonType diff --git a/frontend/shop/src/components/index.ts b/frontend/shop/src/components/index.ts new file mode 100644 index 0000000..b43ccb3 --- /dev/null +++ b/frontend/shop/src/components/index.ts @@ -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' diff --git a/frontend/shop/src/components/load-more.tsx b/frontend/shop/src/components/load-more.tsx new file mode 100644 index 0000000..6da3375 --- /dev/null +++ b/frontend/shop/src/components/load-more.tsx @@ -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 = ({ action, isLoading, isEndOfList }) => { + const ref = useRef(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 ( + + {isLoading && } + {isEndOfList && End of list!} + + ) +} diff --git a/frontend/shop/src/components/product-card.tsx b/frontend/shop/src/components/product-card.tsx new file mode 100644 index 0000000..c477f8a --- /dev/null +++ b/frontend/shop/src/components/product-card.tsx @@ -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 ( + + + + {name[0]} + + } + title={name} + subheader={'subheader'} + /> + + ) => { + e.currentTarget.src = + 'https://react-learning.ru/image-compressed/default-image.jpg' + }} + image={picture_url} + alt={name} + /> + + + + {description} + + + {`Цена: ${cost} ₽`} + + + {`Доступно: ${count} шт.`} + + + + + + + + ) +} diff --git a/frontend/shop/src/components/product-detail.tsx b/frontend/shop/src/components/product-detail.tsx new file mode 100644 index 0000000..b60a153 --- /dev/null +++ b/frontend/shop/src/components/product-detail.tsx @@ -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 ( + + + + {name[0]} + + } + title={name} + subheader={'subheader'} + /> + + + + {description} + + + {`Цена: ${cost} ₽`} + + + {`Доступно: ${count} шт.`} + + + + + + + + ) +} diff --git a/frontend/shop/src/components/product-list.tsx b/frontend/shop/src/components/product-list.tsx new file mode 100644 index 0000000..02b01d7 --- /dev/null +++ b/frontend/shop/src/components/product-list.tsx @@ -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(1) + + const productsLength = useAppSelector(selectProducts).length + const isEndOfList = products && products.length >= productsLength + + const loadMorePosts = useCallback(() => { + if (!isEndOfList) { + setPage(page + 1) + } + }, [isEndOfList, page]) + + return ( + <> + + {products.map(product => ( + + ))} + + {!!products?.length && ( + + )} + + ) +} diff --git a/frontend/shop/src/components/spinner.tsx b/frontend/shop/src/components/spinner.tsx new file mode 100644 index 0000000..a9f0a05 --- /dev/null +++ b/frontend/shop/src/components/spinner.tsx @@ -0,0 +1,10 @@ +import CircularProgress from '@mui/material/CircularProgress' +import Box from '@mui/material/Box' + +export const Spinner = () => { + return ( + + + + ) +} diff --git a/frontend/shop/src/hocs/index.ts b/frontend/shop/src/hocs/index.ts new file mode 100644 index 0000000..9f4d16b --- /dev/null +++ b/frontend/shop/src/hocs/index.ts @@ -0,0 +1 @@ +export * from './with-protection' diff --git a/frontend/shop/src/hocs/with-protection.tsx b/frontend/shop/src/hocs/with-protection.tsx new file mode 100644 index 0000000..12d4f66 --- /dev/null +++ b/frontend/shop/src/hocs/with-protection.tsx @@ -0,0 +1,34 @@ +import { type ComponentType, type FC } from 'react' +import { Navigate, useLocation } from 'react-router-dom' + +export const withProtection =

(WrappedComponent: ComponentType

) => { + const ReturnedComponent: FC

= props => { + // Достаем accessToken из redux'a + const accessToken = '23123123' //useAppSelector(authSelector.accessTokenSelector) + // Объект location на понадобиться для задания состояния при redirect'e + const location = useLocation() + + // Если токен пустой, то нужно отправить пользователя на странице входа в систему + if (!accessToken) { + return ( + + ) + } + + return + } + + // У каждого компонента должно быть имя. Это поможет нам, когда будем использовать + // dev tools'ы реакта + ReturnedComponent.displayName = `withProtection${WrappedComponent.displayName}` + + return ReturnedComponent +} diff --git a/frontend/shop/src/layout/body.tsx b/frontend/shop/src/layout/body.tsx new file mode 100644 index 0000000..02c2f2b --- /dev/null +++ b/frontend/shop/src/layout/body.tsx @@ -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 ( + <> + + {loading ? ( + + + + ) : ( + + + + )} + + ) +} diff --git a/frontend/shop/src/layout/footer.tsx b/frontend/shop/src/layout/footer.tsx new file mode 100644 index 0000000..fd6051a --- /dev/null +++ b/frontend/shop/src/layout/footer.tsx @@ -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 ( + + theme.palette.mode === 'light' + ? theme.palette.grey[200] + : theme.palette.grey[800], + }} + > + + + Пилипенко Степан Андреевич + + {new Date().getFullYear()} + + + ) +} diff --git a/frontend/shop/src/layout/header.tsx b/frontend/shop/src/layout/header.tsx new file mode 100644 index 0000000..73c23e3 --- /dev/null +++ b/frontend/shop/src/layout/header.tsx @@ -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) + const [anchorElUser, setAnchorElUser] = useState(null) + + // TODO: брать из storage + const quantitiesSum = 3 + const currentUser = { + name: 'Петя', + avatarPath: '', + } + + const handleOpenNavMenu = (event: MouseEvent) => { + setAnchorElNav(event.currentTarget) + } + const handleOpenUserMenu = (event: MouseEvent) => { + 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 ( + + + + + + + + +

+ {pages.map(page => ( + + + + {page.name} + + + + ))} + + + + {pages.map(page => ( + + + {page.name} + + + ))} + + + + + + + + + {settings.map(setting => ( + + + {setting.name} + + + ))} + + + + + + ) +} diff --git a/frontend/shop/src/layout/index.ts b/frontend/shop/src/layout/index.ts new file mode 100644 index 0000000..626835b --- /dev/null +++ b/frontend/shop/src/layout/index.ts @@ -0,0 +1 @@ +export * from './layout' diff --git a/frontend/shop/src/layout/layout.tsx b/frontend/shop/src/layout/layout.tsx new file mode 100644 index 0000000..b911e70 --- /dev/null +++ b/frontend/shop/src/layout/layout.tsx @@ -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 ( + + + +
+ +