initial commit

This commit is contained in:
Kayashov.SM
2026-01-30 20:09:37 +04:00
parent f34a7eced5
commit 1c08be1d07
132 changed files with 24439 additions and 0 deletions

12
Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
# Создание рабочей среды для сборки
FROM maven:3.6.3-openjdk-17-slim as build
RUN mkdir -p /build/source
COPY . /build/source
WORKDIR /build/source
RUN mvn -am clean package
# Создание образа my-bar
FROM openjdk:17-ea-jdk-slim as my-bar
COPY --from=build /build/source/target/*.jar /data/app/my-bar.jar
WORKDIR /data/app
EXPOSE 8080
ENTRYPOINT "java" $JAVA_OPTS "-jar" "/data/app/my-bar.jar"

0
front/.env Normal file
View File

23
front/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

3
front/Dockerfile Normal file
View File

@@ -0,0 +1,3 @@
FROM nginx:1.16.0-alpine
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

70
front/README.md Normal file
View File

@@ -0,0 +1,70 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
The page will reload when you make changes.\
You may also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `npm run build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)

26
front/default.conf Normal file
View File

@@ -0,0 +1,26 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri /index.html;
}
location /api {
proxy_pass http://192.168.1.101:8080;
}
# Настройка кэширования статических ресурсов
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 30d;
add_header Cache-Control "public, no-transform";
}
# Включение сжатия Gzip
gzip on;
gzip_types text/plain application/javascript application/json application/xml;
}

19344
front/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

66
front/package.json Normal file
View File

@@ -0,0 +1,66 @@
{
"name": "front",
"version": "0.1.0",
"private": true,
"dependencies": {
"@emotion/react": "^11.13.0",
"@emotion/styled": "^11.13.0",
"@fontsource/inter": "5.0.18",
"@fontsource/plus-jakarta-sans": "5.0.20",
"@fontsource/roboto": "^5.0.14",
"@fontsource/roboto-mono": "5.0.18",
"@hookform/resolvers": "3.6.0",
"@mui/icons-material": "^5.16.6",
"@mui/material": "^5.16.6",
"@mui/utils": "^5.16.6",
"@mui/x-date-pickers": "7.7.1",
"@phosphor-icons/react": "2.1.6",
"@reduxjs/toolkit": "^2.5.0",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/react": "^18.3.3",
"apexcharts": "3.49.2",
"axios": "^1.7.3",
"cors": "^2.8.5",
"dayjs": "1.11.11",
"moment": "^2.30.1",
"notistack": "^3.0.1",
"prop-types": "^15.8.1",
"react": "^18.3.1",
"react-apexcharts": "1.4.1",
"react-dom": "^18.3.1",
"react-hook-form": "7.52.0",
"react-jwt": "^1.2.2",
"react-redux": "^9.2.0",
"react-router": "^7.1.1",
"react-router-dom": "^6.25.1",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4",
"zod": "3.23.8"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#212636" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512" xml:space="preserve">
<g>
<g>
<rect x="169.69" y="496.792" width="231.799" height="15.208"/>
</g>
</g>
<g>
<g>
<path d="M293.189,297.798L471.835,95.29H245.669C241.764,42.091,197.236,0,143.058,0C86.323,0,40.165,46.158,40.165,102.894
c0,56.735,46.157,102.891,102.893,102.891c15.495,0,30.831-3.527,44.722-10.236l90.201,102.25v168.576h-77.876v15.208h170.958
v-15.208h-77.875V297.798z M230.415,95.288h-69l48.752-48.751C221.462,59.967,228.82,76.812,230.415,95.288z M150.662,15.552
c18.474,1.595,35.331,8.928,48.76,20.222l-48.76,48.76V15.552z M135.454,15.542v68.932L86.701,35.775
C100.131,24.481,116.981,17.135,135.454,15.542z M75.947,46.528l48.812,48.76H55.708C57.302,76.811,64.65,59.958,75.947,46.528z
M55.706,110.496h57.044l5.601,6.35l-42.408,42.408C64.647,145.824,57.3,128.97,55.706,110.496z M135.454,190.244
c-18.475-1.594-35.328-8.94-48.758-20.237l41.735-41.735l7.023,7.961V190.244z M150.662,190.224v-36.751l26.603,30.156
C168.817,187.207,159.823,189.424,150.662,190.224z M201.485,188.096l-21.039-23.848h132.009V149.04H167.031l-33.999-38.541
h112.92h192.188l-34,38.54h-64.81v15.208h51.395L285.586,283.43L201.485,188.096z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#212636" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512" xml:space="preserve">
<g>
<g>
<rect x="169.69" y="496.792" width="231.799" height="15.208"/>
</g>
</g>
<g>
<g>
<path d="M293.189,297.798L471.835,95.29H245.669C241.764,42.091,197.236,0,143.058,0C86.323,0,40.165,46.158,40.165,102.894
c0,56.735,46.157,102.891,102.893,102.891c15.495,0,30.831-3.527,44.722-10.236l90.201,102.25v168.576h-77.876v15.208h170.958
v-15.208h-77.875V297.798z M230.415,95.288h-69l48.752-48.751C221.462,59.967,228.82,76.812,230.415,95.288z M150.662,15.552
c18.474,1.595,35.331,8.928,48.76,20.222l-48.76,48.76V15.552z M135.454,15.542v68.932L86.701,35.775
C100.131,24.481,116.981,17.135,135.454,15.542z M75.947,46.528l48.812,48.76H55.708C57.302,76.811,64.65,59.958,75.947,46.528z
M55.706,110.496h57.044l5.601,6.35l-42.408,42.408C64.647,145.824,57.3,128.97,55.706,110.496z M135.454,190.244
c-18.475-1.594-35.328-8.94-48.758-20.237l41.735-41.735l7.023,7.961V190.244z M150.662,190.224v-36.751l26.603,30.156
C168.817,187.207,159.823,189.424,150.662,190.224z M201.485,188.096l-21.039-23.848h132.009V149.04H167.031l-33.999-38.541
h112.92h192.188l-34,38.54h-64.81v15.208h51.395L285.586,283.43L201.485,188.096z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#ffffff" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512" xml:space="preserve">
<g>
<g>
<rect x="169.69" y="496.792" width="231.799" height="15.208"/>
</g>
</g>
<g>
<g>
<path d="M293.189,297.798L471.835,95.29H245.669C241.764,42.091,197.236,0,143.058,0C86.323,0,40.165,46.158,40.165,102.894
c0,56.735,46.157,102.891,102.893,102.891c15.495,0,30.831-3.527,44.722-10.236l90.201,102.25v168.576h-77.876v15.208h170.958
v-15.208h-77.875V297.798z M230.415,95.288h-69l48.752-48.751C221.462,59.967,228.82,76.812,230.415,95.288z M150.662,15.552
c18.474,1.595,35.331,8.928,48.76,20.222l-48.76,48.76V15.552z M135.454,15.542v68.932L86.701,35.775
C100.131,24.481,116.981,17.135,135.454,15.542z M75.947,46.528l48.812,48.76H55.708C57.302,76.811,64.65,59.958,75.947,46.528z
M55.706,110.496h57.044l5.601,6.35l-42.408,42.408C64.647,145.824,57.3,128.97,55.706,110.496z M135.454,190.244
c-18.475-1.594-35.328-8.94-48.758-20.237l41.735-41.735l7.023,7.961V190.244z M150.662,190.224v-36.751l26.603,30.156
C168.817,187.207,159.823,189.424,150.662,190.224z M201.485,188.096l-21.039-23.848h132.009V149.04H167.031l-33.999-38.541
h112.92h192.188l-34,38.54h-64.81v15.208h51.395L285.586,283.43L201.485,188.096z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#ffffff" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512" xml:space="preserve">
<g>
<g>
<rect x="169.69" y="496.792" width="231.799" height="15.208"/>
</g>
</g>
<g>
<g>
<path d="M293.189,297.798L471.835,95.29H245.669C241.764,42.091,197.236,0,143.058,0C86.323,0,40.165,46.158,40.165,102.894
c0,56.735,46.157,102.891,102.893,102.891c15.495,0,30.831-3.527,44.722-10.236l90.201,102.25v168.576h-77.876v15.208h170.958
v-15.208h-77.875V297.798z M230.415,95.288h-69l48.752-48.751C221.462,59.967,228.82,76.812,230.415,95.288z M150.662,15.552
c18.474,1.595,35.331,8.928,48.76,20.222l-48.76,48.76V15.552z M135.454,15.542v68.932L86.701,35.775
C100.131,24.481,116.981,17.135,135.454,15.542z M75.947,46.528l48.812,48.76H55.708C57.302,76.811,64.65,59.958,75.947,46.528z
M55.706,110.496h57.044l5.601,6.35l-42.408,42.408C64.647,145.824,57.3,128.97,55.706,110.496z M135.454,190.244
c-18.475-1.594-35.328-8.94-48.758-20.237l41.735-41.735l7.023,7.961V190.244z M150.662,190.224v-36.751l26.603,30.156
C168.817,187.207,159.823,189.424,150.662,190.224z M201.485,188.096l-21.039-23.848h132.009V149.04H167.031l-33.999-38.541
h112.92h192.188l-34,38.54h-64.81v15.208h51.395L285.586,283.43L201.485,188.096z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
front/public/assets/qr.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
front/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

43
front/public/index.html Normal file
View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Набор коктейлей для вашего бара"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>My Little Bar</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

BIN
front/public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
front/public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,15 @@
{
"short_name": "MyBar",
"name": "My Little Bar",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

3
front/public/robots.txt Normal file
View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

7
front/src/Config.js Normal file
View File

@@ -0,0 +1,7 @@
import {getSiteURL} from "./lib/getSiteUrl";
import {LogLevel} from "./lib/Logger";
export const config = {
site: {name: 'Bar', description: '', themeColor: '#090a0b', url: getSiteURL()},
logLevel: (process.env.NEXT_PUBLIC_LOG_LEVEL) ?? LogLevel.ALL,
};

5
front/src/Dockerfile Normal file
View File

@@ -0,0 +1,5 @@
FROM nginx:alpine as nginx
WORKDIR /app
COPY ../nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD "nginx" "-g" "daemon off;"

55
front/src/app/App.js Normal file
View File

@@ -0,0 +1,55 @@
import {CssBaseline, GlobalStyles} from "@mui/material";
import {LocalizationProvider} from "../components/core/LocalizationProvider";
import {AuthProvider} from "../context/AuthContext";
import {createTTheme} from "../styles/theme/create-theme";
import {Experimental_CssVarsProvider as CssVarsProvider} from '@mui/material/styles';
import {BrowserRouter as Router} from "react-router-dom";
import {NavigationRoutes} from "./NavigationRoutes";
import {SnackbarProvider} from 'notistack';
import {UserProvider} from "../context/UserContext";
import {SelectProvider} from "../context/SelectContext";
function App() {
const theme = createTTheme();
return (
// Провайдер времени
<LocalizationProvider>
{/*Провайдер уведомлений*/}
<SnackbarProvider maxSnack={6} anchorOrigin={{vertical: 'bottom', horizontal: 'right'}}
style={{borderRadius: '10px'}}>
{/*Провайдер авторизации*/}
<AuthProvider>
{/*Провайдер пользователя*/}
<UserProvider>
{/*Провайдер темы*/}
<CssVarsProvider theme={theme}>
<CssBaseline/>
<GlobalStyles
styles={{
body: {
'--MainNav-height': '56px',
'--MainNav-zIndex': 1000,
'--SideNav-width': '280px',
'--SideNav-zIndex': 1200,
'--MobileNav-width': '320px',
'--MobileNav-zIndex': 1200,
},
}}
/>
{/*Провайдер выбора*/}
<SelectProvider>
{/*Маршрутизация*/}
<Router>
<NavigationRoutes/>
</Router>
</SelectProvider>
</CssVarsProvider>
</UserProvider>
</AuthProvider>
</SnackbarProvider>
</LocalizationProvider>
);
}
export default App;

View File

@@ -0,0 +1,10 @@
import {paths} from "../path";
import {Loading} from "../components/core/Loading";
export function HomeRedirect({auth}) {
const redirectPath = auth ? paths.dashboard.overview : paths.auth.signIn;
window.location.replace(redirectPath);
return (
<Loading loading={true}/>
)
}

View File

@@ -0,0 +1,132 @@
import {Route, Routes} from "react-router-dom";
import {paths} from "../path";
import {useAuth} from "../hooks/useAuth";
import NotFoundPage from "./pages/notFound/NotFoundPage";
import {UserLayout} from "./layout/UserLayout";
import {HomeRedirect} from "./HomeRedirect";
import {PublicLayout} from "./layout/PublicLayout";
import LoginPage from "./pages/auth/sign-in/loginPage";
import {TelegramCode} from "./pages/auth/sign-in/telegram-code";
import {IngredientsPage} from "./pages/ingredients/IngredientsPage";
import {MenuPage} from "./pages/cocktails/MenuPage";
import {EditIngredientPage} from "./pages/ingredients/EditIngredientPage";
import {EditCocktailPage} from "./pages/cocktails/EditCocktailPage";
import {useEffect, useState} from "react";
import {BarChangePage} from "./pages/BarChangePage";
import {CalcPage} from "./pages/calc/CalcPage";
export function NavigationRoutes() {
const {auth} = useAuth();
const [loadedRoutes, setLoadedRoutes] = useState(undefined);
useEffect(() => {
setLoadedRoutes(auth ? authPages : guestPages)
}, [auth]);
if (!loadedRoutes) {
return null
}
return (
<Routes>
{loadedRoutes.map((page) => {
return (
<Route
key={page.path + page.isPrivate + page.exact}
path={page.path}
exact={page.exact}
element={<ElementProvider isPrivate={page.isPrivate}>
{page.children}
</ElementProvider>}/>
)
})}
</Routes>
)
}
function ElementProvider({isPrivate, children}) {
if (isPrivate) {
return (<UserLayout>{children}</UserLayout>);
} else {
return (<PublicLayout>{children}</PublicLayout>);
}
}
const authPages = [
{
children: (<HomeRedirect auth={true}/>),
isPrivate: false,
path: paths.home,
},
{
path: paths.auth.signIn,
children: (<LoginPage/>),
isPrivate: false,
},
{
path: paths.bar.calc,
children: (<CalcPage/>),
isPrivate: true,
},
{
path: paths.dashboard.overview,
isPrivate: true,
children: (<MenuPage/>),
exact: true,
},
{
path: paths.bar.list,
isPrivate: true,
children: (<BarChangePage/>),
},
{
path: paths.bar.ingredients,
isPrivate: true,
children: (<IngredientsPage/>)
},
{
path: paths.bar.ingredientEdit,
isPrivate: true,
forAdmin: true,
children: (<EditIngredientPage/>)
},
{
path: paths.bar.cocktailEdit,
isPrivate: true,
forAdmin: true,
children: (<EditCocktailPage/>)
},
{
path: paths.notFound,
isPrivate: false,
children: (<NotFoundPage/>)
},
]
const guestPages = [
{
path: paths.dashboard.overview,
isPrivate: true,
children: (<MenuPage/>),
exact: true,
},
{
children: (<HomeRedirect auth={true}/>),
isPrivate: false,
path: paths.home,
},
{
path: paths.auth.tg,
isPrivate: false,
children: (<TelegramCode/>),
exact: false
},
{
path: paths.auth.signIn,
isPrivate:
false,
children: (<LoginPage/>),
},
{
path: paths.notFound,
isPrivate: false,
children: (<NotFoundPage/>),
},
]

View File

@@ -0,0 +1,55 @@
import * as React from 'react';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import {DynamicLogo} from "../../components/core/Logo";
import {paths} from "../../path";
export function PublicLayout({ children }) {
return (
<Box
sx={{
display: { xs: 'flex', lg: 'grid' },
flexDirection: 'column',
gridTemplateColumns: '1fr 1fr',
}}
>
<Box sx={{ display: 'flex', flex: '1 1 auto', flexDirection: 'column' }}>
<Box sx={{ p: 3 }}>
<Box component={'a'} href={paths.home} sx={{ display: 'inline-block', fontSize: 0 }}>
<DynamicLogo colorDark="light" colorLight="dark" height={32} width={122} />
</Box>
</Box>
<Box sx={{ alignItems: 'center', display: 'flex', flex: '1 1 auto', justifyContent: 'center', p: 3 }}>
<Box sx={{ maxWidth: '450px', width: '100%' }}>{children}</Box>
</Box>
</Box>
<Box
sx={{
alignItems: 'center',
background: 'radial-gradient(50% 50% at 50% 50%, #122647 0%, #090E23 100%)',
color: 'var(--mui-palette-common-white)',
display: { xs: 'none', lg: 'flex' },
justifyContent: 'center',
p: 3,
}}
>
<Stack spacing={3}>
<Stack spacing={1}>
<Typography color="inherit" sx={{ fontSize: '24px', lineHeight: '32px', textAlign: 'center' }} variant="h1">
<Box component="span" sx={{ color: '#15b79e' }}>
Добро пожаловать в бар
</Box>
</Typography>
<Box
component="img"
alt="Under development"
src="/assets/qr.png"
sx={{ display: 'inline-block', height: 'auto', maxWidth: '100%', width: '400px' }}
/>
</Stack>
</Stack>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,31 @@
import {SideNav} from "../../components/navigation/SideNav";
import Box from "@mui/material/Box";
import {MainNav} from "../../components/navigation/MainNav";
import Container from "@mui/material/Container";
export function UserLayout({children}) {
return (
<Box
sx={{
bgcolor: 'var(--mui-palette-background-default)',
display: 'flex',
flexDirection: 'column',
position: 'relative',
minHeight: '100%',
}}
>
<SideNav/>
<Box sx={{
display: 'flex',
flex: '1 1 auto',
flexDirection: 'column',
pl: {xl: 'var(--SideNav-width)'}
}}>
<MainNav/>
<Container maxWidth="xl" sx={{py: '16px'}}>
{children}
</Container>
</Box>
</Box>
)
}

View File

@@ -0,0 +1,79 @@
import Paper from "@mui/material/Paper";
import React, {useEffect, useState} from "react";
import {useAlert} from "../../hooks/useAlert";
import {Card} from "@mui/material";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import Box from "@mui/material/Box";
import DeleteIcon from '@mui/icons-material/Delete';
import ElectricalServicesIcon from '@mui/icons-material/ElectricalServices';
import Toolbar from "@mui/material/Toolbar";
import AddCircleIcon from '@mui/icons-material/AddCircle';
import {BarCreateModal} from "../../components/BarCreateModal";
import PowerIcon from '@mui/icons-material/Power';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import {barClient} from "../../lib/clients/BarClient";
export function BarChangePage() {
const [bars, setBars] = useState([])
const [open, setOpen] = useState(false)
const [oldId, setOldId] = useState(null);
const {createError, createSuccess, createWarning} = useAlert();
const createHandler = (id, name) => {
if (id) {
barClient.copyBar(id, name, setBars, bars, createError, createSuccess, setOpen)
} else {
barClient.createBar(name, bars, createSuccess, createError, setBars, setOpen)
}
}
// eslint-disable-next-line
useEffect(() => barClient.getBarList(setBars, createError), []);
return (<>
<BarCreateModal open={open} setOpen={setOpen} create={createHandler} id={oldId}/>
<Paper sx={{p: 1}}>
<Toolbar>
<Typography variant='h6'>Списки ингредиентов (бары)</Typography>
<IconButton edge="end" onClick={() => {
setOldId(null);
setOpen(true);
}}>
<AddCircleIcon/>
</IconButton>
</Toolbar>
{bars.map((b) => {
return <Card key={b.id} sx={{m: 2, p: 2}}>
<Stack direction='row' justifyContent={'space-between'}>
<Typography>{b.name}</Typography>
<Box>
<IconButton onClick={() => {
setOldId(b.id)
setOpen(true);
}}>
<ContentCopyIcon/>
</IconButton>
{b.active && <IconButton disabled>
<PowerIcon/>
</IconButton>}
{!b.active && <>
<IconButton
onClick={() => barClient.deleteBar(b, bars, createError, createSuccess, setBars)}>
<DeleteIcon/>
</IconButton>
<IconButton
onClick={() => barClient.changeBar(b.id, bars, createWarning, createSuccess, createError, setBars)}>
<ElectricalServicesIcon/>
</IconButton>
</>}
</Box>
</Stack>
</Card>
})}
</Paper>
</>
)
}

View File

@@ -0,0 +1,11 @@
import * as React from 'react';
import {GuestGuard} from "../../../../components/auth/guest-guard";
import {SignInForm} from "../../../../components/auth/sign-in-form";
export default function LoginPage() {
return (
<GuestGuard>
<SignInForm/>
</GuestGuard>
);
}

View File

@@ -0,0 +1,16 @@
import * as React from "react";
import {useSearchParams} from "react-router-dom";
import {Loading} from "../../../../components/core/Loading";
import {useAuth} from "../../../../hooks/useAuth";
import {authClient} from "../../../../lib/clients/AuthClient";
export function TelegramCode() {
const [searchParams] = useSearchParams();
const {checkSession} = useAuth();
authClient.loginByCode(searchParams.get("code"), checkSession)
return (
<Loading loading={true}/>
)
}

View File

@@ -0,0 +1,90 @@
import Typography from "@mui/material/Typography";
import * as React from "react";
import {useEffect, useMemo} from "react";
import {useAlert} from "../../../hooks/useAlert";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import {CocktailItemCalc} from "./CocktailItemCalc";
import {IngredientCalcCard} from "./IngredientCalcCard";
import {cocktailClient} from "../../../lib/clients/CocktailClient";
export function CalcPage() {
const {createError} = useAlert();
const [cocktails, setCocktails] = React.useState([]);
const [load, setLoad] = React.useState(false);
const [cocktailMap, setCocktailMap] = React.useState({});
const changeHandler = (id, value) => {
setCocktailMap((prev) => ({
...prev,
[id]: value
}));
}
useEffect(() => {
cocktailClient.getCocktailsForCalcPage(load, setLoad, setCocktails, setCocktailMap, createError)
// eslint-disable-next-line
}, [load]);
const ingredients = useMemo(() => {
let map = {}
if (!cocktails) {
return [];
}
cocktails.forEach((c) => {
const receipts = c.receipt;
const countMeter = cocktailMap[c.id];
if (!receipts) {
return
}
receipts.forEach((r) => {
const ingredient = r.ingredient;
const id = ingredient.id;
const ingredientCount = r.count;
const resultCount = ingredientCount * countMeter;
if (map[id]) {
map[id] = {
...map[id],
count: map[id].count + resultCount
}
} else {
map[id] = {
ingredient: ingredient,
count: resultCount
}
}
})
})
return Object.values(map);
},
[cocktails, cocktailMap])
const deleteHandler = (id) => {
const state = cocktails.filter((c) => c.id !== id);
setCocktails(state);
}
console.log(cocktailMap)
return (
<Box padding={2}>
<Typography variant="h4" align="center">Коктейли</Typography>
<Stack mt={2}>
{cocktails.map((item, i) => (
<CocktailItemCalc key={i} cocktail={item} deleteHandler={deleteHandler}
changeHandler={changeHandler}/>
))}
</Stack>
<Typography variant="h4" mt={2} align="center">Ингредиенты</Typography>
<Stack mt={2}>
{ingredients.map((item, i) => (
<IngredientCalcCard key={i} count={item.count} ingredient={item.ingredient}/>
))}
</Stack>
</Box>
)
}

View File

@@ -0,0 +1,40 @@
import {Card} from "@mui/material";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton";
import DeleteIcon from "@mui/icons-material/Delete";
import React from "react";
import Typography from "@mui/material/Typography";
import {Counter} from "./Counter";
export function CocktailItemCalc({cocktail, deleteHandler, changeHandler}) {
return (
<Card sx={{mb: 1, display: 'relative', p: 2}}>
<Stack justifyContent={'start'} spacing={2}>
<Stack direction='row' justifyContent='start' alignItems='center'>
<Box sx={{width: '100px', height: '100px'}}>
<img src={cocktail.image} loading='lazy' height={'100px'} width={'100px'} alt={cocktail.id}/>
</Box>
<Box sx={{width: 'calc(90% - 100px)', pr: 2, ml: 2}}>
<Stack>
<Typography>{cocktail.name}</Typography>
<Typography>{cocktail.volume}</Typography>
<Typography>{cocktail.category}</Typography>
<Typography>{cocktail.alcoholic}</Typography>
<Typography color={'textSecondary'}>{cocktail.components}</Typography>
</Stack>
</Box>
<Stack direction='row'>
<Stack sx={{width: '5%'}} spacing={1} justifyContent='flex-start'>
<IconButton size='small' onClick={() => deleteHandler(cocktail.id)}>
<DeleteIcon/>
</IconButton>
</Stack>
</Stack>
</Stack>
<Counter id={cocktail.id} changeHandler={changeHandler}/>
</Stack>
</Card>
)
}

View File

@@ -0,0 +1,65 @@
import React, {useState} from 'react';
import {Box, TextField, Button} from '@mui/material';
import {styled} from '@mui/material/styles';
// Стилизуем контейнер счетчика
styled(Box)`
display: flex;
align-items: center;
justify-content: center;
width: 150px;
height: 50px;
border-radius: 8px;
//box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
//background-color: #ffffff;
`;
export function Counter({id, changeHandler}) {
const [value, setValue] = useState(1);
const handleChange = (newValue) => {
setValue(newValue);
changeHandler(id, newValue);
}
return (
<Box>
<Button onClick={() => {
if (value > 0) {
setValue(value - 1);
}
}}
sx={{
width: '20px',
height: '55px',
borderRadius: '50%',
margin: '0 8px',
backgroundColor: 'transparent',
}}></Button>
<TextField
value={value}
onChange={(e) => {
const newValue = parseInt(e.target.value, 10);
if (!isNaN(newValue)) {
handleChange(newValue);
}
}}
inputProps={{inputMode: 'numeric'}}
sx={{
width: '40px',
height: '15px',
fontSize: '10px',
textAlign: 'center'
}}
/>
<Button onClick={() => handleChange(value + 1)}
sx={{
width: '20px',
height: '55px',
borderRadius: '50%',
margin: '0 8px',
backgroundColor: 'transparent',
}}>+</Button>
</Box>
);
}

View File

@@ -0,0 +1,21 @@
import {Card} from "@mui/material";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import React from "react";
export function IngredientCalcCard({ingredient, count}) {
return (
<Card sx={{mb: 1, height: '130px', display: 'relative', pt: 1}}>
<Stack direction='row' justifyContent='start' alignItems='center'>
<Box sx={{width: '100px', height: '100px'}}>
<img src={ingredient.image} loading='lazy' height={'100px'} width={'100px'} alt={ingredient.id}/>
</Box>
<Box sx={{width: 'calc(90% - 100px)', pr: 2}}>{ingredient.name}</Box>
<Stack direction='row'>
<Box mr={1} pt={'3px'}>{count}</Box>
</Stack>
</Stack>
</Card>
)
}

View File

@@ -0,0 +1,196 @@
import Grid from "@mui/material/Grid";
import {useAlert} from "../../../hooks/useAlert";
import * as React from "react";
import {useCallback, useEffect, useState} from "react";
import {Cocktail} from "../../../components/cocktails/Cocktail";
import {Fab, Skeleton} from "@mui/material";
import Box from "@mui/material/Box";
import {NoResult} from "../../../components/cocktails/NoResult";
import {FilterBlock} from "../../../components/cocktails/FilterBlock";
import {CocktailInfoModal} from "../../../components/cocktails/CocktailInfoModal";
import {useUser} from "../../../hooks/useUser";
import {blue} from "@mui/material/colors";
import UpIcon from "@mui/icons-material/KeyboardArrowUp";
import {useSelect} from "../../../hooks/useSelect";
import Paper from "@mui/material/Paper";
import CheckMarks from "../../../components/cocktails/CheckMarks";
import {cocktailClient} from "../../../lib/clients/CocktailClient";
const emptyFilter = {
search: "",
all: false,
hidden: true,
onlyFavourite: false,
glass: [],
category: [],
alcohol: [],
iCount: [],
ingredient: [],
inMenu: "",
sorting: "Название по возрастанию"
}
const CocktailsPageContent = () => {
const {user} = useUser();
const {createError, createSuccess} = useAlert();
const [rows, setRows] = useState([]);
const [filter, setFilter] = useState(emptyFilter)
// const [chips, setChips] = useState([])
const chips = [];
const [page, setPage] = useState(-1);
const [load, setLoad] = useState(false);
const [isEnd, setIsEnd] = useState(false);
const [isNew, setIsNew] = useState(true);
const {selectCocktail, getCocktail, getOpenCocktail} = useSelect();
const loading = useCallback(() => {
const size = Math.floor((window.innerWidth) / 350) * 5;
if (load || (!isNew && isEnd)) {
return false;
}
cocktailClient.getMenu(setRows, setIsNew, setPage, setLoad, setIsEnd, isNew, rows, page, size, filter, createError);
// eslint-disable-next-line
}, [load, isEnd, page]);
useEffect(() => {
const handleScroll = () => {
const {scrollTop, scrollHeight, clientHeight} = document.documentElement;
if (scrollTop + clientHeight >= scrollHeight - 100) {
loading();
}
}
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
// eslint-disable-next-line
}, [loading]);
// eslint-disable-next-line
useEffect(() => loading(), [filter])
const renderSkeleton = () => {
return Array.from({length: 3}, () => null)
.map((v, index) => <Skeleton sx={{m: 2}}
key={index}
variant="rounded"
width={350}
height={690}/>);
}
const handleChangeRating = (row, value) => {
const newState = rows.map((r) => {
if (row.id === r.id) {
let newRating = r.rating;
newRating.rating = value
return {
...r,
rating: newRating
}
}
return r;
})
cocktailClient.changeRating(row.id, newState, value, setRows, createSuccess, createError)
}
const handleFilterChange = (filterName, value) => {
const newState = {
...filter,
[filterName]: value
}
setFilter(newState)
setIsNew(true);
setIsEnd(false);
setPage(-1);
}
const handleFavourite = (row) => {
const value = !row.rating.favourite;
const newState = rows.map((r) => {
if (r.id === row.id) {
let newRating = r.rating;
newRating.favourite = value;
return {
...r,
rating: newRating
}
}
return r;
});
cocktailClient.changeFavourite(value, row.id, newState, setRows, createSuccess, createError)
}
const handleFilterClear = () => {
setFilter(emptyFilter);
setIsNew(true);
setIsEnd(false);
setPage(-1);
}
const handleSelectCocktail = (row) => selectCocktail(row.id)
const deleteHandle = (row) => cocktailClient.deleteCocktail(row.id, rows, setRows, createSuccess, createError)
const hideHandler = (id) => {
cocktailClient.hiddenCocktail(id)
.then(() => {
createSuccess("Коктейль скрыт успешно");
setRows(rows.filter((r) => r.id !== id))
}).catch(() => createError("Ошибка при попытке скрыть коктейль"))
}
return (
<Box>
{/*<Loading loading={load}/>*/}
{/*Модальное окно информации о коктейле*/}
<CocktailInfoModal row={getCocktail()} open={getOpenCocktail()}/>
{/*Блок фильтров*/}
<FilterBlock
filter={filter}
handleFilterChange={handleFilterChange}
handleClearFilter={handleFilterClear}
barmen={user.role !== 'USER'}
/>
{
(filter.all && filter.iCount === 1) && (
<Paper sx={{mt: 1}}>
<CheckMarks rows={chips} name={"Выбор ингредиента"} filterName={"ingredient"}
filterValue={filter.ingredient}
handleChange={handleFilterChange}
identity
/>
</Paper>
)
}
<Box>
{/*Основное содержимое*/}
<Grid container rowSpacing={2} columnSpacing={{xs: 1, sm: 1, md: 2}} sx={{m: 1}}>
{rows.length > 0 && rows.map((row) => {
return (
<Cocktail key={row.id} row={row} handleFavourite={handleFavourite}
handleChangeRating={handleChangeRating}
handleSelect={handleSelectCocktail}
deleteHandler={deleteHandle}
hideHandler={hideHandler}
/>
)
})}
{load && renderSkeleton()}
{rows.length === 0 && (<NoResult/>)}
</Grid>
</Box>
<Fab sx={{
alpha: '30%',
position: 'sticky',
left: 'calc(100% - 16px)',
bottom: '16px',
color: 'common.white',
bgcolor: blue[600],
'&:hover': {
bgcolor: blue[600],
},
}}
onClick={() => window.window.scrollTo(0, 0)}
aria-label='Expand'
color='inherit'>
<UpIcon/>
</Fab>
</Box>
);
}
export default CocktailsPageContent;

View File

@@ -0,0 +1,194 @@
import Box from "@mui/material/Box";
import Toolbar from "@mui/material/Toolbar";
import Typography from "@mui/material/Typography";
import * as React from "react";
import {useEffect, useState} from "react";
import Paper from "@mui/material/Paper";
import {Autocomplete} from "@mui/material";
import TextField from "@mui/material/TextField";
import {useAlert} from "../../../hooks/useAlert";
import Stack from "@mui/material/Stack";
import Button from "@mui/material/Button";
import {EditCocktailReceipt} from "../../../components/cocktails/EditCocktailReceipt";
import {SelectEdit} from "../../../components/cocktails/SelectEdit";
import {useSearchParams} from "react-router-dom";
import {Loading} from "../../../components/core/Loading";
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import {styled} from "@mui/material/styles";
import {cocktailClient} from "../../../lib/clients/CocktailClient";
import {categoryClient} from "../../../lib/clients/CategoryClient";
import {glassClient} from "../../../lib/clients/GlassClient";
const emptyCocktail = {
id: null,
name: "",
alcoholic: "",
category: "",
components: "",
glass: "",
image: "",
instructions: "",
isAllowed: false,
rating: {
rating: 0,
favourite: false
},
receipt: [],
tags: "",
video: ""
};
const alcohol = [
{
id: 1,
name: "Алкогольный"
},
{
id: 2,
name: "Безалкогольный",
},
{
id: 3,
name: "Опционально"
}
]
const VisuallyHiddenInput = styled('input')({
clip: 'rect(0 0 0 0)',
clipPath: 'inset(50%)',
height: 1,
overflow: 'hidden',
position: 'absolute',
bottom: 0,
left: 0,
whiteSpace: 'nowrap',
width: 1,
});
export function EditCocktailPage() {
const [searchParams] = useSearchParams();
const [loading, setLoading] = useState(true);
const {createError, createSuccess, getError} = useAlert();
const [cocktails, setCocktails] = useState([]);
const [selected, setSelected] = useState(null);
const [cocktail, setCocktail] = useState(emptyCocktail);
const [glass, setGlass] = useState([]);
const [category, setCategory] = useState([]);
useEffect(() => {
cocktailClient.getSimpleList(setCocktails, setSelected, setLoading, createError, searchParams.get("id"))
categoryClient.getCategoryList(setCategory, createError);
glassClient.getGlassList(setGlass, createError)
// eslint-disable-next-line
}, []);
// eslint-disable-next-line
useEffect(() => cocktailClient.getOneCocktail(selected, setCocktail, getError, emptyCocktail), [selected])
const saveHandler = () => cocktailClient.saveChangeCocktail(cocktail, createError, createSuccess)
const deleteHandle = () => cocktailClient.deleteCocktailFromEdit(setCocktails, setCocktail, createError, cocktails, cocktail, emptyCocktail)
const changeCocktailValue = (name, value) => {
if (name === "tags") {
value = value.join(",");
}
setCocktail((prev) => ({
...prev,
[name]: value
}))
}
return (
<Box>
{/*Загрузка*/}
<Loading loading={loading}/>
{/*Заголовок*/}
<Toolbar>
<Typography variant="h6" component="div" sx={{flexGrow: 1}}>Коктейли</Typography>
</Toolbar>
{/*Поиск*/}
<Paper elevation={6} sx={{my: 2, display: 'grid', p: 2}}>
<Autocomplete
disablePortal
options={cocktails}
onChange={(e, v) => {
if (!v) {
setCocktail(emptyCocktail);
setSelected(null)
} else {
setSelected(v.id)
}
}}
isOptionEqualToValue={(selected, value) => selected.id === value.id}
getOptionKey={(selected) => selected.id}
getOptionLabel={(selected) => selected.name + (selected.hasError ? " (есть ошибка)" : "")}
renderInput={(params) => <TextField {...params} label="Поиск"/>}
/>
</Paper>
{/*Рабочая область*/}
<Paper elevation={6} sx={{my: 2, display: 'grid', p: 1, pb: 2}}>
<Stack>
<Box hidden={cocktail.id === null} ml={1} mb={1}>
<Button color='error' onClick={() => deleteHandle()}>Удалить коктейль</Button>
</Box>
{/*Фото*/}
<Box ml={1}>
<img src={cocktail.image} alt={""} width={300} height={300} loading={'eager'}/>
</Box>
{/*Редактирование ссылки на фото*/}
<Stack direction='row' pr={2} m={1} display='relative'>
<TextField sx={{width: '75%'}}
label={"Ссылка на фото"} variant='outlined' multiline
value={!cocktail.image ? "" : cocktail.image}
onChange={(e) => changeCocktailValue("image", e.target.value)}
/>
<Button
component="label"
role={undefined}
variant="contained"
tabIndex={-1}
startIcon={<CloudUploadIcon/>}
sx={{width: '10%', fontSize: 40, ml: 1, pr: 1}}
>
<VisuallyHiddenInput
type="file"
accept=".jpg,.jpeg,.png"
onChange={(event) => cocktailClient.savePhoto(event, changeCocktailValue, getError)}
/>
</Button>
</Stack>
{/*Название*/}
<Box m={1}>
<TextField sx={{mr: 1, mb: 2, minWidth: 300}}
variant="outlined" label={"Название"}
value={cocktail.name}
onChange={(e) => changeCocktailValue("name", e.target.value)}/>
</Box>
{/*Категория, посуда, алкогольность, теги*/}
<Box mb={2}>
<SelectEdit value={cocktail.category} label={"Категория"} width={300} margin={1}
array={category}
attributeName={"category"} handler={changeCocktailValue}/>
<SelectEdit value={cocktail.glass} label={"Посуда"} width={300} margin={1} array={glass}
attributeName={"glass"} handler={changeCocktailValue}/>
<SelectEdit value={cocktail.alcoholic} label={"Алкогольность"} width={300} margin={1}
array={alcohol}
attributeName={"alcoholic"} handler={changeCocktailValue}/>
</Box>
{/*Рецепт*/}
<EditCocktailReceipt receipt={cocktail.receipt} handler={changeCocktailValue}/>
<Box pr={2} ml={1}>
<TextField sx={{width: '100%'}}
label={"Инструкция"} variant='outlined' multiline
value={!cocktail.instructions ? "" : cocktail.instructions}
onChange={(e) => changeCocktailValue("instructions", e.target.value)}
/>
</Box>
</Stack>
</Paper>
<Box display={'flex'} justifyContent={'flex-end'}>
<Button variant='contained' onClick={() => saveHandler()}>Сохранить</Button>
</Box>
</Box>
)
}

View File

@@ -0,0 +1,7 @@
import CocktailsPageContent from "./CocktailsPageContent";
export function MenuPage() {
return (
<CocktailsPageContent/>
)
}

View File

@@ -0,0 +1,142 @@
import Box from "@mui/material/Box";
import Toolbar from "@mui/material/Toolbar";
import Typography from "@mui/material/Typography";
import * as React from "react";
import {useEffect, useState} from "react";
import Paper from "@mui/material/Paper";
import {Autocomplete, FormControl, FormControlLabel, InputLabel} from "@mui/material";
import {useAlert} from "../../../hooks/useAlert";
import {useSearchParams} from "react-router-dom";
import TextField from "@mui/material/TextField";
import Switch from "@mui/material/Switch";
import Stack from "@mui/material/Stack";
import Button from "@mui/material/Button";
import Select from "@mui/material/Select";
import MenuItem from "@mui/material/MenuItem";
import {ingredientClient} from "../../../lib/clients/IngredientClient";
const emptyIngredient = {
id: null,
name: "",
enName: "",
have: false,
image: null,
type: "",
alcohol: false,
abv: null,
description: null
}
export function EditIngredientPage() {
const [searchParams] = useSearchParams();
const [ingredients, setIngredients] = useState([]);
const [types, setTypes] = useState([]);
const [ingredient, setIngredient] = useState(emptyIngredient)
const {createError, createSuccess} = useAlert();
useEffect(() => {
ingredientClient.allList(searchParams.get("id"), setIngredients, setIngredient, createError)
ingredientClient.getType(setTypes)
// eslint-disable-next-line
}, []);
const changeIngredientValue = (name, value) => {
setIngredient((prev) => ({
...prev,
[name]: value
}))
}
return (
<Box>
{/*Заголовок*/}
<Toolbar>
<Typography variant="h6" component="div" sx={{flexGrow: 1}}>Ингредиенты</Typography>
</Toolbar>
{/*Поиск*/}
<Paper elevation={6} sx={{my: 2, display: 'grid', p: 2}}>
<Autocomplete
disablePortal
options={ingredients}
defaultChecked={emptyIngredient}
onChange={(e, v) => {
return !v ? setIngredient(emptyIngredient) : setIngredient(v)
}}
isOptionEqualToValue={(selected, value) => selected.id === value.id}
getOptionKey={(selected) => selected.id}
getOptionLabel={(selected) => selected.name}
renderInput={(params) => <TextField {...params} label="Ингредиенты"/>}
/>
</Paper>
{/*Форма ингредиента*/}
<Paper elevation={6} sx={{my: 2, display: 'grid', p: 1, pb: 2}}>
<Stack>
<Box display={'flex'} justifyContent={'flex-end'} pr={2}>
<FormControlLabel control={
<Switch
checked={ingredient.have}
onChange={() => changeIngredientValue("have", !ingredient.have)}
/>}
label={"Наличие"} labelPlacement={'start'}/>
</Box>
<Box>
<img src={ingredient.image} alt={""} loading={'eager'}/>
</Box>
<Box m={1}>
<TextField sx={{mr: 1, mb: 2, minWidth: 330}}
variant="outlined" label={"Название"}
value={ingredient.name}
onChange={(e) => changeIngredientValue("name", e.target.value)}/>
</Box>
<Box height={70} mt={1} ml={1}>
<FormControlLabel sx={{pt: 1}}
control={
<Switch
checked={ingredient.alcohol}
onChange={() => changeIngredientValue("alcohol", !ingredient.alcohol)}
/>}
label="Алкогольный"/>
{ingredient.alcohol && (
<TextField sx={{width: 100}}
variant='outlined' label='Градус'
value={!ingredient.abv ? "" : ingredient.abv}
onChange={(e) => changeIngredientValue("abv", e.target.value)}/>
)}
</Box>
<Box mb={2} ml={1}>
<FormControl sx={{width: 330}}>
<InputLabel id="select-label">Категория</InputLabel>
<Select
id="select-label"
autoWidth
label={"Категория"}
value={!ingredient.type ? "" : ingredient.type}
onChange={(e) => changeIngredientValue("type", e.target.value)}
>
<MenuItem value="">
<em>None</em>
</MenuItem>
{types.map((c) => {
return (<MenuItem key={c.id} value={c.name}>{c.name}</MenuItem>)
})}
</Select>
</FormControl>
</Box>
<Box pr={2} ml={1}>
<TextField sx={{width: '100%'}}
label={"Описание"} variant='outlined' multiline
onChange={(e) => changeIngredientValue("description", e.target.value)}
value={!ingredient.description ? "" : ingredient.description}/>
</Box>
</Stack>
</Paper>
<Box display={'flex'} justifyContent={'flex-end'}>
<Button variant='contained'
onClick={() => ingredientClient.saveIngredient(ingredient, createSuccess, createError)}>Сохранить</Button>
</Box>
</Box>
)
}

View File

@@ -0,0 +1,126 @@
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Toolbar from "@mui/material/Toolbar";
import Paper from "@mui/material/Paper";
import {Fab, FormControl, InputAdornment, InputLabel, OutlinedInput, Tabs} from "@mui/material";
import IconButton from "@mui/material/IconButton";
import SearchIcon from "@mui/icons-material/Search";
import * as React from "react";
import {useEffect, useMemo, useState} from "react";
import {Loading} from "../../../components/core/Loading";
import {useAlert} from "../../../hooks/useAlert";
import {IngredientInfoModal} from "../../../components/Ingredients/IngredientInfoModal";
import Tab from "@mui/material/Tab";
import {a11yProps} from "../../../components/core/tabProps";
import {CustomTabPanel} from "../../../components/core/TabPanel";
import {IngredientList} from "../../../components/Ingredients/IngredientList";
import {blue} from "@mui/material/colors";
import UpIcon from "@mui/icons-material/KeyboardArrowUp";
import {useSelect} from "../../../hooks/useSelect";
import {ingredientClient} from "../../../lib/clients/IngredientClient";
export function IngredientsPage() {
const [value, setValue] = React.useState(0);
const handleChange = (event, newValue) => setValue(newValue);
const [loading, setLoading] = useState(true);
const [findString, setFindString] = useState("");
const [ingredients, setIngredients] = useState([]);
const {getIngredient, selectIngredient} = useSelect();
const {createError, createSuccess} = useAlert();
useEffect(() => {
ingredientClient.getAllIngredients(setIngredients, setLoading, createError)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const visibleIngredients = useMemo(() => {
if (findString.length === 0) {
return ingredients;
}
const reg = new RegExp("(.*?)" + findString + "(.*?)", "i");
return ingredients.filter((ingredient) => ingredient.name.match(reg) !== null);
}, [findString, ingredients]);
const ingredientsToAdd = visibleIngredients.filter((ingredient) => !ingredient.have);
const ingredientsInBar = visibleIngredients.filter((ingredient) => ingredient.have);
const changeHandler = (row, value) => {
const newState = ingredients.map((ingredient) => {
if (ingredient.id === row.id) {
return {
...ingredient,
have: value
}
} else {
return ingredient;
}
})
ingredientClient.changeIngredientIsHave(row.id, value, newState, setIngredients, createError)
}
const handleDelete = (id) => {
const newState = ingredients.filter((ingredient) => ingredient.id !== id);
setIngredients(newState)
ingredientClient.deleteIngredientIsHave(id, createSuccess, createError)
}
return (
<Box>
{/*Заголовок*/}
<Toolbar>
<Typography variant="h6" component="div" sx={{flexGrow: 1}}>Ингредиенты бара</Typography>
</Toolbar>
{/*Поиск*/}
<Paper elevation={6} sx={{my: 2}}>
<FormControl sx={{m: 1, width: 'calc(100% - 20px'}}>
<InputLabel htmlFor="outlined-adornment-amount">Поиск</InputLabel>
<OutlinedInput
onChange={(e) => setFindString(e.target.value)}
label="With normal TextField"
startAdornment={
<InputAdornment position="start">
<IconButton edge="end">
<SearchIcon/>
</IconButton>
</InputAdornment>
}
/>
</FormControl>
</Paper>
{/*Рабочее поле ингредиентов*/}
<Box>
<Tabs value={value} onChange={handleChange} aria-label="basic tabs example">
<Tab label="В баре" {...a11yProps(0)} />
<Tab label="Список" {...a11yProps(1)} />
</Tabs>
</Box>
<Box>
<CustomTabPanel value={value} index={0}>
<IngredientList rows={ingredientsInBar} value={false} changeHandler={changeHandler}
infoHandler={selectIngredient}/>
</CustomTabPanel>
<CustomTabPanel value={value} index={1}>
<IngredientList rows={ingredientsToAdd} value={true} changeHandler={changeHandler}
infoHandler={selectIngredient}/>
</CustomTabPanel>
</Box>
<Fab sx={{
alpha: '30%',
position: 'sticky',
bottom: '16px',
color: 'common.white',
bgcolor: blue[600],
'&:hover': {
bgcolor: blue[600],
},
}}
onClick={() => window.window.scrollTo(0, 0)}
aria-label='Expand'
color='inherit'>
<UpIcon/>
</Fab>
{/*Загрузчик*/}
<Loading loading={loading}/>
{/*Модальное окно информации об ингредиенте*/}
<IngredientInfoModal ingredient={getIngredient()} handleDelete={handleDelete}/>
</Box>
)
}

View File

@@ -0,0 +1,38 @@
import * as React from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft';
import {paths} from "../../../path";
export default function NotFoundPage() {
return (
<Box component="main" sx={{ alignItems: 'center', display: 'flex', justifyContent: 'center', minHeight: '100%' }}>
<Stack spacing={3} sx={{ alignItems: 'center', maxWidth: 'md' }}>
<Box>
<Box
component="img"
alt="Under development"
src="/assets/error-404.png"
sx={{ display: 'inline-block', height: 'auto', maxWidth: '100%', width: '400px' }}
/>
</Box>
<Typography variant="h3" sx={{ textAlign: 'center' }}>
404: Страница не найдена или недоступна
</Typography>
<Typography color="text.secondary" variant="body1" sx={{ textAlign: 'center' }}>
Вы либо выбрали какой-то сомнительный маршрут, либо попали сюда по ошибке. Что бы это ни было, попробуйте воспользоваться навигацией
</Typography>
<Button
// component={'a'}
href={paths.home}
startIcon={<ArrowLeftIcon fontSize="var(--icon-fontSize-md)" />}
variant="contained"
>
На домашнюю страницу
</Button>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,43 @@
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import * as React from "react";
import {useState} from "react";
import Typography from "@mui/material/Typography";
import DialogContent from "@mui/material/DialogContent";
import DialogActions from "@mui/material/DialogActions";
import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";
export function BarCreateModal({open, setOpen, create, id}) {
const [value, setValue] = useState("");
return (
<Dialog fullWidth={true}
open={open} onClose={() => setOpen(false)}
sx={{
'& .MuiDialog-paper': {
margin: '8px',
},
'& .MuiPaper-root': {
width: 'calc(100% - 16px)',
}
}}>
<DialogTitle>
<Typography>Создать список</Typography>
</DialogTitle>
<DialogContent>
<TextField sx={{width: '75%'}}
label={<Typography pt={'4px'}>
Название списка</Typography>} variant='outlined'
value={!value ? "" : value}
onChange={(e) => setValue(e.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => {
create(id, value);
setValue("");
}}>Создать</Button>
</DialogActions>
</Dialog>
)
}

View File

@@ -0,0 +1,39 @@
import * as React from 'react';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
export function IngredientAlert({open, handleClose, handleDelete, id, handleCloseParent}) {
return (
<React.Fragment>
<Dialog
open={open}
onClose={handleClose}
aria-labelledby="Предупреждение об удалении"
aria-describedby="alert-dialog-description"
>
<DialogTitle id="alert-dialog-title">
{"Вы готовы удалить ингредиент?"}
</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">
После удаления ингредиента, удаляться все рецепты и коктейли связанные с ним!
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Отмена</Button>
<Button color='error' onClick={() => {
handleClose();
handleCloseParent();
handleDelete(id)
}} autoFocus>
Удалить
</Button>
</DialogActions>
</Dialog>
</React.Fragment>
);
}

View File

@@ -0,0 +1,37 @@
import {Card} from "@mui/material";
import IconButton from "@mui/material/IconButton";
import DeleteIcon from '@mui/icons-material/Delete';
import AddBoxRoundedIcon from '@mui/icons-material/AddBoxRounded';
import InfoRoundedIcon from '@mui/icons-material/InfoRounded';
import React from "react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import {paths} from "../../path";
import EditIcon from '@mui/icons-material/Edit';
export function IngredientCard({row, value, infoHandler, changeHandler}) {
return (
<Card sx={{mb: 1, height: '130px', display: 'relative', pt: 1}}>
<Stack direction='row' justifyContent='start' alignItems='center'>
<Box sx={{width: '100px', height: '100px'}}>
<img src={row.image} loading='lazy' height={'100px'} width={'100px'} alt={row.id}/>
</Box>
<Box sx={{width: 'calc(90% - 100px)', pr: 2}}>{row.name}</Box>
<Stack direction='row'>
<Box mr={1} pt={'3px'}>{!row.alcohol ? "" : `${row.abv}%`}</Box>
<Stack sx={{width: '5%'}} spacing={1} justifyContent='flex-start'>
<IconButton size='small' onClick={() => changeHandler(row, value)}>
{value ? <AddBoxRoundedIcon/> : <DeleteIcon/>}
</IconButton>
<IconButton size='small' onClick={() => infoHandler(row)}>
<InfoRoundedIcon/>
</IconButton>
<IconButton size='small' href={`${paths.bar.ingredientEdit}?id=${row.id}`}>
<EditIcon/>
</IconButton>
</Stack>
</Stack>
</Stack>
</Card>
)
}

View File

@@ -0,0 +1,85 @@
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import DialogActions from "@mui/material/DialogActions";
import Button from "@mui/material/Button";
import * as React from "react";
import {useEffect, useState} from "react";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import List from "@mui/material/List";
import {useAlert} from "../../hooks/useAlert";
import ListItem from "@mui/material/ListItem";
import {useSelect} from "../../hooks/useSelect";
import {IngredientAlert} from "./IngredientAlert";
import {useUser} from "../../hooks/useUser";
import {cocktailClient} from "../../lib/clients/CocktailClient";
export function IngredientInfoModal({ingredient, handleDelete}) {
const {user} = useUser();
const [cocktails, setCocktails] = useState([]);
const {closeIngredient, getOpenIngredient, selectCocktail} = useSelect();
const {createError} = useAlert();
const [open, setOpen] = React.useState(false);
const handleClose = () => {
setOpen(false);
};
useEffect(() => {
cocktailClient.getCocktailByIngredient(ingredient, setCocktails)
.catch(() => createError())
// eslint-disable-next-line
}, [ingredient]);
if (!ingredient) {
return null;
}
return (
<Dialog fullWidth={true} maxWidth="350px" open={getOpenIngredient()} onClose={closeIngredient}
sx={{
'& .MuiDialog-paper': {
margin: '8px',
},
'& .MuiPaper-root': {
width: 'calc(100% - 16px)',
}
}}>
<DialogTitle>{ingredient.name}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{justifyContent: "center"}}>
<img src={ingredient.image} alt={ingredient.name} loading={"eager"} width={"300"}/>
{ingredient.alcohol && (<Typography>{`Крепость ${ingredient.abv}`}</Typography>)}
<Typography>{ingredient.description}</Typography>
</Stack>
{cocktails.length > 0 && (
<>
<Typography sx={{mt: 2}}>Коктейли:</Typography>
<List>
{cocktails.map((c) => {
return (
<ListItem key={c.id} onClick={() => {
selectCocktail(c.id)
closeIngredient();
}}>
<Stack direction={'row'}>
<img src={c.image} alt={c.name} loading={"eager"} width={"50"}/>
<Typography sx={{mx: 1}}>{c.name}</Typography>
{c.rating.rating > 0 && <Typography> {`${c.rating.rating}/5`}</Typography>}
</Stack>
</ListItem>
)
})}
</List>
</>
)}
</DialogContent>
<DialogActions>
{user.role !== 'USER' && <Button onClick={() => setOpen(true)}>Удалить</Button>}
<Button onClick={closeIngredient}>Закрыть</Button>
</DialogActions>
<IngredientAlert handleDelete={handleDelete} handleClose={handleClose} open={open} id={ingredient.id}
handleCloseParent={closeIngredient}/>
</Dialog>
);
}

View File

@@ -0,0 +1,55 @@
import Box from "@mui/material/Box";
import {IngredientCard} from "./IngredientCard";
import {useMemo, useState} from "react";
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import {Accordion, AccordionDetails, AccordionSummary} from "@mui/material";
import Typography from "@mui/material/Typography";
import {getComparator} from "../core/getComparator";
import {groupByForLoop} from "../core/groupByForLoop";
export function IngredientList({rows, value, infoHandler, changeHandler}) {
const visibleRows = useMemo(() => {
let res = [];
if (rows.length === 0) {
return null;
}
const group = groupByForLoop(rows, "type")
if (!group || group.size === 0) {
return null;
}
const keys = Array.from(group.keys());
keys.sort(getComparator("asc"))
.forEach((key) => {
const list = group.get(key)
res.push(
<Accordion key={key}>
<AccordionSummary
expandIcon={<ExpandMoreIcon/>}
aria-controls="panel1-content"
id="panel1-header"
>
<Typography component="span">{key !== "null" ? key : "Без категории"}</Typography>
</AccordionSummary>
<AccordionDetails>
{list.sort(getComparator("asc", "name"))
.map((row) => {
return (
<IngredientCard row={row} key={row.id} value={value}
changeHandler={changeHandler} infoHandler={infoHandler}/>
)
})}
</AccordionDetails>
</Accordion>
)
})
return res;
// eslint-disable-next-line
}, [rows])
return (
<Box mt={2}>
{visibleRows}
</Box>
)
}

View File

@@ -0,0 +1,48 @@
'use client';
import * as React from 'react';
import Alert from '@mui/material/Alert';
import {useAuth} from "../../hooks/useAuth";
import {logger} from "../../lib/DefaultLogger";
import {paths} from "../../path";
export function GuestGuard({ children }) {
const { auth, error, isLoading } = useAuth();
const [isChecking, setIsChecking] = React.useState(true);
const checkPermissions = async () => {
if (isLoading) {
return;
}
if (error) {
setIsChecking(false);
return;
}
if (auth) {
logger.debug('[GuestGuard]: User is logged in, redirecting to dashboard');
window.location.replace(paths.dashboard.overview);
return;
}
setIsChecking(false);
};
React.useEffect(() => {
checkPermissions().catch(() => {
// noop
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- Expected
}, [auth, error, isLoading]);
if (isChecking) {
return null;
}
if (error) {
return <Alert color="error">{error}</Alert>;
}
return <React.Fragment>{children}</React.Fragment>;
}

View File

@@ -0,0 +1,122 @@
import * as React from 'react';
import {useState} from 'react';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import {paths} from "../../path";
import TextField from "@mui/material/TextField";
import TelegramIcon from '@mui/icons-material/Telegram';
import IconButton from "@mui/material/IconButton";
import Button from "@mui/material/Button";
import CircularProgress from "@mui/material/CircularProgress";
import Box from "@mui/material/Box";
import {red} from "@mui/material/colors";
import {useAuth} from "../../hooks/useAuth";
import {authClient} from "../../lib/clients/AuthClient";
const emptyRequest = {
byLogin: true,
code: "",
login: "",
password: ""
}
export function SignInForm() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [request, setRequest] = useState(emptyRequest);
const [pass, setPass] = useState(true)
const {checkSession} = useAuth();
const buttonSx = {
minWidth: "300px",
...(error && {
bgcolor: red[500],
'&:hover': {
bgcolor: red[700],
},
}),
};
const handleButtonClick = async () => {
await authClient.login(request, setLoading, setError, checkSession)
}
const renderByCode = () => {
return (
<Stack direction='row' mt={1}>
<IconButton href={paths.auth.bot} target="_blank" color='primary'>
<TelegramIcon/>
</IconButton>
<TextField value={request.code}
onChange={(e) => setRequest((prevState) => ({
...prevState,
code: e.target.value
}))}
sx={{minWidth: 300}} id="outlined-basic" label="Код подтверждения"
variant="outlined"/>
</Stack>
)
}
const renderByLogin = () => {
return (
<Stack mt={1} spacing={2}>
<TextField value={request.login}
onChange={(e) => setRequest(prevState => ({
...prevState,
login: e.target.value
}))}
sx={{minWidth: 300}} id="loginField" label="Логин"
variant="outlined"/>
<TextField value={request.password}
onChange={(e) => setRequest((prevState) => ({
...prevState,
password: e.target.value
}))}
sx={{minWidth: 300}} id="passwordField" label="Пароль" type="password"
autoComplete="current-password" variant="outlined"/>
</Stack>
)
}
return (
<Stack spacing={4} sx={{marginBottom: '85%', marginTop: '45%'}}>
<Stack spacing={1}>
<Typography variant="h4">Авторизация</Typography>
<Typography variant='body1' component="a" href='#'
onClick={() => {
setRequest((prevState) => ({
...prevState,
byLogin: !pass
}))
setPass(!pass)
}}>
{pass ? "Вход по телеграмм-коду" : "Вход по логину и паролю"}
</Typography>
<Typography color="text.secondary" variant="body2">
{pass ? "Введите логин и пароль"
: "Для входа нужно всего лишь сказать об этом моему Telegram-боту, перейди по ссылке и набери \n/start"}
</Typography>
{pass ? renderByLogin() : renderByCode()}
<Box sx={{display: 'flex', alignItems: 'center', ml: '40px', mt: 1}}>
<Stack>
{error && (
<Typography mb={1} color={'error'}>{error}</Typography>
)}
<Button
variant="contained"
sx={buttonSx}
disabled={loading}
onClick={handleButtonClick}
>
{loading ? (
<CircularProgress
size={25}
/>
) : "Войти"}
</Button>
</Stack>
</Box>
</Stack>
</Stack>
);
}

View File

@@ -0,0 +1,68 @@
import * as React from 'react';
import OutlinedInput from '@mui/material/OutlinedInput';
import InputLabel from '@mui/material/InputLabel';
import MenuItem from '@mui/material/MenuItem';
import FormControl from '@mui/material/FormControl';
import ListItemText from '@mui/material/ListItemText';
import Select from '@mui/material/Select';
import Checkbox from '@mui/material/Checkbox';
const ITEM_HEIGHT = 48;
const ITEM_PADDING_TOP = 8;
const MenuProps = {
PaperProps: {
style: {
maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP,
width: 250,
},
},
};
export default function CheckMarks({rows, name, filterValue, handleChange, filterName, width, nonMulti, nullValue, identity}) {
const realValue = !nonMulti ? filterValue.filter((v) => v.length > 0) : filterValue;
return (
<div>
<FormControl sx={{m: 1, width: !width ? 300 : width}}>
<InputLabel>{name}</InputLabel>
<Select
multiple={!nonMulti}
value={realValue}
onChange={(e) => handleChange(filterName, e.target.value)}
input={<OutlinedInput label={name}/>}
renderValue={(selected) => !nonMulti ? selected.join(", ") : selected}
MenuProps={MenuProps}
defaultChecked={nonMulti && rows[0]}
variant="filled">
{(nonMulti && nullValue) && (
<MenuItem value={""}>
<em>Не выбрано</em>
</MenuItem>
)}
{rows.map((value) => {
if(identity) {
return (
<MenuItem key={"menuItemIn" + value} value={value}>
{!nonMulti && (
<Checkbox
checked={realValue.includes(value)}/>
)}
<ListItemText primary={value}/>
</MenuItem>
)
} else {
return (
<MenuItem key={value.id} value={value.name}>
{!nonMulti && (
<Checkbox
checked={realValue.includes(value.name)}/>
)}
<ListItemText primary={value.name}/>
</MenuItem>
)
}
})}
</Select>
</FormControl>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import {CardActions, CardContent, CardMedia, Rating} from "@mui/material";
import Typography from "@mui/material/Typography";
import Grid from "@mui/material/Grid";
import {CocktailItemStyled} from "./CocktailItemStyled";
import IconButton from "@mui/material/IconButton";
import FavoriteBorderIcon from '@mui/icons-material/FavoriteTwoTone';
import FavoriteIcon from '@mui/icons-material/Favorite';
import Box from "@mui/material/Box";
import {CocktailDescription} from "./CocktailDescription";
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import LocalBarIcon from '@mui/icons-material/LocalBar';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import {paths} from "../../path";
import {useAlert} from "../../hooks/useAlert";
import {useUser} from "../../hooks/useUser";
import {cocktailClient} from "../../lib/clients/CocktailClient";
function renderFavouriteBadge(handleFavourite, row) {
const childIcon = row.rating.favourite ? <FavoriteIcon/> : <FavoriteBorderIcon/>;
return (
<IconButton size={'small'}
onClick={() => handleFavourite(row)}>
{childIcon}
</IconButton>
)
}
function renderRating(handleChangeRating, row) {
return (
<Rating
name="simple-controlled"
value={row.rating.rating}
onChange={(event, newValue) => handleChangeRating(row, newValue)}
/>
)
}
export function Cocktail({row, handleFavourite, handleChangeRating, handleSelect, deleteHandler, hideHandler}) {
const {createError, createSuccess} = useAlert();
const {user} = useUser();
return (
<Grid item sx={{pr: 2}}>
<CocktailItemStyled>
<Box sx={{
p: '4px 4px',
m: 1,
width: '320px',
position: 'relative',
}}>
<CardMedia
sx={{
loading: "eager",
borderRadius: 2
}}
onClick={() => handleSelect(row)}
component="img"
alt={row.name}
height="300"
image={row.image.includes("thecocktaildb") ? (row.image + "/preview") : row.image}
/>
<CardActions>
<IconButton sx={{m: 0}} size='small'
onClick={() => cocktailClient.drinkCocktail(row.id, createSuccess, createError)}>
<LocalBarIcon fontSize='small'/>
</IconButton>
{renderFavouriteBadge(handleFavourite, row)}
{renderRating(handleChangeRating, row)}
{
(user.role && user.role !== 'USER') &&
<>
<IconButton size='small' href={`${paths.bar.cocktailEdit}?id=${row.id}`}>
<EditIcon fontSize='small'/>
</IconButton>
<IconButton size='small' onClick={() => hideHandler(row.id)}>
<VisibilityOffIcon fontSize='small'/>
</IconButton>
<IconButton size='small' onClick={() => deleteHandler(row)}>
<DeleteIcon fontSize='small'/>
</IconButton>
</>
}
</CardActions>
<CardContent sx={{pb: 0, pl: 2, pt: 0}}>
<Typography variant="h5" minHeight={'50px'} mt={2}>{row.name} </Typography>
<CocktailDescription row={row}/>
</CardContent>
</Box>
</CocktailItemStyled>
</Grid>
)
}

View File

@@ -0,0 +1,32 @@
import ListItem from "@mui/material/ListItem";
import ListItemText from "@mui/material/ListItemText";
import List from "@mui/material/List";
export function CocktailDescription({row}) {
return (
<List sx={{py: '0px'}}>
{row.hasError && (
<ListItem sx={{p: '2px 12px 0px 0px', m: '0px'}}>
<ListItemText color={'red'}>Имеет ошибку в рецепте или ингредиентах</ListItemText>
</ListItem>
)}
<ListItem sx={{p: '2px 12px 0px 0px', m: '0px'}}>
<ListItemText>{"Категория: " + row.category}</ListItemText>
</ListItem>
<ListItem sx={{p: '2px 12px 0px 0px', m: '0px'}}>
<ListItemText>{"Алкоголь: " + row.alcoholic}</ListItemText>
</ListItem>
{row.volume !== null && (
<ListItem sx={{p: '2px 12px 0px 0px', m: '0px'}}>
<ListItemText>{"Крепость: ≈" + row.volume}</ListItemText>
</ListItem>
)}
<ListItem sx={{p: '2px 12px 0px 0px', m: '0px'}}>
<ListItemText>{"Подача: " + row.glass}</ListItemText>
</ListItem>
<ListItem sx={{p: '2px 12px 0px 0px', m: '0px'}}>
<ListItemText>{"Состав: " + row.components}</ListItemText>
</ListItem>
</List>
)
}

View File

@@ -0,0 +1,124 @@
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import DialogActions from "@mui/material/DialogActions";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import * as React from "react";
import {useEffect, useState} from "react";
import {CardMedia} from "@mui/material";
import Paper from "@mui/material/Paper";
import Box from "@mui/material/Box";
import StarBorderIcon from '@mui/icons-material/StarBorder';
import IconButton from "@mui/material/IconButton";
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
import DeleteIcon from '@mui/icons-material/Delete';
import {IngredientInfoModal} from "../Ingredients/IngredientInfoModal";
import {useAlert} from "../../hooks/useAlert";
import {paths} from "../../path";
import {Loading} from "../core/Loading";
import {useUser} from "../../hooks/useUser";
import {useSelect} from "../../hooks/useSelect";
import {cocktailClient} from "../../lib/clients/CocktailClient";
import {ingredientClient} from "../../lib/clients/IngredientClient";
export function CocktailInfoModal({row}) {
const {user} = useUser();
const {getError, createError, createSuccess} = useAlert();
const [cocktail, setCocktail] = useState(null)
const [loading, setLoading] = useState(false);
const {closeCocktail, selectIngredient, getIngredient, getOpenCocktail} = useSelect();
// eslint-disable-next-line
useEffect(() => cocktailClient.getCocktailForModal(row, setLoading, setCocktail, closeCocktail, getError), [row]);
if (!row || !cocktail) {
return null;
}
return (
<Dialog fullWidth={true}
open={getOpenCocktail()} onClose={closeCocktail}
sx={{
'& .MuiDialog-paper': {
margin: '8px',
},
'& .MuiPaper-root': {
width: 'calc(100% - 16px)',
}
}}>
<IngredientInfoModal ingredient={getIngredient()}/>
<Loading loading={loading}/>
<DialogTitle>
<Stack direction='row' justifyContent={'space-between'}>
<Box>{cocktail.name}</Box>
{cocktail.rating.rating > 0 &&
(
<Stack ml={3} direction='row'>
{`${cocktail.rating.rating}/5`}
<StarBorderIcon sx={{pb: "2px"}}/>
</Stack>
)
}
</Stack>
</DialogTitle>
<DialogContent>
<CardMedia
image={cocktail.image.includes("thecocktaildb") ? (cocktail.image + "/preview") : cocktail.image}
sx={{
loading: "eager",
borderRadius: 2
}}
component="img"
alt={cocktail.name}
height="300"
/>
<Box mt={1}>
<Typography>Ингредиенты:</Typography>
<Paper sx={{p: 1}} elevation={3}>
<Stack>
{cocktail.receipt.map((r) => {
const hasError = r.count === null || r.unit === null;
const measure = hasError ? r.measure : (r.count + " " + r.unit.name)
return (
<Stack key={r.ingredient.id} direction='row' justifyContent={'space-between'}
mt={1}>
<Stack direction='row'>
{(user.role && user.role !== "USER") && (
<IconButton size="small" sx={{pb: "2px"}}
onClick={() => ingredientClient.changeIngredientInBar(r.ingredient, cocktail, setCocktail, createSuccess, createError)}>
{r.ingredient.isHave
? (<DeleteIcon fontSize="small"/>)
: (<ShoppingCartIcon fontSize="small"/>)
}
</IconButton>
)}
<Typography
onClick={() => ingredientClient.findOne(r.ingredient.id, selectIngredient, createError)}>{r.ingredient.name}</Typography>
</Stack>
<Typography color={hasError && 'red'}>{measure}</Typography>
</Stack>
)
})}
</Stack>
</Paper>
</Box>
<Box>
<Typography mt={2}>Инструкция:</Typography>
<Paper sx={{p: 1}} elevation={3}>
<Box>
{cocktail.instructions}
</Box>
</Paper>
</Box>
</DialogContent>
<DialogActions>
{(user.role && user.role.includes("ADMIN")) && (
<Button href={`${paths.bar.cocktailEdit}?id=${cocktail.id}`}>Редактировать</Button>
)}
<Button onClick={closeCocktail}>Закрыть</Button>
</DialogActions>
</Dialog>
)
}

View File

@@ -0,0 +1,12 @@
import {styled} from "@mui/material/styles";
import {Card} from "@mui/material";
export const CocktailItemStyled = styled(Card)(({theme}) => ({
backgroundColor: '#fff',
...theme.typography.body2,
padding: theme.spacing(1),
color: theme.palette.text.secondary,
...theme.applyStyles('dark', {
backgroundColor: '#1A2027',
})
}));

View File

@@ -0,0 +1,44 @@
import {Card, FormControlLabel} from "@mui/material";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton";
import InfoRoundedIcon from "@mui/icons-material/InfoRounded";
import {paths} from "../../path";
import EditIcon from "@mui/icons-material/Edit";
import React from "react";
import Switch from "@mui/material/Switch";
export function CocktailListCard({row, changeHandler, infoHandler}) {
return (
<Card sx={{mb: 1, height: '130px', display: 'relative', pt: 1, borderRadius: '15px'}}>
<Stack direction='row' justifyContent='start' alignItems='start'>
<Box sx={{width: '100px', height: '100px', ml: 1}}>
<img src={row.image} loading='eager' height={'100px'} width={'100px'} alt={row.id}
style={{borderRadius: '20%'}}/>
</Box>
<Stack sx={{width: 'calc(95% - 100px)', pr: 2, ml: 1}}>
<Box>{row.name}</Box>
<FormControlLabel sx={{mt: 5, pr: 2}}
onClick={() => changeHandler(row, !row.inMenu)}
value="bottom"
control={
<Switch color="primary" checked={row.inMenu}/>
}
label="В меню"
labelPlacement="start"
/>
</Stack>
<Stack direction='row'>
<Stack sx={{width: '5%', mt: 2}} spacing={1} justifyContent='flex-start'>
<IconButton size='small' onClick={() => infoHandler(row)}>
<InfoRoundedIcon/>
</IconButton>
<IconButton size='small' href={`${paths.bar.cocktailEdit}?id=${row.id}`}>
<EditIcon/>
</IconButton>
</Stack>
</Stack>
</Stack>
</Card>
)
}

View File

@@ -0,0 +1,76 @@
import {useMemo, useState} from "react";
import {getComparator} from "../core/getComparator";
import {Accordion, AccordionDetails, AccordionSummary} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
import {CocktailListCard} from "./CocktailListCard";
import {groupByForLoop} from "../core/groupByForLoop";
export function CocktailsList({rows, grouping, changeHandler, infoHandler}) {
const [size, setSize] = useState(20);
window.addEventListener('scroll', () => {
if (window.innerHeight + window.scrollY >= (document.documentElement.scrollHeight - 100)) {
if (!grouping) {
setSize(size + 10)
}
}
});
const visibleRows = useMemo(() => {
let res = [];
if (rows.length === 0) {
return null;
}
if (!grouping) {
return rows
.sort(getComparator("asc", "name"))
.slice(0, size)
.map((row) => {
return (
<CocktailListCard row={row} key={row.id}
changeHandler={changeHandler} infoHandler={infoHandler}/>
)
})
}
const group = groupByForLoop(rows, "category")
if (!group || group.size === 0) {
return null;
}
Array.from(group.keys())
.sort(getComparator())
.map((key) => {
const list = group.get(key);
res.push(
<Accordion key={key} sx={{borderRadius: '5px', mb: 1}}>
<AccordionSummary
expandIcon={<ExpandMoreIcon/>}
aria-controls="panel1-content"
id="panel1-header"
>
<Typography component="span">{key}</Typography>
</AccordionSummary>
<AccordionDetails sx={{p: 1}}>
{list.sort(getComparator("asc", "name"))
.map((row) => {
return (
<CocktailListCard row={row} key={row.id}
changeHandler={changeHandler} infoHandler={infoHandler}/>
)
})}
</AccordionDetails>
</Accordion>
)
})
return res;
// eslint-disable-next-line
}, [size, rows])
return (
<Box mt={2}>
{visibleRows}
</Box>
)
}

View File

@@ -0,0 +1,162 @@
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import AddIcon from "@mui/icons-material/Add";
import * as React from "react";
import {useEffect, useState} from "react";
import {useAlert} from "../../hooks/useAlert";
import {Card} from "@mui/material";
import {SelectEdit} from "./SelectEdit";
import TextField from "@mui/material/TextField";
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
import {ingredientClient} from "../../lib/clients/IngredientClient";
export function EditCocktailReceipt({receipt, handler}) {
const {createError} = useAlert()
const [ingredients, setIngredients] = useState([]);
const [units, setUnits] = useState([])
useEffect(() => {
ingredientClient.findAll(setIngredients, createError)
ingredientClient.findUnit(setUnits, createError)
// eslint-disable-next-line
}, []);
const selectHandler = (name, value) => {
const ing = ingredients.find((i) => i.name === value)
const newState = receipt.map((r, i) => {
if (i !== name) {
return r;
}
return {
id: r.id,
ingredient: {
id: ing.id,
isHave: ing.have,
name: ing.name
},
measure: r.measure
}
})
handler("receipt", newState);
checkAllowed(newState);
}
const unitHandler = (name, value) => {
const ing = units.find((i) => i.name === value)
const newState = receipt.map((r, i) => {
if (i !== name) {
return r;
}
return {
id: r.id,
ingredient: r.ingredient,
unit: ing,
count: r.count,
measure: r.measure
}
})
handler("receipt", newState);
checkAllowed(newState);
}
const removeHandler = (index) => {
const arr = receipt.filter((r, i) => i !== index)
handler("receipt", arr)
checkAllowed(arr)
}
const addHandler = () => {
const oldState = receipt;
oldState.push({
id: null,
ingredient: {
id: null,
isHave: false,
name: ""
},
measure: ""
});
handler("receipt", oldState);
checkAllowed(oldState);
}
const checkAllowed = (state) => {
handler("isAllowed", !state.map((r) => r.ingredient.isHave).includes(false))
}
const measureHandler = (index, value) => {
const newState = receipt.map((r, i) => {
if (index !== i) {
return r
}
return {
...r,
measure: value
}
})
handler("receipt", newState)
}
const countHandler = (index, value) => {
const newState = receipt.map((r, i) => {
if (index !== i) {
return r
}
return {
...r,
count: value
}
})
handler("receipt", newState)
}
return (
<Box mb={2}>
{/*Заголовок*/}
<Stack direction='row' justifyContent={'space-between'} sx={{mr: 1}}>
<Typography ml={1} mt={1}>Рецепт</Typography>
<IconButton onClick={() => addHandler()}>
<AddIcon/>
</IconButton>
</Stack>
{/*Рецепт*/}
<Stack sx={{mr: 1}}>
{receipt.map((r, i) => {
return (
<Card key={i} sx={{ml: 0, mb: 1}}>
<Stack>
<Stack direction='row'>
<SelectEdit width={'calc(65% - 28px)'} array={ingredients} value={r.ingredient.name}
handler={selectHandler} label={"Ингредиент"}
margin={1} attributeName={i}
/>
<TextField sx={{width: 'calc(35% - 28px)', mt: 1}}
label={"Кол-во"}
variant="outlined"
disabled
value={r.measure}
onChange={(e) => measureHandler(i, e.target.value)}
/>
<IconButton sx={{mt: 2}}
onClick={() => removeHandler(i)}
>
<DeleteForeverIcon/>
</IconButton>
</Stack>
<Stack direction='row' ml={1} mb={1}>
<TextField sx={{width: 'calc(35% - 28px)', mt: 1}}
label={"Кол-во"}
variant="outlined"
value={r.count}
onChange={(e) => countHandler(i, e.target.value)}
/>
<SelectEdit width={'calc(65% - 28px)'} array={units}
value={!r.unit ? null : r.unit.name}
handler={unitHandler} label={"Ед."}
margin={1} attributeName={i}
/>
</Stack>
</Stack>
</Card>
)
})}
</Stack>
</Box>
)
}

View File

@@ -0,0 +1,105 @@
import {Card, FormControl, FormControlLabel, InputAdornment, InputLabel, OutlinedInput} from "@mui/material";
import IconButton from "@mui/material/IconButton";
import SearchIcon from "@mui/icons-material/Search";
import Tooltip from "@mui/material/Tooltip";
import FilterListIcon from "@mui/icons-material/FilterList";
import Box from "@mui/material/Box";
import Grid from "@mui/material/Grid";
import Switch from "@mui/material/Switch";
import CheckMarks from "./CheckMarks";
import Button from "@mui/material/Button";
import * as React from "react";
import {useEffect, useState} from "react";
import {useAlert} from "../../hooks/useAlert";
import {categoryClient} from "../../lib/clients/CategoryClient";
import {glassClient} from "../../lib/clients/GlassClient";
export function FilterBlock({filter, handleFilterChange, handleClearFilter, barmen}) {
const {createError} = useAlert();
const [glass, setGlass] = useState([]);
const [category, setCategory] = useState([]);
const alcohol = ['Алкогольный', 'Безалкогольный'];
const ingredientCount = [1, 2, 3, 4, 5];
const sort = ['Название по возрастанию', 'Название по убыванию'];
useEffect(() => {
categoryClient.getCategoryList(setCategory, createError)
glassClient.getGlassList(setGlass, createError)
// eslint-disable-next-line
}, []);
return (
<Card>
{/*Строка поиска*/}
<FormControl sx={{m: 1, width: '300px'}}>
<InputLabel htmlFor="outlined-adornment-amount">Поиск</InputLabel>
<OutlinedInput
onChange={(e) => handleFilterChange("search", e.target.value)}
label="With normal TextField"
startAdornment={
<InputAdornment position="start">
<IconButton edge="end">
<SearchIcon/>
</IconButton>
</InputAdornment>
}
/>
</FormControl>
{/*Кнопка открытия фильтров*/}
<Tooltip title="Filter list">
<IconButton onClick={() => handleFilterChange("hidden", !filter.hidden)}>
<FilterListIcon/>
</IconButton>
</Tooltip>
{/*Блок сортировки*/}
<Box hidden={filter.hidden}>
<Grid container>
{/*Фильтр по алкогольности*/}
<CheckMarks rows={sort} name={"Сортировать по..."} handleChange={handleFilterChange}
filterValue={filter.sorting} filterName={"sorting"} nonMulti identity/>
</Grid>
</Box>
{/*Блок фильтров*/}
<Box hidden={filter.hidden}>
<Grid container>
{/*Фильтр по избранным*/}
<FormControlLabel
control={
<Switch inputProps={{'aria-label': 'controlled'}}
onChange={() => handleFilterChange("onlyFavourite", !filter.onlyFavourite)}
/>
}
label="Только избранные"
sx={{ml: 1}}
/>
{/*Фильтр по избранным*/}
<FormControlLabel
control={
<Switch inputProps={{'aria-label': 'controlled'}}
onChange={() => handleFilterChange("all", !filter.all)}
/>
}
label={!filter.all ? "Полный список" : "Только доступные"}
sx={{ml: 1}}
/>
{/*Фильтр по алкогольности*/}
<CheckMarks rows={alcohol} name={"Алкогольность"} handleChange={handleFilterChange}
filterValue={filter.alcohol} filterName={"alcohol"} identity/>
{/*Фильтр по категории*/}
{category.length > 0 && (
<CheckMarks rows={category} name={"Категории"} filterValue={filter.category}
filterName={"category"} handleChange={handleFilterChange}/>)}
{/*Фильтр по посуде*/}
{glass.length > 0 && (<CheckMarks rows={glass} name={"Подача"} handleChange={handleFilterChange}
filterValue={filter.glass} filterName={"glass"}/>)}
{/*Фильтр по нехватке ингредиентов*/}
{(barmen && filter.all) && (<CheckMarks rows={ingredientCount} name={"Не хватает ингредиентов"}
handleChange={handleFilterChange}
nonMulti nullValue identity
filterValue={filter.iCount} filterName={"iCount"}/>)}
<Button onClick={() => handleClearFilter()}>Сбросить</Button>
</Grid>
</Box>
</Card>
)
}

View File

@@ -0,0 +1,17 @@
import Grid from "@mui/material/Grid";
import {Stack} from "@mui/material";
import Typography from "@mui/material/Typography";
import {CocktailItemStyled} from "./CocktailItemStyled";
export function NoResult({load}) {
return (
<Grid item lg={4} md={6} sm={12} xl={3} hidden={!load}>
<CocktailItemStyled>
<Stack align="center" sx={{width: "350px"}}>
<Typography variant="h5" minHeight={'50px'} mt={2}>Нет результатов</Typography>
<Typography>Попробуйте заглянуть позднее</Typography>
</Stack>
</CocktailItemStyled>
</Grid>
)
}

View File

@@ -0,0 +1,26 @@
import {FormControl, InputLabel} from "@mui/material";
import Select from "@mui/material/Select";
import MenuItem from "@mui/material/MenuItem";
import * as React from "react";
export function SelectEdit({label, value, array, handler, attributeName, width, margin}) {
return (
<FormControl sx={{width: width, m: margin}}>
<InputLabel>{label}</InputLabel>
<Select
autoWidth
label={label}
value={!value ? "" : value}
onChange={(e) => handler(attributeName, e.target.value)}
>
<MenuItem value="">
<em>None</em>
</MenuItem>
{array.map((c) => {
return (<MenuItem key={c.id} value={c.name}>{c.name}</MenuItem>)
})}
</Select>
</FormControl>
)
}

View File

@@ -0,0 +1,27 @@
export const sortList = [
{
id: "name|asc",
name: "Название по возрастанию"
},
{
id: "name|desc",
name: "Название по убыванию"
},
// todo: добавить сортировки в беке
// {
// id: "rating.rating|desc",
// name: "Сначала с оценкой"
// },
// {
// id: "rating.rating|asc",
// name: "Сначала без оценки"
// },
// {
// id: "rating.favourite|desc",
// name: "Сначала избранные"
// },
// {
// id: "rating.favourite|asc",
// name: "Сначала не избранные"
// }
]

View File

@@ -0,0 +1,12 @@
import {Backdrop, CircularProgress} from "@mui/material";
export function Loading({loading}) {
return (
<Backdrop
sx={{color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1}}
open={loading}
>
<CircularProgress color="inherit"/>
</Backdrop>
);
}

View File

@@ -0,0 +1,10 @@
import {AdapterDayjs} from '@mui/x-date-pickers/AdapterDayjs';
import {LocalizationProvider as Provider} from '@mui/x-date-pickers/LocalizationProvider';
import * as React from 'react';
import 'dayjs/locale/ru'
export function LocalizationProvider({children}) {
return (
<Provider dateAdapter={AdapterDayjs} adapterLocale="ru-Ru">{children}</Provider>
);
}

View File

@@ -0,0 +1,38 @@
'use client';
import * as React from 'react';
import Box from '@mui/material/Box';
import { useColorScheme } from '@mui/material/styles';
import {NoSsr} from "./NoSsr";
const HEIGHT = 60;
const WIDTH = 60;
export function Logo({ color = 'dark', emblem, height = HEIGHT, width = WIDTH }) {
let url;
if (emblem) {
url = color === 'light' ? '/assets/logo-emblem.svg' : '/assets/logo-emblem--dark.svg';
} else {
url = color === 'light' ? '/assets/logo.svg' : '/assets/logo--dark.svg';
}
return <Box alt="logo" component="img" height={height} src={url} width={width} />;
}
export function DynamicLogo({
colorDark = 'light',
colorLight = 'dark',
height = HEIGHT,
width = WIDTH,
...props
}) {
const { colorScheme } = useColorScheme();
const color = colorScheme === 'dark' ? colorDark : colorLight;
return (
<NoSsr fallback={<Box sx={{ height: `${height}px`, width: `${width}px` }} />}>
<Logo color={color} height={height} width={width} {...props} />
</NoSsr>
);
}

View File

@@ -0,0 +1,25 @@
import * as React from 'react';
import useEnhancedEffect from '@mui/utils/useEnhancedEffect';
export function NoSsr(props) {
const {children, defer = false, fallback = null} = props;
const [mountedState, setMountedState] = React.useState(false);
useEnhancedEffect(() => {
if (!defer) {
setMountedState(true);
}
}, [defer]);
React.useEffect(() => {
if (defer) {
setMountedState(true);
}
}, [defer]);
return (
<>
{mountedState ? children : fallback}
</>
)
}

View File

@@ -0,0 +1,24 @@
import PropTypes from "prop-types";
import * as React from "react";
export function CustomTabPanel(props) {
const {children, value, index, ...other} = props;
return (
<div
role="tabpanel"
hidden={value !== index}
id={`simple-tabpanel-${index}`}
aria-labelledby={`simple-tab-${index}`}
{...other}
>
{value === index && children}
</div>
);
}
CustomTabPanel.propTypes = {
children: PropTypes.node,
index: PropTypes.number.isRequired,
value: PropTypes.number.isRequired,
};

View File

@@ -0,0 +1,81 @@
import {styled, useColorScheme} from "@mui/material/styles";
import Switch from "@mui/material/Switch";
import React from "react";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
export function ThemeSwitch() {
const {mode, setMode} = useColorScheme();
return (
<Box
sx={{
alignItems: 'center',
// backgroundColor: 'var(--mui-palette-neutral-950)',
border: '1px solid var(--mui-palette-neutral-700)',
borderRadius: '12px',
cursor: 'pointer',
display: 'flex',
p: '4px 12px',
}}
>
<StyledSwitch
checked={mode === 'dark'}
onChange={(e) => setMode(e.target.checked ? 'dark' : 'light')}
inputProps={{'aria-label': 'controlled'}}
/>
<Box sx={{flex: '1 1 auto'}}>
<Typography color="inherit" variant="subtitle1">
{(mode === 'dark' ? "Темная " : "Светлая ") + "тема"}
</Typography>
</Box>
</Box>
)
}
const StyledSwitch = styled(Switch)(({theme}) => ({
width: 62,
height: 34,
padding: 7,
'& .MuiSwitch-switchBase': {
margin: 1,
padding: 0,
transform: 'translateX(6px)',
'&.Mui-checked': {
color: '#fff',
transform: 'translateX(22px)',
'& .MuiSwitch-thumb:before': {
backgroundImage: `url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="20" width="20" viewBox="0 0 20 20"><path fill="${encodeURIComponent(
'#fff',
)}" d="M4.2 2.5l-.7 1.8-1.8.7 1.8.7.7 1.8.6-1.8L6.7 5l-1.9-.7-.6-1.8zm15 8.3a6.7 6.7 0 11-6.6-6.6 5.8 5.8 0 006.6 6.6z"/></svg>')`,
},
'& + .MuiSwitch-track': {
opacity: 1,
backgroundColor: theme.palette.mode === 'dark' ? '#8796A5' : '#aab4be',
},
},
},
'& .MuiSwitch-thumb': {
backgroundColor: theme.palette.mode === 'dark' ? '#003892' : '#001e3c',
width: 32,
height: 32,
'&::before': {
content: "''",
position: 'absolute',
width: '100%',
height: '100%',
left: 0,
top: 0,
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
backgroundImage: `url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="20" width="20" viewBox="0 0 20 20"><path fill="${encodeURIComponent(
'#fff',
)}" d="M9.305 1.667V3.75h1.389V1.667h-1.39zm-4.707 1.95l-.982.982L5.09 6.072l.982-.982-1.473-1.473zm10.802 0L13.927 5.09l.982.982 1.473-1.473-.982-.982zM10 5.139a4.872 4.872 0 00-4.862 4.86A4.872 4.872 0 0010 14.862 4.872 4.872 0 0014.86 10 4.872 4.872 0 0010 5.139zm0 1.389A3.462 3.462 0 0113.471 10a3.462 3.462 0 01-3.473 3.472A3.462 3.462 0 016.527 10 3.462 3.462 0 0110 6.528zM1.665 9.305v1.39h2.083v-1.39H1.666zm14.583 0v1.39h2.084v-1.39h-2.084zM5.09 13.928L3.616 15.4l.982.982 1.473-1.473-.982-.982zm9.82 0l-.982.982 1.473 1.473.982-.982-1.473-1.473zM9.305 16.25v2.083h1.389V16.25h-1.39z"/></svg>')`,
},
},
'& .MuiSwitch-track': {
opacity: 1,
backgroundColor: theme.palette.mode === 'dark' ? '#8796A5' : '#aab4be',
borderRadius: 20 / 2,
},
}));

View File

@@ -0,0 +1,101 @@
import * as React from 'react';
import Box from '@mui/material/Box';
import Divider from '@mui/material/Divider';
import ListItemIcon from '@mui/material/ListItemIcon';
import MenuItem from '@mui/material/MenuItem';
import MenuList from '@mui/material/MenuList';
import Popover from '@mui/material/Popover';
import Typography from '@mui/material/Typography';
import {SignOut as SignOutIcon} from '@phosphor-icons/react/dist/ssr/SignOut';
import {SignIn as SignInIcon} from '@phosphor-icons/react/dist/ssr/SignIn';
import {logger} from "../../lib/DefaultLogger";
import {useAuth} from "../../hooks/useAuth";
import {authClient} from "../../lib/clients/AuthClient";
import {useLocation} from "react-router-dom";
import {useUser} from "../../hooks/useUser";
import {paths} from "../../path";
export function UserPopover({anchorEl, onClose, open}) {
const {checkSession} = useAuth();
const {user, session} = useUser();
const location = useLocation();
const handleSignOut = React.useCallback(async () => {
try {
const {error} = await authClient.signOut();
if (error) {
logger.error('Sign out error', error);
return;
}
// Refresh the auth state
await checkSession?.();
// UserProvider, for this case, will not refresh the router and we need to do it manually
window.location.reload();
// After refresh, AuthGuard will handle the redirect
} catch (err) {
logger.error('Sign out error', err);
}
// eslint-disable-next-line
}, [checkSession, location]);
return (
<Popover
anchorEl={anchorEl}
anchorOrigin={{horizontal: 'left', vertical: 'bottom'}}
onClose={onClose}
open={open}
slotProps={{paper: {sx: {width: '240px'}}}}
>
<Box sx={{p: '16px 20px '}}>
{userDescriptor(user, session)}
</Box>
<Divider/>
<MenuList disablePadding sx={{p: '8px', '& .MuiMenuItem-root': {borderRadius: 1}}}>
{/*<MenuItem component={'a'} href={paths.dashboard.settings} onClick={onClose}>*/}
{/* <ListItemIcon>*/}
{/* <GearSixIcon fontSize="var(--icon-fontSize-md)"/>*/}
{/* </ListItemIcon>*/}
{/* Настройки*/}
{/*</MenuItem>*/}
{/*<MenuItem component={'a'} href={paths.dashboard.account} onClick={onClose}>*/}
{/* <ListItemIcon>*/}
{/* <UserIcon fontSize="var(--icon-fontSize-md)"/>*/}
{/* </ListItemIcon>*/}
{/* Профиль*/}
{/*</MenuItem>*/}
{!user.name ? <MenuItem onClick={() => window.location.replace(paths.auth.signIn)}>
<ListItemIcon>
<SignInIcon fontSize="var(--icon-fontSize-md)"/>
</ListItemIcon>
Вход
</MenuItem> :
<MenuItem onClick={handleSignOut}>
<ListItemIcon>
<SignOutIcon fontSize="var(--icon-fontSize-md)"/>
</ListItemIcon>
Выход
</MenuItem>
}
</MenuList>
</Popover>
);
}
function userDescriptor(user) {
if (!user) {
return null;
}
if (!user.name) {
return (<Typography variant="subtitle1">Гость</Typography>);
}
return (
<>
<Typography variant="subtitle1">{user.name + " " + user.lastName}</Typography>
<Typography color="text.secondary" variant="body2">{user.id}</Typography>
</>
);
}

View File

@@ -0,0 +1,29 @@
export function descendingComparator(a, b, orderBy, lastOrder) {
if (getValue(b, orderBy) < getValue(a, orderBy)) {
return -1;
}
if (getValue(b, orderBy) > getValue(a, orderBy)) {
return 1;
}
if (lastOrder && orderBy !== lastOrder) {
if (getValue(b, lastOrder) < getValue(a, lastOrder)) {
return 1;
}
if (getValue(b, lastOrder) > getValue(a, lastOrder)) {
return -1;
}
}
return 0;
}
function getValue(obj, orderBy) {
if (!orderBy) {
return obj;
}
const split = orderBy.split(".")
let res = obj[split[0]];
for (let i = 1; i < split.length; i++) {
res = res[split[i]];
}
return res;
}

View File

@@ -0,0 +1,10 @@
import {descendingComparator} from "./descendingComparator";
export function getComparator(order, orderBy, lastOrder) {
if(!order) {
order = "asc"
}
return order === 'desc'
? (a, b) => descendingComparator(a, b, orderBy, lastOrder)
: (a, b) => -descendingComparator(a, b, orderBy, lastOrder);
}

View File

@@ -0,0 +1,14 @@
export const groupByForLoop = (arr, prop) => {
const grouped = new Map();
for (let i = 0; i < arr.length; i++) {
let key = arr[i][prop];
if (!key) {
key = "Без категории"
}
if (!grouped.has(key)) {
grouped.set(key, []);
}
grouped.get(key).push(arr[i]);
}
return grouped;
};

View File

@@ -0,0 +1,39 @@
import {ChartPie as ChartPieIcon} from '@phosphor-icons/react/dist/ssr/ChartPie';
import {GearSix as GearSixIcon} from '@phosphor-icons/react/dist/ssr/GearSix';
import {PlugsConnected as PlugsConnectedIcon} from '@phosphor-icons/react/dist/ssr/PlugsConnected';
import {User as UserIcon} from '@phosphor-icons/react/dist/ssr/User';
import {Users as UsersIcon} from '@phosphor-icons/react/dist/ssr/Users';
import {XSquare} from '@phosphor-icons/react/dist/ssr/XSquare';
import {
Basket,
BookOpen,
Books,
Cheers,
CoffeeBean,
Coins,
Martini,
Storefront,
Users,
Wallet,
Calculator
} from "@phosphor-icons/react";
export const navIcons = {
'menu': BookOpen,
'list': Books,
'storefront': Storefront,
'wallet': Wallet,
'cocktail': Martini,
'visitors': Users,
'orders': Cheers,
'basket': Basket,
'coins': Coins,
'ingredients': CoffeeBean,
'chart-pie': ChartPieIcon,
'gear-six': GearSixIcon,
'plugs-connected': PlugsConnectedIcon,
'calc': Calculator,
'x-square': XSquare,
user: UserIcon,
users: UsersIcon,
}

View File

@@ -0,0 +1,6 @@
export function a11yProps(index) {
return {
id: `simple-tab-${index}`,
'aria-controls': `simple-tabpanel-${index}`,
};
}

View File

@@ -0,0 +1,55 @@
import * as React from 'react';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import {List as ListIcon} from '@phosphor-icons/react/dist/ssr/List';
// import NotificationsIcon from '@mui/icons-material/Notifications';
import {usePopover} from "../../hooks/usePopover";
import {MobileNav} from "./MobileNav";
import {UserPopover} from "../core/UserPopover";
// import Tooltip from "@mui/material/Tooltip";
// import {Badge} from "@mui/material";
// import {useAlert} from "../../hooks/useAlert";
export function MainNav() {
const [openNav, setOpenNav] = React.useState(false);
const userPopover = usePopover();
return (
<>
<Box
component="header"
sx={{
borderBottom: '1px solid var(--mui-palette-divider)',
backgroundColor: 'var(--mui-palette-background-paper)',
position: 'sticky',
top: 0,
zIndex: 'var(--mui-zIndex-appBar)',
height: '64px'
}}
>
<Stack direction="row" spacing={3}
sx={{alignItems: 'center', justifyContent: 'space-between', minHeight: '64px', px: 2}}>
<Stack sx={{alignItems: 'center'}} direction="row" spacing={3}>
<IconButton onClick={() => setOpenNav(true)} sx={{display: {xl: 'none'}}}>
<ListIcon/>
</IconButton>
</Stack>
<Stack sx={{alignItems: 'center'}} direction="row" spacing={2}>
<Avatar onClick={userPopover.handleOpen} ref={userPopover.anchorRef} src="/assets/avatar.png"
sx={{cursor: 'pointer'}}/>
</Stack>
</Stack>
</Box>
<UserPopover anchorEl={userPopover.anchorRef.current} onClose={userPopover.handleClose}
open={userPopover.open}/>
<MobileNav
onClose={() => {
setOpenNav(false);
}}
open={openNav}
/>
</>
);
}

View File

@@ -0,0 +1,38 @@
import * as React from 'react';
import Drawer from '@mui/material/Drawer';
import {NavigationMenu} from "./NavigationMenu";
//Боковое меню
export function MobileNav({open, onClose}) {
return (
<Drawer
PaperProps={{
sx: {
'--MobileNav-background': 'var(--mui-palette-neutral-950)',
'--MobileNav-color': 'var(--mui-palette-common-white)',
'--NavItem-color': 'var(--mui-palette-neutral-300)',
'--NavItem-hover-background': 'rgba(255, 255, 255, 0.04)',
'--NavItem-active-background': 'var(--mui-palette-primary-main)',
'--NavItem-active-color': 'var(--mui-palette-primary-contrastText)',
'--NavItem-disabled-color': 'var(--mui-palette-neutral-500)',
'--NavItem-icon-color': 'var(--mui-palette-neutral-400)',
'--NavItem-icon-active-color': 'var(--mui-palette-primary-contrastText)',
'--NavItem-icon-disabled-color': 'var(--mui-palette-neutral-600)',
bgcolor: 'var(--MobileNav-background)',
color: 'var(--MobileNav-color)',
display: 'flex',
flexDirection: 'column',
maxWidth: '100%',
scrollbarWidth: 'none',
width: 'var(--MobileNav-width)',
zIndex: 'var(--MobileNav-zIndex)',
'&::-webkit-scrollbar': {display: 'none'},
},
}}
onClose={onClose}
open={open}
>
<NavigationMenu/>
</Drawer>
);
}

View File

@@ -0,0 +1,74 @@
import Stack from "@mui/material/Stack";
import {isNavItemActive} from "./isNavItemActive";
import {navIcons} from "../core/navIcons";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import {Link} from "react-router-dom";
export function renderNavItems({items = [], pathname}) {
const children = items.reduce((acc, curr) => {
const {key, ...item} = curr;
acc.push(<NavItem key={key} pathname={pathname} {...item} />);
return acc;
}, []);
return (
<Stack key={"stack-NavItem-key"} component="ul" spacing={1} sx={{listStyle: 'none', m: 0, p: 0}}>
{children}
</Stack>
);
}
function NavItem({disabled, external, href, icon, matcher, pathname, title}) {
const active = isNavItemActive({disabled, external, href, matcher, pathname});
const Icon = icon ? navIcons[icon] : null;
return (
<li>
<Link to={href} style={{ textDecoration: 'none' }}>
<Box
sx={{
alignItems: 'center',
borderRadius: 1,
color: 'var(--NavItem-color)',
cursor: 'pointer',
display: 'flex',
flex: '0 0 auto',
gap: 1,
p: '6px 16px',
position: 'relative',
textDecoration: 'none',
whiteSpace: 'nowrap',
...(disabled && {
bgcolor: 'var(--NavItem-disabled-background)',
color: 'var(--NavItem-disabled-color)',
cursor: 'not-allowed',
}),
...(active && {
bgcolor: 'var(--NavItem-active-background)',
color: 'var(--NavItem-active-color)'
}),
}}
>
<Box sx={{alignItems: 'center', display: 'flex', justifyContent: 'center', flex: '0 0 auto'}}>
{Icon ? (
<Icon
fill={active ? 'var(--NavItem-icon-active-color)' : 'var(--NavItem-icon-color)'}
fontSize="var(--icon-fontSize-md)"
weight={active ? 'fill' : undefined}
/>
) : null}
</Box>
<Box sx={{flex: '1 1 auto'}}>
<Typography
component="span"
sx={{color: 'inherit', fontSize: '0.875rem', fontWeight: 500, lineHeight: '28px'}}
>
{title}
</Typography>
</Box>
</Box>
</Link>
</li>
);
}

View File

@@ -0,0 +1,54 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import {ThemeSwitch} from "../core/ThemeSwitch";
import Divider from "@mui/material/Divider";
import {renderNavItems} from "./NavItem";
import {navItems} from "../../navItems";
import React, {useEffect, useState} from "react";
import {useLocation} from "react-router-dom";
import {useUser} from "../../hooks/useUser";
import Typography from "@mui/material/Typography";
function renderSpecialItems(items, label, pathname) {
return (
<Box>
{renderNavItems({items: items, pathname: pathname})}
</Box>
)
}
export function NavigationMenu() {
const location = useLocation();
const pathname = location.pathname;
const {user} = useUser();
const [items, setItems] = useState(null)
const userChild = navItems.filter((item) => !item.forBarmen && !item.forAdmin)
const barmenChild = navItems.filter((item) => item.forBarmen)
const adminChild = navItems.filter((item) => item.forAdmin)
useEffect(() => {
const role = !user ? "USER" : Object.keys(user).length === 0 ? "USER" : user.role
const newState = (
<Box component="nav" sx={{flex: '1 1 auto', p: '12px'}}>
{renderNavItems({items: userChild, pathname: pathname})}
{role !== "USER" && renderSpecialItems(barmenChild, "Для бармена:", pathname)}
{role === "ADMIN" && renderSpecialItems(adminChild, "Для админа", pathname)}
</Box>
)
setItems(newState)
// eslint-disable-next-line
}, [user, pathname]);
return (
<>
{/*верхняя стопка*/}
<Stack spacing={2} sx={{p: 2, height: '63px'}}>
<ThemeSwitch/>
</Stack>
<Divider sx={{borderColor: 'var(--mui-palette-neutral-700)'}}/>
{/*меню навигации*/}
{items}
</>
)
}

View File

@@ -0,0 +1,37 @@
import * as React from 'react';
import Box from '@mui/material/Box';
import {NavigationMenu} from "./NavigationMenu";
export function SideNav() {
return (
<Box
sx={{
'--SideNav-background': 'var(--mui-palette-neutral-950)',
'--SideNav-color': 'var(--mui-palette-common-white)',
'--NavItem-color': 'var(--mui-palette-neutral-300)',
'--NavItem-hover-background': 'rgba(255, 255, 255, 0.04)',
'--NavItem-active-background': 'var(--mui-palette-primary-main)',
'--NavItem-active-color': 'var(--mui-palette-primary-contrastText)',
'--NavItem-disabled-color': 'var(--mui-palette-neutral-500)',
'--NavItem-icon-color': 'var(--mui-palette-neutral-400)',
'--NavItem-icon-active-color': 'var(--mui-palette-primary-contrastText)',
'--NavItem-icon-disabled-color': 'var(--mui-palette-neutral-600)',
bgcolor: 'var(--SideNav-background)',
color: 'var(--SideNav-color)',
display: {xs: 'none', xl: 'flex'},
flexDirection: 'column',
height: '100%',
left: 0,
maxWidth: '100%',
position: 'fixed',
scrollbarWidth: 'none',
top: 0,
width: 'var(--SideNav-width)',
zIndex: 'var(--SideNav-zIndex)',
'&::-webkit-scrollbar': {display: 'none'},
}}
>
<NavigationMenu/>
</Box>
);
}

View File

@@ -0,0 +1,25 @@
export function isNavItemActive({
disabled,
external,
href,
matcher,
pathname,
}) {
if (disabled || !href || external) {
return false;
}
if (matcher) {
if (matcher.type === 'startsWith') {
return pathname.startsWith(matcher.href);
}
if (matcher.type === 'equals') {
return pathname === matcher.href;
}
return false;
}
return pathname === href;
}

View File

@@ -0,0 +1,49 @@
import * as React from 'react';
import {useCallback, useEffect} from 'react';
import {logger} from "../lib/DefaultLogger";
import {tokenUtil} from "../lib/clients/TokenUtil";
export const AuthContext = React.createContext(undefined);
export function AuthProvider({children}) {
const [state, setState] = React.useState({
auth: false,
error: "",
isLoading: true,
});
const checkSession = useCallback(async () => {
try {
if (!await tokenUtil.checkToken(tokenUtil.getToken())) {
setState((prev) => ({...prev, auth: false, error: '', isLoading: false}));
return;
}
setState((prev) => ({...prev, auth: true, error: "", isLoading: false}));
} catch (err) {
logger.error(err);
setState((prev) => ({...prev, auth: false, error: 'Что-то пошло не так', isLoading: false}));
}
updater().then();
// eslint-disable-next-line
}, []);
useEffect(() => {
checkSession()
.catch((err) => {
logger.error(err);
});
// eslint-disable-next-line
}, []);
const updater = async () => {
await new Promise((resolve) => setTimeout(resolve, 1000 * 60 * 60));
checkSession()
.catch((err) => {
logger.error(err)
})
}
return <AuthContext.Provider value={{...state}}>{children}</AuthContext.Provider>;
}
export const AuthConsumer = AuthContext.Consumer;

View File

@@ -0,0 +1,61 @@
import * as React from "react";
export const SelectContext = React.createContext(undefined);
export function SelectProvider({children}) {
const [selected, setSelected] = React.useState({
cocktail: null,
ingredient: null
});
const selectCocktail = (row) => {
setSelected((prev) => ({
...prev,
cocktail: row
}))
}
const getCocktail = () => {
return selected.cocktail
}
const getOpenCocktail = () => {
return selected.cocktail !== null;
}
const closeCocktail = () => {
setSelected((prevState) => ({
...prevState,
cocktail: null,
}))
}
const selectIngredient = (row) => {
setSelected((prev) => ({
...prev,
ingredient: row
}))
}
const closeIngredient = () => {
setSelected((prevState) => ({
...prevState,
ingredient: null
}))
}
const getIngredient = () => {
return selected.ingredient
}
const getOpenIngredient = () => {
return selected.ingredient !== null
}
return <SelectContext.Provider value={{...selected,
selectCocktail,
getCocktail,
getOpenCocktail,
closeCocktail,
selectIngredient,
closeIngredient,
getIngredient,
getOpenIngredient
}}>{children}</SelectContext.Provider>;
}
export const SelectConsumer = SelectContext.Consumer;

View File

@@ -0,0 +1,55 @@
import * as React from "react";
import {createContext, useCallback, useEffect, useState} from "react";
import {logger} from "../lib/DefaultLogger";
import {userClient} from "../lib/clients/UserClient";
import {tokenUtil} from "../lib/clients/TokenUtil";
export const UserContext = createContext(undefined);
export function UserProvider({children}) {
const refresh = () => {
checkSession()
.catch((err) => logger.error(err))
}
const [state, setState] = useState({
user: {},
session: {},
error: "",
isLoading: true,
refresh: refresh
});
const checkSession = useCallback(async () => {
try {
setState((prev) => ({...prev, isLoading: true}));
if (!tokenUtil.checkToken(tokenUtil.getToken())) {
setState((prev) => ({...prev, error: '', isLoading: false, user: {}}));
return;
}
if (Object.keys(state.user).length === 0) {
const {data, errorData} = await userClient.getMe();
if (errorData) {
setState((prev) => ({...prev, error: errorData, isLoading: false, user: {}}));
return;
}
setState((prev) => ({...prev, error: "", isLoading: false, user: data}));
}
} catch (err) {
logger.error(err);
setState((prev) => ({...prev, error: 'Что-то пошло не так', isLoading: false, user: {}}));
}
}, [state]);
useEffect(() => {
checkSession()
.catch((err) => {
logger.error(err);
});
// eslint-disable-next-line
}, []);
return <UserContext.Provider value={{...state}}>{children}</UserContext.Provider>;
}
export const UserConsumer = UserContext.Consumer;

View File

@@ -0,0 +1,36 @@
import {useSnackbar} from "notistack";
export function useAlert() {
// variant could be success, error, warning, info, or default
const {enqueueSnackbar} = useSnackbar();
function createAlert(message, variant) {
const options = {
...variant,
anchorOrigin: {vertical: 'top', horizontal: 'right'},
}
enqueueSnackbar(message, options);
}
function notImplement() {
createAlert("Данный функционал пока не реализован", {variant: 'warning'});
}
function createWarning(message) {
createAlert(message, {variant: 'warning'})
}
function createError(message) {
createAlert(message, {variant: "error"});
}
function getError() {
createAlert("Ошибка получения данных", {variant: "error"});
}
function createSuccess(message) {
createAlert(message, {variant: "success"});
}
return {createAlert, notImplement, createError, getError, createSuccess, createWarning}
}

View File

@@ -0,0 +1,14 @@
import * as React from 'react';
import {AuthContext} from "../context/AuthContext";
export function useAuth() {
const context = React.useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within a AuthProvider');
}
window.auth = context;
return context;
}

View File

@@ -0,0 +1,20 @@
import * as React from 'react';
export function usePopover() {
const anchorRef = React.useRef(null);
const [open, setOpen] = React.useState(false);
const handleOpen = React.useCallback(() => {
setOpen(true);
}, []);
const handleClose = React.useCallback(() => {
setOpen(false);
}, []);
const handleToggle = React.useCallback(() => {
setOpen((prevState) => !prevState);
}, []);
return { anchorRef, handleClose, handleOpen, handleToggle, open };
}

View File

@@ -0,0 +1,14 @@
import * as React from "react";
import {SelectContext} from "../context/SelectContext";
export function useSelect() {
const context = React.useContext(SelectContext);
if (!context) {
throw new Error('useSelect must be used within a SelectProvider');
}
window.select = context;
return context;
}

View File

@@ -0,0 +1,47 @@
import * as React from 'react';
// IMPORTANT: To prevent infinite loop, `keys` argument must be memoized with React.useMemo hook.
export function useSelection(keys) {
const [selected, setSelected] = React.useState(new Set());
React.useEffect(() => {
setSelected(new Set());
}, [keys]);
const handleDeselectAll = React.useCallback(() => {
setSelected(new Set());
}, []);
const handleDeselectOne = React.useCallback((key) => {
setSelected((prev) => {
const copy = new Set(prev);
copy.delete(key);
return copy;
});
}, []);
const handleSelectAll = React.useCallback(() => {
setSelected(new Set(keys));
}, [keys]);
const handleSelectOne = React.useCallback((key) => {
setSelected((prev) => {
const copy = new Set(prev);
copy.add(key);
return copy;
});
}, []);
const selectedAny = selected.size > 0;
const selectedAll = selected.size === keys.length;
return {
deselectAll: handleDeselectAll,
deselectOne: handleDeselectOne,
selectAll: handleSelectAll,
selectOne: handleSelectOne,
selected,
selectedAny,
selectedAll,
};
}

View File

@@ -0,0 +1,13 @@
import * as React from "react";
import {UserContext} from "../context/UserContext";
export function useUser() {
const context = React.useContext(UserContext);
if (!context) {
throw new Error('useUser must be used within a UserProvider');
}
window.user = context;
return context;
}

13
front/src/index.css Normal file
View File

@@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

13
front/src/index.js Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './app/App';
import './index.css';
import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<App/>
);

View File

@@ -0,0 +1,4 @@
import {createLogger} from "./Logger";
import {config} from "../Config";
export const logger = createLogger({ level: config.logLevel });

65
front/src/lib/Logger.js Normal file
View File

@@ -0,0 +1,65 @@
/* eslint-disable no-console -- Allow */
// NOTE: A tracking system such as Sentry should replace the console
export const LogLevel = {NONE: 'NONE', ERROR: 'ERROR', WARN: 'WARN', DEBUG: 'DEBUG', ALL: 'ALL'};
const LogLevelNumber = {NONE: 0, ERROR: 1, WARN: 2, DEBUG: 3, ALL: 4};
export class Logger {
prefix;
level;
showLevel;
levelNumber;
constructor({prefix = '', level = LogLevel.ALL, showLevel = true}) {
this.prefix = prefix;
this.level = level;
this.levelNumber = LogLevelNumber[this.level];
this.showLevel = showLevel;
}
debug = (...args) => {
if (this.canWrite(LogLevel.DEBUG)) {
this.write(LogLevel.DEBUG, ...args);
}
};
warn = (...args) => {
if (this.canWrite(LogLevel.WARN)) {
this.write(LogLevel.WARN, ...args);
}
};
error = (...args) => {
if (this.canWrite(LogLevel.ERROR)) {
this.write(LogLevel.ERROR, ...args);
}
};
canWrite(level) {
return this.levelNumber >= LogLevelNumber[level];
}
write(level, ...args) {
let prefix = this.prefix;
if (this.showLevel) {
prefix = `- ${level} ${prefix}`;
}
if (level === LogLevel.ERROR) {
console.error(prefix, ...args);
} else {
console.log(prefix, ...args);
}
}
}
// This can be extended to create context specific logger (Server Action, Router Handler, etc.)
// to add context information (IP, User-Agent, timestamp, etc.)
export function createLogger({prefix, level} = {}) {
return new Logger({prefix, level});
}

View File

@@ -0,0 +1,45 @@
import {api} from "./api";
import {requests} from "../../requests";
class AuthClient {
async signOut() {
localStorage.removeItem("token");
return {};
}
async login(request, setLoading, setError, checkSession) {
setLoading(true);
const response = await api().post(requests.auth.login, request);
if (response.data.error) {
setError(response.data.error);
setLoading(false);
return;
}
localStorage.setItem("token", response.data.token);
await checkSession?.();
window.location.reload();
}
loginByCode(code, checkSession) {
const request = {
byLogin: false,
code: code
}
api().post(requests.auth.login, request)
.then(async (response) => {
if (response.data.error) {
return;
}
localStorage.setItem("token", response.data.token);
await checkSession?.();
window.location.reload();
})
}
}
export const authClient = new AuthClient();

Some files were not shown because too many files have changed in this diff Show More