1 Commits

Author SHA1 Message Date
d979aab7cb Ošetření pádu aplikace při selhání food.api 2023-06-13 19:32:21 +02:00
135 changed files with 11285 additions and 14208 deletions

26
.gitignore vendored
View File

@@ -1,2 +1,26 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
types/gen
/.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*
__pycache__
venv

View File

@@ -1,64 +0,0 @@
variables:
- &node_image "node:22-alpine"
- &branch "master"
when:
- event: push
branch: *branch
steps:
- name: Generate TypeScript types
image: *node_image
commands:
- cd types
- yarn install --frozen-lockfile
- yarn openapi-ts
- name: Install server dependencies
image: *node_image
commands:
- cd server
- yarn install --frozen-lockfile
depends_on: [Generate TypeScript types]
- name: Install client dependencies
image: *node_image
commands:
- cd client
- yarn install --frozen-lockfile
depends_on: [Generate TypeScript types]
- name: Build server
depends_on: [Install server dependencies]
image: *node_image
commands:
- cd server
- yarn build
- name: Build client
depends_on: [Install client dependencies]
image: *node_image
commands:
- cd client
- yarn build
- name: Build Docker image
depends_on: [Build server, Build client]
image: woodpeckerci/plugin-docker-buildx
settings:
dockerfile: Dockerfile-Woodpecker
platforms: linux/amd64
registry:
from_secret: REPO_URL
username:
from_secret: REPO_USERNAME
password:
from_secret: REPO_PASSWORD
repo:
from_secret: REPO_NAME
- name: Discord notification - build
image: appleboy/drone-discord
depends_on: [Build Docker image]
when:
- status: [success, failure]
settings:
webhook_id:
from_secret: DISCORD_WEBHOOK_ID
webhook_token:
from_secret: DISCORD_WEBHOOK_TOKEN
message: "{{#success build.status}}✅ Sestavení {{build.number}} proběhlo úspěšně.{{else}}❌ Sestavení {{build.number}} selhalo.{{/success}}\n\nPipeline: {{build.link}}\nPoslední commit: {{commit.message}}Autor: {{commit.author}}"

View File

@@ -1,93 +0,0 @@
ARG NODE_VERSION="node:22-alpine"
# Builder
FROM ${NODE_VERSION} AS builder
WORKDIR /build
# Zkopírování závislostí - OpenAPI generátor
COPY types/package.json ./types/
COPY types/yarn.lock ./types/
COPY types/api.yml ./types/
COPY types/schemas ./types/schemas/
COPY types/paths ./types/paths/
COPY types/openapi-ts.config.ts ./types/
# Zkopírování závislostí - server
COPY server/package.json ./server/
COPY server/yarn.lock ./server/
# Zkopírování závislostí - klient
COPY client/package.json ./client/
COPY client/yarn.lock ./client/
# Instalace závislostí - OpenAPI generátor
WORKDIR /build/types
RUN yarn install --frozen-lockfile
# Instalace závislostí - server
WORKDIR /build/server
RUN yarn install --frozen-lockfile
# Instalace závislostí - klient
WORKDIR /build/client
RUN yarn install --frozen-lockfile
WORKDIR /build
# Zkopírování build závislostí - server
COPY server/tsconfig.json ./server/
COPY server/src ./server/src/
# Zkopírování build závislostí - klient
COPY client/tsconfig.json ./client/
COPY client/vite.config.ts ./client/
COPY client/vite-env.d.ts ./client/
COPY client/index.html ./client/
COPY client/src ./client/src
COPY client/public ./client/public
# Zkopírování společných typů
COPY types/index.ts ./types/
# Vygenerování společných typů z OpenAPI
WORKDIR /build/types
RUN yarn openapi-ts
# Sestavení serveru
WORKDIR /build/server
RUN yarn build
# Sestavení klienta
WORKDIR /build/client
RUN yarn build
# Runner
FROM ${NODE_VERSION}
RUN apk add --no-cache tzdata
ENV TZ=Europe/Prague \
LC_ALL=cs_CZ.UTF-8 \
NODE_ENV=production
WORKDIR /app
# Vykopírování sestaveného serveru
COPY --from=builder /build/server/node_modules ./server/node_modules
COPY --from=builder /build/server/dist ./
# Vykopírování sestaveného klienta
COPY --from=builder /build/client/dist ./public
# Zkopírování produkčních .env serveru
COPY /server/.env.production ./server
# Zkopírování konfigurace easter eggů
RUN if [ -f /server/.easter-eggs.json ]; then cp /server/.easter-eggs.json ./server/; fi
# Export /data/db.json do složky /data
VOLUME ["/data"]
EXPOSE 3000
CMD [ "node", "./server/src/index.js" ]

View File

@@ -1,26 +0,0 @@
ARG NODE_VERSION="node:22-alpine"
FROM ${NODE_VERSION}
RUN apk add --no-cache tzdata
ENV TZ=Europe/Prague \
LC_ALL=cs_CZ.UTF-8 \
NODE_ENV=production
WORKDIR /app
# Vykopírování sestaveného serveru
COPY ./server/node_modules ./server/node_modules
COPY ./server/dist ./
# TODO tohle není dobře, má to být součástí serveru
# COPY ./server/resources ./resources
# Vykopírování sestaveného klienta
COPY ./client/dist ./public
# Zkopírování konfigurace easter eggů
RUN if [ -f ./server/.easter-eggs.json ]; then cp ./server/.easter-eggs.json ./server/; fi
EXPOSE 3000
CMD [ "node", "./server/src/index.js" ]

View File

@@ -1,29 +1,30 @@
# Luncher
Aplikace pro profesionální management obědů.
Aplikace sestává ze tří modulů.
- types
- OpenAPI definice společných typů, generované přes [openapi-ts](https://github.com/hey-api/openapi-ts)
Aplikace sestává ze tří (čtyř) modulů.
- food_api
- Python scraper/parser pro zpracování obědových menu restaurací
- server
- backend psaný v [node.js](https://nodejs.dev)
- client
- frontend psaný v [React.js](https://react.dev)
- [nginx](https://nginx.org)
- proxy pro snadné propojení Docker kontejnerů pod jednou URL
## Spuštění pro vývoj
### Závislosti
#### Food API
- [Python 3](https://www.python.org)
- [pip](https://pypi.org/project/pip)
#### Klient/server
- [Node.js 22.x (>= 22.11)](https://nodejs.dev)
- [Node.js 18.x](https://nodejs.dev)
- [Yarn 1.22.x (Classic)](https://classic.yarnpkg.com)
### Spuštění na *nix platformách
- Nainstalovat závislosti viz předchozí bod
- Zkopírovat `client/.env.template` do `client/.env.development` a upravit dle potřeby
- Zkopírovat `server/.env.template` do `server/.env.development` a upravit dle potřeby
- Vygenerovat společné TypeScript typy
- `cd types && yarn install && yarn openapi-ts`
- Server
- `cd server && yarn install && export NODE_ENV=development && yarn startReload`
- Klient
- `cd client && yarn install && yarn start`
- Spustit `./run_dev.sh`. Na jiných platformách se lze inspirovat jeho obsahem, postup by měl být víceméně stejný.
## Sestavení a spuštění produkční verze v Docker
### Závislosti
@@ -33,8 +34,30 @@ Aplikace sestává ze tří modulů.
### Spuštění
- `docker compose up --build -d`
### Spuštení s traefik
- `docker compose -f compose-traefik.yml up --build -d`
## TODO
Dostupné [zde](TODO.md).
- [x] Umožnit smazání aktuální volby "popelnicí", místo nutnosti vybrat prázdnou položku v selectu
- [x] Přívětivější možnost odhlašování
- [x] Vyřešit responzivní design pro použití na mobilu
- [x] Vyndat URL na Food API do .env
- [x] Neselhat při nedostupnosti nebo chybě z Food API
- [x] Dokončit docker-compose pro kompletní funkčnost
- [x] Implementovat Pizza day
- [x] Umožnit uzamčení objednávek zakladatelem
- [x] Možnost uložení čísla účtu
- [ ] Automatické generování a zobrazení QR kódů
- [ ] https://qr-platba.cz/pro-vyvojare/restful-api/
- [ ] Zobrazovat celkovou cenu objednávky pod tabulkou objednávek
- [ ] Zobrazit upozornění před smazáním/zamknutím/odemknutím pizza day
- [ ] Umožnit přidat k objednávce poznámku (např. "bez oliv")
- [ ] Předvyplnění poslední vybrané hodnoty občas nefunguje, viz komentář
- [ ] Nasazení nové verze v Docker smaže veškerá data (protože data.json není venku)
- [ ] Vylepšit dokumentaci projektu
- [ ] Popsat Food API, nginx
- [x] Popsat závislosti, co je nutné provést před vývojem a postup spuštění pro vývoj
- [x] Popsat dostupné env
- [ ] Pizzy se samy budou při naklikání přidávat do košíku
- [ ] Nutno nejprve vyřešit předávání PHPSESSIONID cookie na pizzachefie.cz pomocí fetch()
- [ ] Přesunout autentizaci na server (JWT?)
- [x] Zavést .env.template a přidat .env do .gitignore
- [ ] Zkrášlit dialog pro vyplnění čísla účtu, vypadá mizerně
- [ ] Podpora pro notifikace v externích systémech (Gotify, Discord, MS Teams)

73
TODO.md
View File

@@ -1,73 +0,0 @@
# TODO
- [ ] HTTP_REMOTE_TRUSTED_IPS se nikde nevalidují, hlavičky jsou přijímány odkudkoli
- [ ] V případě zapnutí přihlašování přes trusted headers nefunguje standardní přihlášení (nevrátí žádnou odpověď)
- [ ] Nemělo by se jít dostat na přihlašovací formulář (měla by tam být nanejvýš hláška nebo přesměrování)
- [ ] Možnost úhrady celé útraty jednou osobou
- Základní myšlenka: jedna osoba uhradí celou útratu (v zájmu rychlosti odbavení), ostatním se automaticky vygeneruje QR kód, kterým následně uhradí svoji část útraty
- Obecně to bude problém např. pokud si někdo objedná něco navíc (pití apod.)
- [ ] Tlačítko "Uhradit" u každého řádku podniku - platí ten, kdo kliknul
- [ ] Zobrazeno bude pouze, pokud má daný uživatel nastaveno číslo účtu
- [ ] Dialog pro zadání spropitného, které se následně rozpočte rovnoměrně všem strávníkům
- [ ] Generování a zobrazení QR kódů ostatním strávníkům
- [ ] Umožnit u každého strávníka připočíst vlastní částku (např. za pití)
- [ ] Umožnit (např. zaškrtávátky) vybrat, za koho bude zaplaceno (pokud někdo bude platit zvlášť)
- [ ] Podpora pro notifikace v externích systémech (Gotify, Discord, MS Teams)
- [ ] Umožnit zadat URL/tokeny uživatelem
- [ ] Umožnit uživatelsky konfigurovat typy notifikací, které se budou odesílat
- [ ] Zavést notifikace typu "Jdeme na oběd"
- [ ] Notifikaci dostanou pouze uživatelé, kteří mají vybranou stejnou lokalitu
- [ ] Vylepšit parsery restaurací
- [ ] Sladovnická
- [ ] Zbytečná prvotní validace indexu, datum konkrétního dne je i v samotné tabulce s jídly, viz TODO v parseru
- [ ] U Motlíků
- [ ] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (např. '12.6.-16.6.')
- [ ] Jídelní lístek se stahuje jednou každý den, teoreticky by stačilo jednou týdně (za předpokladu, že se během týdne nemění)
- [ ] TechTower
- [ ] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (typicky 'Obědy 12. 6. - 16. 6. 2023 (každý den vždy i obědový bufet)')
- [ ] Jídelní lístek se stahuje v rámci prvního požadavku daný den, ale často se jídelní lístek na stránkách aktualizuje až v průběhu pondělního dopoledne a ten zobrazený je proto neaktuální
- Stránka neposílá hlavičku o času poslední modifikace, takže o to se nelze opřít
- Nevím aktuálně jak řešit jinak, než častějším scrapováním celé stránky
- [X] Někdy jsou v názvech jídel přebytečné mezery kolem čárek ( , )
- [ ] Nasazení nové verze v Docker smaže veškerá data (protože data.json není vystrčený ven z kontejneru)
- [ ] Zavést složku /data
- [ ] Mazat z databáze data z minulosti, aktuálně je to k ničemu
- [ ] Skripty pro snadné spuštění vývoje na Windows (ekvivalent ./run_dev.sh)
- [ ] Implementovat Pizza day
- [ ] Zobrazit upozornění před smazáním/zamknutím/odemknutím pizza day
- [ ] Pizzy se samy budou při naklikání přidávat do košíku
- [ ] Nutno nejprve vyřešit předávání PHPSESSIONID cookie na pizzachefie.cz pomocí fetch()
- [ ] Ceny krabic za pizzu jsou napevno v kódu - problém, pokud se někdy změní
- [X] Umožnit u Pizza day ručně připočíst cenu za přísady
- [X] Prvotní načtení pizz při založení Pizza Day trvá a nic se během toho nezobrazuje (např. loader)
- [X] Po doručení zobrazit komu zaplatit (kdo objednával)
- [x] Zbytečně nescrapovat každý den pizzy z Pizza Chefie, dokud není založen Pizza Day
- [x] Umožnit uzamčení objednávek zakladatelem
- [x] Možnost uložení čísla účtu
- [x] Automatické generování a zobrazení QR kódů
- [x] https://qr-platba.cz/pro-vyvojare/restful-api/
- [x] Zobrazovat celkovou cenu objednávky pod tabulkou objednávek
- [x] Umožnit přidat k objednávce poznámku (např. "bez oliv")
- [x] Negenerovat QR kód pro objednávajícího
- [X] Možnost náhledu na ostatní dny v týdnu (např. pomocí šipek)
- [X] Možnost výběru oběda na následující dny v týdnu
- [X] Umožnit vybrat libovolný čas odchodu
- [X] Validace zadání smysluplného času (ideálně i klientská)
- [x] Umožnit smazání aktuální volby "popelnicí", místo nutnosti vybrat prázdnou položku v selectu
- [x] Přívětivější možnost odhlašování
- [x] Vyřešit responzivní design pro použití na mobilu
- [x] Vyndat URL na Food API do .env
- [x] Neselhat při nedostupnosti nebo chybě z Food API
- [x] Dokončit docker-compose pro kompletní funkčnost
- [x] Vylepšit dokumentaci projektu
- [x] Popsat závislosti, co je nutné provést před vývojem a postup spuštění pro vývoj
- [x] Popsat dostupné env
- [x] Přesunout autentizaci na server (JWT?)
- [x] Zavést .env.template a přidat .env do .gitignore
- [x] Zkrášlit dialog pro vyplnění čísla účtu, vypadá mizerně
- [x] Zbavit se Food API, potřebnou funkcionalitu zahrnout do serveru
- [x] Vyřešit API mezi serverem a klientem, aby nebyl v obou projektech duplicitní kód (viz types.ts a Types.tsx)
- [X] Vybraná jídla strávníků zobrazovat v samostatném sloupci
- [X] Umožnit výběr/zadání preferovaného času odchodu na oběd
- Hodí se např. pokud má někdo schůzky
- [X] Ukládat dostupné pizzy do DB místo souborů
- [X] Ukládat jídla do DB místo souborů

3
client/.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
**/node_modules
**/npm-debug.log
build

3
client/.env.template Normal file
View File

@@ -0,0 +1,3 @@
# Veřejná URL, na které bude dostupný klient (typicky přes proxy).
# Pro vývoj není potřeba, bude použita výchozí hodnota http://localhost:3001
# PUBLIC_URL=http://example:3001

2
client/.gitignore vendored
View File

@@ -1,2 +1,2 @@
build
dist
.env.production

23
client/Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM node:18-alpine3.18 AS builder
COPY package.json .
COPY yarn.lock .
COPY tsconfig.json .
COPY .env.production .
RUN yarn install
COPY ./src ./src
COPY ./public ./public
RUN yarn build
FROM node:18-alpine3.18
ENV NODE_ENV production
WORKDIR /app
COPY --from=builder /build .
RUN yarn global add serve && yarn
CMD ["serve", "-s", "."]

2
client/build.sh Executable file
View File

@@ -0,0 +1,2 @@
#!/bin/bash
docker build -t luncher-client .

View File

@@ -1,21 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Moderní webová aplikace pro lepší správu obědových preferencí" />
<link rel="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" />
<title>Luncher</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

View File

@@ -1,45 +1,42 @@
{
"name": "@luncher/client",
"name": "luncher-client",
"version": "0.1.0",
"license": "MIT",
"private": true,
"type": "module",
"homepage": ".",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.1.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"bootstrap": "^5.3.8",
"react": "^19.2.0",
"react-bootstrap": "^2.10.10",
"react-dom": "^19.2.0",
"react-jwt": "^1.3.0",
"react-modal": "^3.16.3",
"react-router": "^7.9.5",
"react-router-dom": "^7.9.5",
"@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/free-regular-svg-icons": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fortawesome/react-fontawesome": "^0.2.0",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.23",
"@types/react": "^18.0.33",
"@types/react-dom": "^18.0.11",
"bootstrap": "^5.2.3",
"react": "^18.2.0",
"react-bootstrap": "^2.7.2",
"react-dom": "^18.2.0",
"react-modal": "^3.16.1",
"react-scripts": "5.0.1",
"react-select-search": "^4.1.6",
"react-snowfall": "^2.3.0",
"react-toastify": "^11.0.5",
"recharts": "^3.4.1",
"sass": "^1.93.3",
"react-toastify": "^9.1.3",
"socket.io-client": "^4.6.1",
"typescript": "^5.9.3",
"vite": "^7.2.2",
"vite-tsconfig-paths": "^5.1.4"
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "yarn vite",
"build": "tsc --noEmit && yarn vite build"
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app"
"react-app",
"react-app/jest"
]
},
"browserslist": {
@@ -55,7 +52,6 @@
]
},
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"prettier": "^3.6.2"
"prettier": "^2.8.8"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

43
client/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="Moderní webová aplikace pro lepší správu obědových preferencí" />
<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>Luncher</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>

View File

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="100px" height="100px" viewBox="1 0 100 100" enable-background="new 1 0 100 100" xml:space="preserve">
<path fill="#8B4513" d="M31.5,77.2c0,0,2.6-1.9,3.4-2.8c0.8-0.9,2-2.5,2-2.5s-4.7-0.7-7.9-1.6c-3.2-0.9-9.7-3.7-9.7-3.7
s1.3-0.4,2.9-1.4c1.6-1.1,2-1.7,2-1.7s-4.7-1.3-7.5-2.4c-2.8-1-7.3-2.9-7.3-2.9s1.4-0.2,2.8-1.3c1.4-1.1,1.5-2.2,1.5-2.2
S9.4,53.3,7,51.9c-2.4-1.5-5.3-3.7-5.3-3.7s5.2-4,8.7-5.5c3.5-1.5,9.1-2.6,9.1-2.6s-0.9-1.5-2.1-2.6c-1.1-1.1-4.2-2.9-4.2-2.9
s5.6-1.9,8.3-2.5c2.7-0.6,8.2,0,8.2,0s-0.1-1.1-1.3-2.2c-1.2-1.1-2.7-2-2.7-2S33,25.4,37,24.4c4-1,10.8-0.6,10.8-0.6
S46.1,22.1,45,21c-1.1-1.1-2.7-2.3-2.7-2.3s5.4-0.4,9.1-0.4c3.7,0.1,9.5,1.1,9.5,1.1s-0.7-1-1.4-2.3c-0.8-1.3-1.5-2.6-1.5-2.6
s7.1,1.7,9.5,2.8c2.4,1,7.6,3.9,7.6,3.9s-0.1-1.8-1-3.5c-0.9-1.7-1.8-3-1.8-3s27.7,17.2,28.1,33.5c0.1,1.8-0.1,3.7-0.3,5.5
c-1.5-0.7-3.1-1.3-4.7-1.8c-2.3-6.3-4.3-9.9-6.4-12.6c-2.4-2.9-6.9-8-9.6-9.1c2.3,1.9,6.8,6.2,9.7,11.8c2.3,4.6,3,5.6,3.8,9.2
c-3.6-1.1-7.3-1.9-11-2.5c-0.7-0.1-1.4-0.2-2.1-0.3c-2-4.3-4.4-8.8-7.6-11.5c-3.7-3.2-8.3-6.7-11.1-7.5c2.6,1.5,8.7,6.8,11.8,10.9
c2.2,3,2.9,5.1,4,7.7c-5-0.6-9.9-0.9-14.9-1.1c-2-3.7-4.5-7.5-7.6-9.6c-3.4-2.4-7.6-5-10-5.5c2.3,1.1,7.9,5.1,10.8,8.3
c2.3,2.6,3,4.4,4.2,6.9c-11.1,0.4-22,1.7-32.8,3.6c9.4-0.8,18.9-1,28.3-0.6c-1.1,1.7-1.8,3-3.8,4.7c-2.6,2.2-7.5,4.5-9.5,4.9
c2,0.1,5.6-1.3,8.6-2.6c2.7-1.3,5.1-4.1,7.1-6.9c1.8,0.1,3.5,0.2,5.3,0.3c2.9,0.1,5.8,0.3,8.7,0.5c-1.4,3-2,5-4.6,8.1
c-3.1,3.6-9.1,8-11.6,9.2c2.7-0.4,7.2-3.3,10.8-5.9c3.3-2.4,6-6.8,8.1-11c2.7,0.3,5.4,0.6,8.1,1.1c2,0.3,3.9,0.7,5.9,1.2
c-1.9,4.1-2,4.6-5,8c-3.4,3.9-9.1,8.9-11.6,10.4c2.9-0.7,8.5-4.7,11.1-6.7c2.4-1.8,5.8-5.5,8.2-11.1c3,0.8,5.9,1.9,8.7,3.2
c-3.1,12.9-11.9,24.1-11.9,24.1s0.2-1.7-0.5-3.9c-0.8-2.2-1-2-1-2s-1.1,2.9-5,6.8c-3.9,3.9-8.9,5-8.9,5s0.7-1.1,0.8-2.4
c0.1-1.3-0.3-2-0.3-2s-2.6,1.9-5.3,3c-2.7,1.2-8.6,2.5-8.6,2.5s1.6-1.9,1.9-3c0.3-1-0.3-1.9-0.3-1.9s-3.9,1.5-7.5,1.5
c-3.6,0-7.7-1.5-7.7-1.5s1-0.5,1.7-1.8c0.8-1.3,0.2-1.9,0.2-1.9s-4.4,0.5-7.5,0.1C36.6,79.3,31.5,77.2,31.5,77.2z"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="100px" height="100px" viewBox="1 0 100 100" enable-background="new 1 0 100 100" xml:space="preserve">
<path fill="#228B22" d="M31.5,77.2c0,0,2.6-1.9,3.4-2.8c0.8-0.9,2-2.5,2-2.5s-4.7-0.7-7.9-1.6c-3.2-0.9-9.7-3.7-9.7-3.7
s1.3-0.4,2.9-1.4c1.6-1.1,2-1.7,2-1.7s-4.7-1.3-7.5-2.4c-2.8-1-7.3-2.9-7.3-2.9s1.4-0.2,2.8-1.3c1.4-1.1,1.5-2.2,1.5-2.2
S9.4,53.3,7,51.9c-2.4-1.5-5.3-3.7-5.3-3.7s5.2-4,8.7-5.5c3.5-1.5,9.1-2.6,9.1-2.6s-0.9-1.5-2.1-2.6c-1.1-1.1-4.2-2.9-4.2-2.9
s5.6-1.9,8.3-2.5c2.7-0.6,8.2,0,8.2,0s-0.1-1.1-1.3-2.2c-1.2-1.1-2.7-2-2.7-2S33,25.4,37,24.4c4-1,10.8-0.6,10.8-0.6
S46.1,22.1,45,21c-1.1-1.1-2.7-2.3-2.7-2.3s5.4-0.4,9.1-0.4c3.7,0.1,9.5,1.1,9.5,1.1s-0.7-1-1.4-2.3c-0.8-1.3-1.5-2.6-1.5-2.6
s7.1,1.7,9.5,2.8c2.4,1,7.6,3.9,7.6,3.9s-0.1-1.8-1-3.5c-0.9-1.7-1.8-3-1.8-3s27.7,17.2,28.1,33.5c0.1,1.8-0.1,3.7-0.3,5.5
c-1.5-0.7-3.1-1.3-4.7-1.8c-2.3-6.3-4.3-9.9-6.4-12.6c-2.4-2.9-6.9-8-9.6-9.1c2.3,1.9,6.8,6.2,9.7,11.8c2.3,4.6,3,5.6,3.8,9.2
c-3.6-1.1-7.3-1.9-11-2.5c-0.7-0.1-1.4-0.2-2.1-0.3c-2-4.3-4.4-8.8-7.6-11.5c-3.7-3.2-8.3-6.7-11.1-7.5c2.6,1.5,8.7,6.8,11.8,10.9
c2.2,3,2.9,5.1,4,7.7c-5-0.6-9.9-0.9-14.9-1.1c-2-3.7-4.5-7.5-7.6-9.6c-3.4-2.4-7.6-5-10-5.5c2.3,1.1,7.9,5.1,10.8,8.3
c2.3,2.6,3,4.4,4.2,6.9c-11.1,0.4-22,1.7-32.8,3.6c9.4-0.8,18.9-1,28.3-0.6c-1.1,1.7-1.8,3-3.8,4.7c-2.6,2.2-7.5,4.5-9.5,4.9
c2,0.1,5.6-1.3,8.6-2.6c2.7-1.3,5.1-4.1,7.1-6.9c1.8,0.1,3.5,0.2,5.3,0.3c2.9,0.1,5.8,0.3,8.7,0.5c-1.4,3-2,5-4.6,8.1
c-3.1,3.6-9.1,8-11.6,9.2c2.7-0.4,7.2-3.3,10.8-5.9c3.3-2.4,6-6.8,8.1-11c2.7,0.3,5.4,0.6,8.1,1.1c2,0.3,3.9,0.7,5.9,1.2
c-1.9,4.1-2,4.6-5,8c-3.4,3.9-9.1,8.9-11.6,10.4c2.9-0.7,8.5-4.7,11.1-6.7c2.4-1.8,5.8-5.5,8.2-11.1c3,0.8,5.9,1.9,8.7,3.2
c-3.1,12.9-11.9,24.1-11.9,24.1s0.2-1.7-0.5-3.9c-0.8-2.2-1-2-1-2s-1.1,2.9-5,6.8c-3.9,3.9-8.9,5-8.9,5s0.7-1.1,0.8-2.4
c0.1-1.3-0.3-2-0.3-2s-2.6,1.9-5.3,3c-2.7,1.2-8.6,2.5-8.6,2.5s1.6-1.9,1.9-3c0.3-1-0.3-1.9-0.3-1.9s-3.9,1.5-7.5,1.5
c-3.6,0-7.7-1.5-7.7-1.5s1-0.5,1.7-1.8c0.8-1.3,0.2-1.9,0.2-1.9s-4.4,0.5-7.5,0.1C36.6,79.3,31.5,77.2,31.5,77.2z"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="100px" height="100px" viewBox="1 0 100 100" enable-background="new 1 0 100 100" xml:space="preserve">
<path fill="#D2691E" d="M31.5,77.2c0,0,2.6-1.9,3.4-2.8c0.8-0.9,2-2.5,2-2.5s-4.7-0.7-7.9-1.6c-3.2-0.9-9.7-3.7-9.7-3.7
s1.3-0.4,2.9-1.4c1.6-1.1,2-1.7,2-1.7s-4.7-1.3-7.5-2.4c-2.8-1-7.3-2.9-7.3-2.9s1.4-0.2,2.8-1.3c1.4-1.1,1.5-2.2,1.5-2.2
S9.4,53.3,7,51.9c-2.4-1.5-5.3-3.7-5.3-3.7s5.2-4,8.7-5.5c3.5-1.5,9.1-2.6,9.1-2.6s-0.9-1.5-2.1-2.6c-1.1-1.1-4.2-2.9-4.2-2.9
s5.6-1.9,8.3-2.5c2.7-0.6,8.2,0,8.2,0s-0.1-1.1-1.3-2.2c-1.2-1.1-2.7-2-2.7-2S33,25.4,37,24.4c4-1,10.8-0.6,10.8-0.6
S46.1,22.1,45,21c-1.1-1.1-2.7-2.3-2.7-2.3s5.4-0.4,9.1-0.4c3.7,0.1,9.5,1.1,9.5,1.1s-0.7-1-1.4-2.3c-0.8-1.3-1.5-2.6-1.5-2.6
s7.1,1.7,9.5,2.8c2.4,1,7.6,3.9,7.6,3.9s-0.1-1.8-1-3.5c-0.9-1.7-1.8-3-1.8-3s27.7,17.2,28.1,33.5c0.1,1.8-0.1,3.7-0.3,5.5
c-1.5-0.7-3.1-1.3-4.7-1.8c-2.3-6.3-4.3-9.9-6.4-12.6c-2.4-2.9-6.9-8-9.6-9.1c2.3,1.9,6.8,6.2,9.7,11.8c2.3,4.6,3,5.6,3.8,9.2
c-3.6-1.1-7.3-1.9-11-2.5c-0.7-0.1-1.4-0.2-2.1-0.3c-2-4.3-4.4-8.8-7.6-11.5c-3.7-3.2-8.3-6.7-11.1-7.5c2.6,1.5,8.7,6.8,11.8,10.9
c2.2,3,2.9,5.1,4,7.7c-5-0.6-9.9-0.9-14.9-1.1c-2-3.7-4.5-7.5-7.6-9.6c-3.4-2.4-7.6-5-10-5.5c2.3,1.1,7.9,5.1,10.8,8.3
c2.3,2.6,3,4.4,4.2,6.9c-11.1,0.4-22,1.7-32.8,3.6c9.4-0.8,18.9-1,28.3-0.6c-1.1,1.7-1.8,3-3.8,4.7c-2.6,2.2-7.5,4.5-9.5,4.9
c2,0.1,5.6-1.3,8.6-2.6c2.7-1.3,5.1-4.1,7.1-6.9c1.8,0.1,3.5,0.2,5.3,0.3c2.9,0.1,5.8,0.3,8.7,0.5c-1.4,3-2,5-4.6,8.1
c-3.1,3.6-9.1,8-11.6,9.2c2.7-0.4,7.2-3.3,10.8-5.9c3.3-2.4,6-6.8,8.1-11c2.7,0.3,5.4,0.6,8.1,1.1c2,0.3,3.9,0.7,5.9,1.2
c-1.9,4.1-2,4.6-5,8c-3.4,3.9-9.1,8.9-11.6,10.4c2.9-0.7,8.5-4.7,11.1-6.7c2.4-1.8,5.8-5.5,8.2-11.1c3,0.8,5.9,1.9,8.7,3.2
c-3.1,12.9-11.9,24.1-11.9,24.1s0.2-1.7-0.5-3.9c-0.8-2.2-1-2-1-2s-1.1,2.9-5,6.8c-3.9,3.9-8.9,5-8.9,5s0.7-1.1,0.8-2.4
c0.1-1.3-0.3-2-0.3-2s-2.6,1.9-5.3,3c-2.7,1.2-8.6,2.5-8.6,2.5s1.6-1.9,1.9-3c0.3-1-0.3-1.9-0.3-1.9s-3.9,1.5-7.5,1.5
c-3.6,0-7.7-1.5-7.7-1.5s1-0.5,1.7-1.8c0.8-1.3,0.2-1.9,0.2-1.9s-4.4,0.5-7.5,0.1C36.6,79.3,31.5,77.2,31.5,77.2z"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="100px" height="100px" viewBox="1 0 100 100" enable-background="new 1 0 100 100" xml:space="preserve">
<path fill="#B22222" d="M31.5,77.2c0,0,2.6-1.9,3.4-2.8c0.8-0.9,2-2.5,2-2.5s-4.7-0.7-7.9-1.6c-3.2-0.9-9.7-3.7-9.7-3.7
s1.3-0.4,2.9-1.4c1.6-1.1,2-1.7,2-1.7s-4.7-1.3-7.5-2.4c-2.8-1-7.3-2.9-7.3-2.9s1.4-0.2,2.8-1.3c1.4-1.1,1.5-2.2,1.5-2.2
S9.4,53.3,7,51.9c-2.4-1.5-5.3-3.7-5.3-3.7s5.2-4,8.7-5.5c3.5-1.5,9.1-2.6,9.1-2.6s-0.9-1.5-2.1-2.6c-1.1-1.1-4.2-2.9-4.2-2.9
s5.6-1.9,8.3-2.5c2.7-0.6,8.2,0,8.2,0s-0.1-1.1-1.3-2.2c-1.2-1.1-2.7-2-2.7-2S33,25.4,37,24.4c4-1,10.8-0.6,10.8-0.6
S46.1,22.1,45,21c-1.1-1.1-2.7-2.3-2.7-2.3s5.4-0.4,9.1-0.4c3.7,0.1,9.5,1.1,9.5,1.1s-0.7-1-1.4-2.3c-0.8-1.3-1.5-2.6-1.5-2.6
s7.1,1.7,9.5,2.8c2.4,1,7.6,3.9,7.6,3.9s-0.1-1.8-1-3.5c-0.9-1.7-1.8-3-1.8-3s27.7,17.2,28.1,33.5c0.1,1.8-0.1,3.7-0.3,5.5
c-1.5-0.7-3.1-1.3-4.7-1.8c-2.3-6.3-4.3-9.9-6.4-12.6c-2.4-2.9-6.9-8-9.6-9.1c2.3,1.9,6.8,6.2,9.7,11.8c2.3,4.6,3,5.6,3.8,9.2
c-3.6-1.1-7.3-1.9-11-2.5c-0.7-0.1-1.4-0.2-2.1-0.3c-2-4.3-4.4-8.8-7.6-11.5c-3.7-3.2-8.3-6.7-11.1-7.5c2.6,1.5,8.7,6.8,11.8,10.9
c2.2,3,2.9,5.1,4,7.7c-5-0.6-9.9-0.9-14.9-1.1c-2-3.7-4.5-7.5-7.6-9.6c-3.4-2.4-7.6-5-10-5.5c2.3,1.1,7.9,5.1,10.8,8.3
c2.3,2.6,3,4.4,4.2,6.9c-11.1,0.4-22,1.7-32.8,3.6c9.4-0.8,18.9-1,28.3-0.6c-1.1,1.7-1.8,3-3.8,4.7c-2.6,2.2-7.5,4.5-9.5,4.9
c2,0.1,5.6-1.3,8.6-2.6c2.7-1.3,5.1-4.1,7.1-6.9c1.8,0.1,3.5,0.2,5.3,0.3c2.9,0.1,5.8,0.3,8.7,0.5c-1.4,3-2,5-4.6,8.1
c-3.1,3.6-9.1,8-11.6,9.2c2.7-0.4,7.2-3.3,10.8-5.9c3.3-2.4,6-6.8,8.1-11c2.7,0.3,5.4,0.6,8.1,1.1c2,0.3,3.9,0.7,5.9,1.2
c-1.9,4.1-2,4.6-5,8c-3.4,3.9-9.1,8.9-11.6,10.4c2.9-0.7,8.5-4.7,11.1-6.7c2.4-1.8,5.8-5.5,8.2-11.1c3,0.8,5.9,1.9,8.7,3.2
c-3.1,12.9-11.9,24.1-11.9,24.1s0.2-1.7-0.5-3.9c-0.8-2.2-1-2-1-2s-1.1,2.9-5,6.8c-3.9,3.9-8.9,5-8.9,5s0.7-1.1,0.8-2.4
c0.1-1.3-0.3-2-0.3-2s-2.6,1.9-5.3,3c-2.7,1.2-8.6,2.5-8.6,2.5s1.6-1.9,1.9-3c0.3-1-0.3-1.9-0.3-1.9s-3.9,1.5-7.5,1.5
c-3.6,0-7.7-1.5-7.7-1.5s1-0.5,1.7-1.8c0.8-1.3,0.2-1.9,0.2-1.9s-4.4,0.5-7.5,0.1C36.6,79.3,31.5,77.2,31.5,77.2z"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="100px" height="100px" viewBox="1 0 100 100" enable-background="new 1 0 100 100" xml:space="preserve">
<path fill="#FFD700" d="M31.5,77.2c0,0,2.6-1.9,3.4-2.8c0.8-0.9,2-2.5,2-2.5s-4.7-0.7-7.9-1.6c-3.2-0.9-9.7-3.7-9.7-3.7
s1.3-0.4,2.9-1.4c1.6-1.1,2-1.7,2-1.7s-4.7-1.3-7.5-2.4c-2.8-1-7.3-2.9-7.3-2.9s1.4-0.2,2.8-1.3c1.4-1.1,1.5-2.2,1.5-2.2
S9.4,53.3,7,51.9c-2.4-1.5-5.3-3.7-5.3-3.7s5.2-4,8.7-5.5c3.5-1.5,9.1-2.6,9.1-2.6s-0.9-1.5-2.1-2.6c-1.1-1.1-4.2-2.9-4.2-2.9
s5.6-1.9,8.3-2.5c2.7-0.6,8.2,0,8.2,0s-0.1-1.1-1.3-2.2c-1.2-1.1-2.7-2-2.7-2S33,25.4,37,24.4c4-1,10.8-0.6,10.8-0.6
S46.1,22.1,45,21c-1.1-1.1-2.7-2.3-2.7-2.3s5.4-0.4,9.1-0.4c3.7,0.1,9.5,1.1,9.5,1.1s-0.7-1-1.4-2.3c-0.8-1.3-1.5-2.6-1.5-2.6
s7.1,1.7,9.5,2.8c2.4,1,7.6,3.9,7.6,3.9s-0.1-1.8-1-3.5c-0.9-1.7-1.8-3-1.8-3s27.7,17.2,28.1,33.5c0.1,1.8-0.1,3.7-0.3,5.5
c-1.5-0.7-3.1-1.3-4.7-1.8c-2.3-6.3-4.3-9.9-6.4-12.6c-2.4-2.9-6.9-8-9.6-9.1c2.3,1.9,6.8,6.2,9.7,11.8c2.3,4.6,3,5.6,3.8,9.2
c-3.6-1.1-7.3-1.9-11-2.5c-0.7-0.1-1.4-0.2-2.1-0.3c-2-4.3-4.4-8.8-7.6-11.5c-3.7-3.2-8.3-6.7-11.1-7.5c2.6,1.5,8.7,6.8,11.8,10.9
c2.2,3,2.9,5.1,4,7.7c-5-0.6-9.9-0.9-14.9-1.1c-2-3.7-4.5-7.5-7.6-9.6c-3.4-2.4-7.6-5-10-5.5c2.3,1.1,7.9,5.1,10.8,8.3
c2.3,2.6,3,4.4,4.2,6.9c-11.1,0.4-22,1.7-32.8,3.6c9.4-0.8,18.9-1,28.3-0.6c-1.1,1.7-1.8,3-3.8,4.7c-2.6,2.2-7.5,4.5-9.5,4.9
c2,0.1,5.6-1.3,8.6-2.6c2.7-1.3,5.1-4.1,7.1-6.9c1.8,0.1,3.5,0.2,5.3,0.3c2.9,0.1,5.8,0.3,8.7,0.5c-1.4,3-2,5-4.6,8.1
c-3.1,3.6-9.1,8-11.6,9.2c2.7-0.4,7.2-3.3,10.8-5.9c3.3-2.4,6-6.8,8.1-11c2.7,0.3,5.4,0.6,8.1,1.1c2,0.3,3.9,0.7,5.9,1.2
c-1.9,4.1-2,4.6-5,8c-3.4,3.9-9.1,8.9-11.6,10.4c2.9-0.7,8.5-4.7,11.1-6.7c2.4-1.8,5.8-5.5,8.2-11.1c3,0.8,5.9,1.9,8.7,3.2
c-3.1,12.9-11.9,24.1-11.9,24.1s0.2-1.7-0.5-3.9c-0.8-2.2-1-2-1-2s-1.1,2.9-5,6.8c-3.9,3.9-8.9,5-8.9,5s0.7-1.1,0.8-2.4
c0.1-1.3-0.3-2-0.3-2s-2.6,1.9-5.3,3c-2.7,1.2-8.6,2.5-8.6,2.5s1.6-1.9,1.9-3c0.3-1-0.3-1.9-0.3-1.9s-3.9,1.5-7.5,1.5
c-3.6,0-7.7-1.5-7.7-1.5s1-0.5,1.7-1.8c0.8-1.3,0.2-1.9,0.2-1.9s-4.4,0.5-7.5,0.1C36.6,79.3,31.5,77.2,31.5,77.2z"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="100px" height="100px" viewBox="1 0 100 100" enable-background="new 1 0 100 100" xml:space="preserve">
<path fill="#471C37" d="M31.5,77.2c0,0,2.6-1.9,3.4-2.8c0.8-0.9,2-2.5,2-2.5s-4.7-0.7-7.9-1.6c-3.2-0.9-9.7-3.7-9.7-3.7
s1.3-0.4,2.9-1.4c1.6-1.1,2-1.7,2-1.7s-4.7-1.3-7.5-2.4c-2.8-1-7.3-2.9-7.3-2.9s1.4-0.2,2.8-1.3c1.4-1.1,1.5-2.2,1.5-2.2
S9.4,53.3,7,51.9c-2.4-1.5-5.3-3.7-5.3-3.7s5.2-4,8.7-5.5c3.5-1.5,9.1-2.6,9.1-2.6s-0.9-1.5-2.1-2.6c-1.1-1.1-4.2-2.9-4.2-2.9
s5.6-1.9,8.3-2.5c2.7-0.6,8.2,0,8.2,0s-0.1-1.1-1.3-2.2c-1.2-1.1-2.7-2-2.7-2S33,25.4,37,24.4c4-1,10.8-0.6,10.8-0.6
S46.1,22.1,45,21c-1.1-1.1-2.7-2.3-2.7-2.3s5.4-0.4,9.1-0.4c3.7,0.1,9.5,1.1,9.5,1.1s-0.7-1-1.4-2.3c-0.8-1.3-1.5-2.6-1.5-2.6
s7.1,1.7,9.5,2.8c2.4,1,7.6,3.9,7.6,3.9s-0.1-1.8-1-3.5c-0.9-1.7-1.8-3-1.8-3s27.7,17.2,28.1,33.5c0.1,1.8-0.1,3.7-0.3,5.5
c-1.5-0.7-3.1-1.3-4.7-1.8c-2.3-6.3-4.3-9.9-6.4-12.6c-2.4-2.9-6.9-8-9.6-9.1c2.3,1.9,6.8,6.2,9.7,11.8c2.3,4.6,3,5.6,3.8,9.2
c-3.6-1.1-7.3-1.9-11-2.5c-0.7-0.1-1.4-0.2-2.1-0.3c-2-4.3-4.4-8.8-7.6-11.5c-3.7-3.2-8.3-6.7-11.1-7.5c2.6,1.5,8.7,6.8,11.8,10.9
c2.2,3,2.9,5.1,4,7.7c-5-0.6-9.9-0.9-14.9-1.1c-2-3.7-4.5-7.5-7.6-9.6c-3.4-2.4-7.6-5-10-5.5c2.3,1.1,7.9,5.1,10.8,8.3
c2.3,2.6,3,4.4,4.2,6.9c-11.1,0.4-22,1.7-32.8,3.6c9.4-0.8,18.9-1,28.3-0.6c-1.1,1.7-1.8,3-3.8,4.7c-2.6,2.2-7.5,4.5-9.5,4.9
c2,0.1,5.6-1.3,8.6-2.6c2.7-1.3,5.1-4.1,7.1-6.9c1.8,0.1,3.5,0.2,5.3,0.3c2.9,0.1,5.8,0.3,8.7,0.5c-1.4,3-2,5-4.6,8.1
c-3.1,3.6-9.1,8-11.6,9.2c2.7-0.4,7.2-3.3,10.8-5.9c3.3-2.4,6-6.8,8.1-11c2.7,0.3,5.4,0.6,8.1,1.1c2,0.3,3.9,0.7,5.9,1.2
c-1.9,4.1-2,4.6-5,8c-3.4,3.9-9.1,8.9-11.6,10.4c2.9-0.7,8.5-4.7,11.1-6.7c2.4-1.8,5.8-5.5,8.2-11.1c3,0.8,5.9,1.9,8.7,3.2
c-3.1,12.9-11.9,24.1-11.9,24.1s0.2-1.7-0.5-3.9c-0.8-2.2-1-2-1-2s-1.1,2.9-5,6.8c-3.9,3.9-8.9,5-8.9,5s0.7-1.1,0.8-2.4
c0.1-1.3-0.3-2-0.3-2s-2.6,1.9-5.3,3c-2.7,1.2-8.6,2.5-8.6,2.5s1.6-1.9,1.9-3c0.3-1-0.3-1.9-0.3-1.9s-3.9,1.5-7.5,1.5
c-3.6,0-7.7-1.5-7.7-1.5s1-0.5,1.7-1.8c0.8-1.3,0.2-1.9,0.2-1.9s-4.4,0.5-7.5,0.1C36.6,79.3,31.5,77.2,31.5,77.2z"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

67
client/src/Api.ts Normal file
View File

@@ -0,0 +1,67 @@
import { PizzaOrder } from "./Types";
import { getBaseUrl } from "./Utils";
async function request<TResponse>(
url: string,
config: RequestInit = {}
): Promise<TResponse> {
return fetch(getBaseUrl() + url, config).then(response => {
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json() as TResponse;
});
}
const api = {
get: <TResponse>(url: string) => request<TResponse>(url),
post: <TBody extends BodyInit, TResponse>(url: string, body: TBody) => request<TResponse>(url, { method: 'POST', body, headers: { 'Content-Type': 'application/json' } }),
}
export const getData = async () => {
return await api.get<any>('/api/data');
}
export const getFood = async () => {
return await api.get<any>('/api/food');
}
export const getPizzy = async () => {
return await api.get<any>('/api/pizza');
}
export const createPizzaDay = async (creator) => {
return await api.post<any, any>('/api/createPizzaDay', JSON.stringify({ creator }));
}
export const deletePizzaDay = async (login) => {
return await api.post<any, any>('/api/deletePizzaDay', JSON.stringify({ login }));
}
export const lockPizzaDay = async (login) => {
return await api.post<any, any>('/api/lockPizzaDay', JSON.stringify({ login }));
}
export const unlockPizzaDay = async (login) => {
return await api.post<any, any>('/api/unlockPizzaDay', JSON.stringify({ login }));
}
export const finishOrder = async (login) => {
return await api.post<any, any>('/api/finishOrder', JSON.stringify({ login }));
}
export const finishDelivery = async (login) => {
return await api.post<any, any>('/api/finishDelivery', JSON.stringify({ login }));
}
export const updateChoice = async (name: string, choice: number | null) => {
return await api.post<any, any>('/api/updateChoice', JSON.stringify({ name, choice }));
}
export const addPizza = async (login: string, pizzaIndex: number, pizzaSizeIndex: number) => {
return await api.post<any, any>('/api/addPizza', JSON.stringify({ login, pizzaIndex, pizzaSizeIndex }));
}
export const removePizza = async (login: string, pizzaOrder: PizzaOrder) => {
return await api.post<any, any>('/api/removePizza', JSON.stringify({ login, pizzaOrder }));
}

74
client/src/App.css Normal file
View File

@@ -0,0 +1,74 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.wrapper {
padding: 20px;
}
.title {
margin: 50px 0;
}
.food-tables {
margin-bottom: 50px;
}
.content-wrapper {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.navbar {
background-color: #3c3c3c;
padding-left: 20px;
padding-right: 20px;
}
#basic-navbar-nav {
justify-content: flex-end;
}
.trash-icon {
color: rgb(0, 89, 255);
cursor: pointer;
margin-left: 10px;
}

View File

@@ -1,184 +0,0 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.loader {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.loader>.loader-icon {
font-size: 64px;
}
/* Sticky footer layout */
html,
body,
#root {
height: 100%;
margin: 0;
}
.app-container {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.wrapper {
padding: 20px;
flex: 1;
/* Zabere zbytek místa */
}
.title {
margin: 50px 20px;
}
.food-tables {
margin-bottom: 50px;
}
.content-wrapper {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.navbar {
background-color: #3c3c3c;
padding-left: 20px;
padding-right: 20px;
}
#basic-navbar-nav {
justify-content: flex-end;
}
.table {
margin-bottom: 0;
}
.table> :not(caption) .action-icon {
color: rgb(0, 89, 255);
cursor: pointer;
margin-left: 10px;
padding: 0;
}
.table ul {
padding: 0;
margin-left: 20px;
margin-bottom: 0;
}
.table td {
vertical-align: top;
}
.table>tbody>tr>td>table>tbody>tr>td {
border: none;
}
.qr-code {
text-align: center;
margin-top: 30px;
}
.select-search-container {
margin: auto;
}
.trusted-icon {
color: rgb(0, 89, 255);
margin-right: 10px;
}
.day-navigator {
display: flex;
align-items: center;
font-size: xx-large;
}
@keyframes bounce-in {
0% {
left: var(--start-left);
right: var(--start-right);
top: var(--start-top);
bottom: var(--start-bottom);
}
25% {
left: var(--end-left);
right: var(--end-right);
top: var(--end-top);
bottom: var(--end-bottom);
}
50% {
left: var(--start-left);
right: var(--start-right);
top: var(--start-top);
bottom: var(--start-bottom);
}
75% {
left: var(--end-left);
right: var(--end-right);
top: var(--end-top);
bottom: var(--end-bottom);
}
100% {
left: var(--start-left);
right: var(--start-right);
top: var(--start-top);
bottom: var(--start-bottom);
}
}
// TODO zjistit, zda to nedokážeme lépe - tohle je kvůli overflow easter egg obrázků, ale skrývá to úplně scrollbar
html {
overflow-x: hidden;
}

View File

@@ -1,139 +1,59 @@
import React, { useContext, useEffect, useMemo, useRef, useState, useCallback } from 'react';
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
import 'bootstrap/dist/css/bootstrap.min.css';
import { EVENT_DISCONNECT, EVENT_MESSAGE, SocketContext } from './context/socket';
import { addPizza, createPizzaDay, deletePizzaDay, finishDelivery, finishOrder, getData, getFood, getPizzy, lockPizzaDay, removePizza, unlockPizzaDay, updateChoice } from './Api';
import { useAuth } from './context/auth';
import Login from './Login';
import { Locations, ClientData, Pizza, PizzaOrder, State } from './Types';
import { Alert, Button, Col, Form, Row, Table } from 'react-bootstrap';
import Header from './components/Header';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import PizzaOrderList from './components/PizzaOrderList';
import SelectSearch, { SelectedOptionValue, SelectSearchOption } from 'react-select-search';
import SelectSearch from 'react-select-search';
import 'react-select-search/style.css';
import './App.scss';
import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons';
import { useSettings } from './context/settings';
import Footer from './components/Footer';
import { faChainBroken, faChevronLeft, faChevronRight, faGear, faSatelliteDish, faSearch } from '@fortawesome/free-solid-svg-icons';
import Loader from './components/Loader';
import { getDayOfWeekIndex, getHumanDate, getHumanDateTime, getIsWeekend, isInTheFuture } from './Utils';
import NoteModal from './components/modals/NoteModal';
import { useEasterEgg } from './context/eggs';
import { ClientData, Food, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime } from '../../types';
import { getLunchChoiceName } from './enums';
import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves';
import './FallingLeaves.scss';
import './App.css';
import { SelectSearchOption } from 'react-select-search';
import { faTrashCan } from '@fortawesome/free-regular-svg-icons';
const EVENT_CONNECT = "connect"
// Fixní styl pro všechny easter egg obrázky
const EASTER_EGG_STYLE = {
zIndex: 1,
animationName: "bounce-in",
animationTimingFunction: "ease"
}
// Mapování čísel alergenů na jejich názvy
const ALLERGENS: { [key: number]: string } = {
1: "Obiloviny obsahující lepek",
2: "Korýši a výrobky z nich",
3: "Vejce a výrobky z nich",
4: "Ryby a výrobky z nich",
5: "Arašidy a výrobky z nich",
6: "Sója a výrobky z nich",
7: "Mléko a výrobky z nich (včetně laktózy)",
8: "Skořápkové plody",
9: "Celer a výrobky z něj",
10: "Hořčice a výrobky z ní",
11: "Sezamová semena a výrobky z nich",
12: "Oxid siřičitý a siřičitany",
13: "Vlčí bob (Lupina) a výrobky z něj",
14: "Měkkýši a výrobky z nich"
}
const LINK_ALLERGENS = 'https://www.strava.cz/Strava/Napoveda/cz/Prilohy/alergeny.pdf';
// Výchozí doba trvání animace v sekundách, pokud není přetíženo v konfiguračním JSONu
const EASTER_EGG_DEFAULT_DURATION = 0.75;
function App() {
const auth = useAuth();
const settings = useSettings();
const [easterEgg, _] = useEasterEgg(auth);
const [isConnected, setIsConnected] = useState<boolean>(false);
const [data, setData] = useState<ClientData>();
const [food, setFood] = useState<RestaurantDayMenuMap>();
const [myOrder, setMyOrder] = useState<PizzaOrder>();
const [foodChoiceList, setFoodChoiceList] = useState<Food[]>();
const [closed, setClosed] = useState<boolean>(false);
const [food, setFood] = useState<any>();
const [pizzy, setPizzy] = useState<Pizza[]>();
const socket = useContext(SocketContext);
const choiceRef = useRef<HTMLSelectElement>(null);
const foodChoiceRef = useRef<HTMLSelectElement>(null);
const departureChoiceRef = useRef<HTMLSelectElement>(null);
const pizzaPoznamkaRef = useRef<HTMLInputElement>(null);
const [failure, setFailure] = useState<boolean>(false);
const [dayIndex, setDayIndex] = useState<number>(); // Index zobrazovaného dne
// TODO berka zde je nutné dořešit mocking pro testování
const [todayDayIndex, setTodayDayIndex] = useState<number>(getDayOfWeekIndex(new Date())); // Index dnešního dne
const [isTodayWeekend, setIsTodayWeekend] = useState<boolean>(getIsWeekend(new Date()));
const [loadingPizzaDay, setLoadingPizzaDay] = useState<boolean>(false);
const [noteModalOpen, setNoteModalOpen] = useState<boolean>(false);
const [eggImage, setEggImage] = useState<Blob>();
const eggRef = useRef<HTMLImageElement>(null);
// Prazvláštní workaround, aby socket.io listener viděl aktuální hodnotu
// https://medium.com/@kishorkrishna/cant-access-latest-state-inside-socket-io-listener-heres-how-to-fix-it-1522a5abebdb
const dayIndexRef = useRef<number | undefined>(dayIndex);
// Načtení dat po přihlášení
// Prvotní načtení aktuálního stavu
useEffect(() => {
if (!auth?.login) {
return
}
getData().then(response => {
const data = response.data
if (data) {
setData(data);
const dayIndex = getDayOfWeekIndex(new Date(data.date));
setDayIndex(dayIndex);
dayIndexRef.current = dayIndex;
setFood(data.menus);
}
}).catch(e => {
setFailure(true);
})
}, [auth, auth?.login]);
// Přenačtení pro zvolený den
useEffect(() => {
if (!auth?.login) {
return
}
setTodayDayIndex(getDayOfWeekIndex(new Date()));
setIsTodayWeekend(getIsWeekend(new Date()));
getData({ query: { dayIndex: dayIndex } }).then(response => {
const data = response.data;
getPizzy().then(pizzy => {
setPizzy(pizzy);
});
getData().then(data => {
setData(data);
if (data) {
setFood(data.menus);
}
}).catch(e => {
setFailure(true);
})
}, [dayIndex, auth]);
getFood().then(food => {
setFood(food);
})
}, []);
// Registrace socket eventů
useEffect(() => {
socket.on(EVENT_CONNECT, () => {
// console.log("Connected!");
setIsConnected(true);
});
socket.on(EVENT_DISCONNECT, () => {
// console.log("Disconnected!");
setIsConnected(false);
});
socket.on(EVENT_MESSAGE, (newData: ClientData) => {
// const data: any = JSON.parse(payload);
// console.log("Přijata nová data ze socketu", newData);
// Aktualizujeme pouze, pokud jsme dostali data pro den, který máme aktuálně zobrazený
if (dayIndexRef.current == null || getDayOfWeekIndex(new Date(newData.date)) === dayIndexRef.current) {
setData(newData);
}
setData(newData);
});
return () => {
@@ -144,158 +64,41 @@ function App() {
}, [socket]);
useEffect(() => {
if (!auth?.login) {
if (!auth || !auth.login) {
return
}
// TODO tohle občas náhodně nezafunguje, nutno přepsat, viz https://medium.com/@teh_builder/ref-objects-inside-useeffect-hooks-eb7c15198780
// TODO nutno opravit
// if (data?.choices && choiceRef.current) {
// for (let entry of Object.entries(data.choices)) {
// if (entry[1].includes(auth.login)) {
// const value = entry[0] as any as number; // TODO tohle je absurdní
// choiceRef.current.value = Object.values(Locations)[value];
// }
// }
// }
if (data?.choices && choiceRef.current) {
for (let entry of Object.entries(data.choices)) {
if (entry[1].includes(auth.login)) {
choiceRef.current.value = Object.values(Locations)[entry[0]]
}
}
}
}, [auth, auth?.login, data?.choices])
// Reference na mojí objednávku
useEffect(() => {
if (data?.pizzaDay?.orders) {
const myOrder = data.pizzaDay.orders.find(o => o.customer === auth?.login);
setMyOrder(myOrder);
}
}, [auth?.login, data?.pizzaDay?.orders])
useEffect(() => {
if (choiceRef?.current?.value && choiceRef.current.value !== "") {
const locationKey = choiceRef.current.value as LunchChoice;
const restaurantKey = Object.keys(Restaurant).indexOf(locationKey);
if (restaurantKey > -1 && food) {
const restaurant = Object.keys(Restaurant)[restaurantKey] as Restaurant;
setFoodChoiceList(food[restaurant]?.food);
setClosed(food[restaurant]?.closed ?? false);
} else {
setFoodChoiceList(undefined);
setClosed(false);
}
} else {
setFoodChoiceList(undefined);
setClosed(false);
}
}, [choiceRef.current?.value, food])
// Navigace mezi dny pomocí klávesových šípek
const handleKeyDown = useCallback((e: any) => {
if (e.keyCode === 37 && dayIndex != null && dayIndex > 0) {
handleDayChange(dayIndex - 1);
} else if (e.keyCode === 39 && dayIndex != null && dayIndex < 4) {
handleDayChange(dayIndex + 1);
}
}, [dayIndex]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
}
}, [handleKeyDown]);
// Stažení a nastavení easter egg obrázku
useEffect(() => {
if (auth?.login && easterEgg?.url && !eggImage) {
getEasterEggImage({ path: { url: easterEgg.url } }).then(response => {
if (response.data) {
setEggImage(response.data);
// Smazání obrázku z DOMu po animaci
setTimeout(() => {
if (eggRef?.current) {
eggRef.current.remove();
}
}, (easterEgg.duration || EASTER_EGG_DEFAULT_DURATION) * 1000);
}
});
}
}, [auth?.login, easterEgg?.duration, easterEgg?.url, eggImage]);
const doAddClickFoodChoice = async (location: LunchChoice, foodIndex?: number) => {
if (document.getSelection()?.type !== 'Range') { // pouze pokud se nejedná o výběr textu
if (auth?.login) {
await addChoice({ body: { locationKey: location, foodIndex, dayIndex } });
}
}
}
const doAddChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => {
const locationKey = event.target.value as LunchChoice;
const changeChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => {
const index = Object.values(Locations).indexOf(event.target.value as unknown as Locations);
if (auth?.login) {
await addChoice({ body: { locationKey, dayIndex } });
if (foodChoiceRef.current?.value) {
foodChoiceRef.current.value = "";
}
choiceRef.current?.blur();
await updateChoice(auth.login, index > -1 ? index : null);
}
}
const doJdemeObed = async () => {
const removeChoice = async (key: string) => {
if (auth?.login) {
await jdemeObed();
}
}
const doAddFoodChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => {
if (event.target.value && foodChoiceList?.length && choiceRef.current?.value) {
const locationKey = choiceRef.current.value as LunchChoice;
if (auth?.login) {
await addChoice({ body: { locationKey, foodIndex: Number(event.target.value), dayIndex } });
}
}
}
const doRemoveChoices = async (locationKey: LunchChoice) => {
if (auth?.login) {
await removeChoices({ body: { locationKey, dayIndex } });
// Vyresetujeme výběr, aby bylo jasné pro který případně vybíráme jídlo
await updateChoice(auth.login, null);
if (choiceRef?.current?.value) {
choiceRef.current.value = "";
}
if (foodChoiceRef?.current?.value) {
foodChoiceRef.current.value = "";
}
}
}
const doRemoveFoodChoice = async (locationKey: LunchChoice, foodIndex: number) => {
if (auth?.login) {
await removeChoice({ body: { locationKey, foodIndex, dayIndex } });
if (choiceRef?.current?.value) {
choiceRef.current.value = "";
}
if (foodChoiceRef?.current?.value) {
foodChoiceRef.current.value = "";
}
}
}
const saveNote = async (note?: string) => {
if (auth?.login) {
await updateNote({ body: { note, dayIndex } });
setNoteModalOpen(false);
}
}
const copyNote = async (note: string) => {
if (auth?.login && note) {
await updateNote({ body: { note, dayIndex } });
}
}
const pizzaSuggestions = useMemo(() => {
if (!data?.pizzaList) {
if (!pizzy) {
return [];
}
const suggestions: SelectSearchOption[] = [];
data.pizzaList.forEach((pizza, index) => {
pizzy.forEach((pizza, index) => {
const group: SelectSearchOption = { name: pizza.name, type: "group", items: [] }
pizza.sizes.forEach((size, sizeIndex) => {
const name = `${size.size} (${size.price} Kč)`;
@@ -305,30 +108,21 @@ function App() {
suggestions.push(group);
})
return suggestions;
}, [data?.pizzaList]);
}, [pizzy]);
const handlePizzaChange = async (value: SelectedOptionValue | SelectedOptionValue[]) => {
if (auth?.login && data?.pizzaList) {
if (typeof value !== 'string') {
throw Error('Nepodporovaný typ hodnoty');
}
const handlePizzaChange = async (value) => {
if (auth?.login && pizzy) {
const s = value.split('|');
const pizzaIndex = Number.parseInt(s[0]);
const pizzaSizeIndex = Number.parseInt(s[1]);
await addPizza({ body: { pizzaIndex, pizzaSizeIndex } });
await addPizza(auth.login, pizzaIndex, pizzaSizeIndex);
}
}
const handlePizzaDelete = async (pizzaOrder: PizzaVariant) => {
await removePizza({ body: { pizzaOrder } });
}
const handlePizzaPoznamkaChange = async () => {
if (pizzaPoznamkaRef.current?.value && pizzaPoznamkaRef.current.value.length > 70) {
alert("Poznámka může mít maximálně 70 znaků");
return;
const handlePizzaDelete = (pizzaOrder: PizzaOrder) => {
if (auth?.login) {
removePizza(auth?.login, pizzaOrder);
}
updatePizzaDayNote({ body: { note: pizzaPoznamkaRef.current?.value } });
}
// const addToCart = async () => {
@@ -355,375 +149,167 @@ function App() {
// }
// }
const handleChangeDepartureTime = async (event: React.ChangeEvent<HTMLSelectElement>) => {
if (foodChoiceList?.length && choiceRef.current?.value) {
await changeDepartureTime({ body: { time: event.target.value as DepartureTime, dayIndex } });
}
}
const handleDayChange = async (dayIndex: number) => {
setDayIndex(dayIndex);
dayIndexRef.current = dayIndex;
if (choiceRef?.current?.value) {
choiceRef.current.value = "";
}
if (foodChoiceRef?.current?.value) {
foodChoiceRef.current.value = "";
}
if (departureChoiceRef?.current?.value) {
departureChoiceRef.current.value = "";
}
}
const renderFoodTable = (location: Restaurant, menu: RestaurantDayMenu) => {
let content;
if (menu?.closed) {
content = <h3>Zavřeno</h3>
} else if (menu?.food?.length && menu.food.length > 0) {
const hideSoups = settings?.hideSoups;
content = <Table striped bordered hover>
<tbody style={{ cursor: 'pointer' }}>
{menu.food.map((f: Food, index: number) =>
(!hideSoups || !f.isSoup) &&
<tr key={f.name} onClick={() => doAddClickFoodChoice(location, index)}>
const renderFoodTable = (name, food) => {
return <Col md={12} lg={4}>
<h3>{name}</h3>
<Table striped bordered hover>
<tbody>
{food?.length>0?food.map((f: any, index: number) =>
<tr key={index}>
<td>{f.amount}</td>
<td>
{f.name}
{f.allergens && f.allergens.length > 0 && (
<> ({f.allergens.map((a, idx) => (
<span key={a}>
<span title={ALLERGENS[a]} style={{ cursor: 'help', textDecoration: 'underline' }} onClick={e => {
e.stopPropagation();
window.open(LINK_ALLERGENS, '_blank');
}}>{a}</span>
{idx < f.allergens!.length - 1 && ','}
</span>
))})</>
)}
</td>
<td>{f.name}</td>
<td>{f.price}</td>
</tr>
)}
):<h1>Hmmmmm podivné.... nic se nevrátilo</h1>}
</tbody>
</Table>
} else {
content = <h3>Chyba načtení dat</h3>
}
return <Col md={12} lg={3} className='mt-3'>
<h3 style={{ cursor: 'pointer' }} onClick={() => doAddClickFoodChoice(location)}>{getLunchChoiceName(location)}</h3>
{menu?.lastUpdate && <small>Poslední aktualizace: {getHumanDateTime(new Date(menu.lastUpdate))}</small>}
{content}
</Col>
}
if (!auth?.login) {
if (!auth || !auth.login) {
return <Login />;
}
if (!isConnected) {
return <Loader
icon={faSatelliteDish}
description={'Zdá se, že máme problémy se spojením se serverem. Pokud problém přetrvává, kontaktujte správce systému.'}
animation={'fa-beat-fade'}
/>
}
if (failure) {
return <Loader
title="Něco se nám nepovedlo :("
icon={faChainBroken}
description={'Ale to nevadí. To se stává, takový je život. Kontaktujte správce systému, který zajistí nápravu.'}
/>
}
if (!data || !food) {
return <Loader
icon={faSearch}
description={'Hledáme, co dobrého je aktuálně v nabídce'}
animation={'fa-bounce'}
/>
if (!data || !isConnected || !food) {
return <div>Načítám data...</div>
}
const noOrders = data?.pizzaDay?.orders?.length === 0;
const canChangeChoice = dayIndex == null || dayIndex >= todayDayIndex;
const { path, url, startOffset, endOffset, duration, ...style } = easterEgg || {};
return (
<div className="app-container">
{easterEgg && eggImage && <img ref={eggRef} alt='' src={URL.createObjectURL(eggImage)} style={{ position: 'absolute', ...EASTER_EGG_STYLE, ...style, animationDuration: `${duration ?? EASTER_EGG_DEFAULT_DURATION}s` }} />}
<>
<Header />
<div className='wrapper'>
{isTodayWeekend ? <h4>Užívejte víkend :)</h4> : <>
{data.isWeekend ? <h4>Užívejte víkend :)</h4> : <>
<Alert variant={'primary'}>
{/* <img alt="" src='hat.png' style={{ position: "absolute", width: "70px", rotate: "-45deg", left: -40, top: -58 }} />
<img alt="" src='snowman.png' style={{ position: "absolute", height: "110px", right: 10, top: 5 }} /> */}
Poslední změny:
<ul>
<li>Zobrazení alergenu při najetí myší a proklik na seznam alergenů</li>
<li>Přesun přenačtení menu do samostatného dialogu</li>
<li>Podzimní atmosféra</li>
<li>Podpora Gotify</li>
<li>Vylepšení dokumentace pro vývojáře</li>
<li>Potíže usnout i v pozdních nočních hodinách</li>
</ul>
</Alert>
{dayIndex != null &&
<div className='day-navigator'>
<span title='Předchozí den'>
<FontAwesomeIcon icon={faChevronLeft} style={{ cursor: "pointer", visibility: dayIndex > 0 ? "initial" : "hidden" }} onClick={() => handleDayChange(dayIndex - 1)} />
</span>
<h1 className='title' style={{ color: dayIndex === todayDayIndex ? 'black' : 'gray' }}>{getHumanDate(new Date(data.date))}</h1>
<span title="Následující den">
<FontAwesomeIcon icon={faChevronRight} style={{ cursor: "pointer", visibility: dayIndex < 4 ? "initial" : "hidden" }} onClick={() => handleDayChange(dayIndex + 1)} />
</span>
</div>
}
<h1 className='title'>Dnes je {data.date}</h1>
<Row className='food-tables'>
{/* TODO zjednodušit, stačí iterovat klíče typu Restaurant */}
{food['SLADOVNICKA'] && renderFoodTable('SLADOVNICKA', food['SLADOVNICKA'])}
{food['TECHTOWER'] && renderFoodTable('TECHTOWER', food['TECHTOWER'])}
{food['ZASTAVKAUMICHALA'] && renderFoodTable('ZASTAVKAUMICHALA', food['ZASTAVKAUMICHALA'])}
{food['SENKSERIKOVA'] && renderFoodTable('SENKSERIKOVA', food['SENKSERIKOVA'])}
{renderFoodTable('Sladovnická', food.sladovnicka)}
{renderFoodTable('U Motlíků', food.uMotliku)}
{renderFoodTable('TechTower', food.techTower)}
</Row>
<div className='content-wrapper'>
<div className='content'>
{canChangeChoice && <>
<p>{`Jak to ${dayIndex == null || dayIndex === todayDayIndex ? 'dnes' : 'tento den'} vidíš s obědem?`}</p>
<Form.Select ref={choiceRef} onChange={doAddChoice}>
<option></option>
{Object.entries(LunchChoice)
.filter(entry => {
const locationKey = entry[0] as Restaurant;
return !food[locationKey]?.closed;
})
.map(entry => <option key={entry[0]} value={entry[0]}>{getLunchChoiceName(entry[1])}</option>)}
</Form.Select>
<small>Je možné vybrat jen jednu možnost. Výběr jiné odstraní předchozí.</small>
{foodChoiceList && !closed && <>
<p style={{ marginTop: "10px" }}>Na co dobrého? <small>(nepovinné)</small></p>
<Form.Select ref={foodChoiceRef} onChange={doAddFoodChoice}>
<option></option>
{foodChoiceList.map((food, index) => <option key={food.name} value={index}>{food.name}</option>)}
</Form.Select>
</>}
{foodChoiceList && !closed && <>
<p style={{ marginTop: "10px" }}>V kolik hodin preferuješ odchod?</p>
<Form.Select ref={departureChoiceRef} onChange={handleChangeDepartureTime}>
<option></option>
{Object.values(DepartureTime)
.filter(time => isInTheFuture(time))
.map(time => <option key={time} value={time}>{time}</option>)}
</Form.Select>
</>}
</>}
<p>Jak to dnes vidíš s obědem?</p>
<Form.Select ref={choiceRef} onChange={changeChoice}>
<option></option>
<option value={Locations.SLADOVNICKA}>Sladovnická</option>
<option value={Locations.UMOTLIKU}>U Motlíků</option>
<option value={Locations.TECHTOWER}>TechTower</option>
<option value={Locations.SPSE}>SPŠE</option>
<option value={Locations.VLASTNI}>Mám vlastní</option>
<option value={Locations.OBJEDNAVAM}>Budu objednávat</option>
<option value={Locations.NEOBEDVAM}>Nebudu obědvat</option>
</Form.Select>
<p style={{ fontSize: "12px", marginTop: "5px" }}>
Aktuálně je možné vybrat pouze jednu variantu.
</p>
{Object.keys(data.choices).length > 0 ?
<Table bordered className='mt-5'>
<Table striped bordered hover className='results-table mt-5'>
<tbody>
{Object.keys(data.choices).map(key => {
const locationKey = key as LunchChoice;
const locationName = getLunchChoiceName(locationKey);
const loginObject = data.choices[locationKey];
if (!loginObject) {
return;
}
const locationLoginList = Object.entries(loginObject);
const locationPickCount = locationLoginList.length
return (
<tr key={key}>
{(locationPickCount ?? 0) > 1 ? (
<td>{locationName} ({locationPickCount})</td>
) : (
<td>{locationName}</td>)}
<td className='p-0'>
<Table>
<tbody>
{locationLoginList.map((entry: [string, UserLunchChoice], index) => {
const login = entry[0];
const userPayload = entry[1];
const userChoices = userPayload?.selectedFoods;
const trusted = userPayload?.trusted || false;
return <tr key={entry[0]}>
<td>
{trusted && <span className='trusted-icon' title='Uživatel ověřený doménovým přihlášením'>
<FontAwesomeIcon icon={faCircleCheck} style={{ cursor: "help" }} />
</span>}
{login}
{userPayload.departureTime && <small> ({userPayload.departureTime})</small>}
{userPayload.note && <span style={{ fontSize: 'small' }}> ({userPayload.note})</span>}
{login !== auth.login && canChangeChoice && userPayload?.note?.length && <span title='Převzít poznámku'>
<FontAwesomeIcon onClick={() => {
copyNote(userPayload.note!);
}} className='action-icon' icon={faComment} />
</span>}
{login === auth.login && canChangeChoice && <span title='Upravit poznámku'>
<FontAwesomeIcon onClick={() => {
setNoteModalOpen(true);
}} className='action-icon' icon={faNoteSticky} />
</span>}
{login === auth.login && canChangeChoice && <span title={`Odstranit volbu ${locationName}, včetně případných zvolených jídel`}>
<FontAwesomeIcon onClick={() => {
doRemoveChoices(key as LunchChoice);
}} className='action-icon' icon={faTrashCan} />
</span>}
</td>
{userChoices?.length && food ? <td>
<ul>
{userChoices?.map(foodIndex => {
const restaurantKey = key as Restaurant;
const foodName = food[restaurantKey]?.food?.[foodIndex].name;
return <li key={foodIndex}>
{foodName}
{login === auth.login && canChangeChoice &&
<span title={`Odstranit ${foodName}`}>
<FontAwesomeIcon onClick={() => {
doRemoveFoodChoice(restaurantKey, foodIndex);
}} className='action-icon' icon={faTrashCan} />
</span>}
</li>
})}
</ul>
</td> : null}
</tr>
}
)}
</tbody>
</Table>
</td>
</tr>)
}
{Object.keys(data.choices).map((key: string, index: number) =>
<tr key={index}>
<td>{Object.values(Locations)[Number(key)]}</td>
<td>
<ul>
{data.choices[Number(key)].map((p: string, index: number) =>
<li key={index}>{p} {p === auth.login && <FontAwesomeIcon onClick={() => {
removeChoice(key);
}} title='Odstranit' className='trash-icon' icon={faTrashCan} />}</li>
)}
</ul>
</td>
</tr>
)}
</tbody>
</Table>
: <div className='mt-5'><i>Zatím nikdo nehlasoval...</i></div>
}
</div>
{dayIndex === todayDayIndex &&
<div className='mt-5'>
{!data.pizzaDay &&
<div className='mt-5'>
{!data.pizzaDay &&
<div style={{ textAlign: 'center' }}>
<p>Pro dnešní den není aktuálně založen Pizza day.</p>
<Button onClick={async () => {
await createPizzaDay(auth.login);
}}>Založit Pizza day</Button>
</div>
}
{data.pizzaDay &&
<div>
<div style={{ textAlign: 'center' }}>
<p>Pro dnešní den není aktuálně založen Pizza day.</p>
{loadingPizzaDay ?
<span>
<FontAwesomeIcon icon={faGear} className='fa-spin' /> Zjišťujeme dostupné pizzy
</span>
:
<>
<Button onClick={async () => {
setLoadingPizzaDay(true);
await createPizzaDay().then(() => setLoadingPizzaDay(false));
}}>Založit Pizza day</Button>
<Button onClick={doJdemeObed} style={{ marginLeft: "14px" }}>Jdeme na oběd !</Button>
</>
}
</div>
}
{data.pizzaDay &&
<div>
<div style={{ textAlign: 'center' }}>
<h3>Pizza day</h3>
{
data.pizzaDay.state === PizzaDayState.CREATED &&
<div>
<p>
Pizza Day je založen a spravován uživatelem {data.pizzaDay.creator}.<br />
Můžete upravovat své objednávky.
</p>
{
data.pizzaDay.creator === auth.login &&
<>
<Button className='danger mb-3' title="Smaže kompletně pizza day, včetně dosud zadaných objednávek." onClick={async () => {
await deletePizzaDay();
}}>Smazat Pizza day</Button>
<Button className='mb-3' style={{ marginLeft: '20px' }} title={noOrders ? "Nelze uzamknout - neexistuje žádná objednávka" : "Zamezí přidávat/odebírat objednávky. Použij před samotným objednáním, aby již nemohlo docházet ke změnám."} disabled={noOrders} onClick={async () => {
await lockPizzaDay();
}}>Uzamknout</Button>
</>
}
</div>
}
{
data.pizzaDay.state === PizzaDayState.LOCKED &&
<div>
<p>Objednávky jsou uzamčeny uživatelem {data.pizzaDay.creator}</p>
{data.pizzaDay.creator === auth.login &&
<>
<Button className='danger mb-3' title="Umožní znovu editovat objednávky." onClick={async () => {
await unlockPizzaDay();
}}>Odemknout</Button>
<Button className='danger mb-3' style={{ marginLeft: '20px' }} title={noOrders ? "Nelze objednat - neexistuje žádná objednávka" : "Použij po objednání. Objednávky zůstanou zamčeny."} disabled={noOrders} onClick={async () => {
await finishOrder();
}}>Objednáno</Button>
</>
}
</div>
}
{
data.pizzaDay.state === PizzaDayState.ORDERED &&
<div>
<p>Pizzy byly objednány uživatelem {data.pizzaDay.creator}</p>
{data.pizzaDay.creator === auth.login &&
<div>
<Button className='danger mb-3' title="Vrátí stav do předchozího kroku (před objednáním)." onClick={async () => {
await lockPizzaDay();
}}>Vrátit do "uzamčeno"</Button>
<Button className='danger mb-3' style={{ marginLeft: '20px' }} title="Nastaví stav na 'Doručeno' - koncový stav." onClick={async () => {
await finishDelivery({ body: { bankAccount: settings?.bankAccount, bankAccountHolder: settings?.holderName } });
}}>Doručeno</Button>
</div>
}
</div>
}
{
data.pizzaDay.state === PizzaDayState.DELIVERED &&
<div>
<p>{`Pizzy byly doručeny.${myOrder?.hasQr ? ` Objednávku můžete uživateli ${data.pizzaDay.creator} uhradit pomocí QR kódu níže.` : ''}`}</p>
</div>
}
</div>
{data.pizzaDay.state === PizzaDayState.CREATED &&
<div style={{ textAlign: 'center' }}>
<SelectSearch
search={true}
options={pizzaSuggestions}
placeholder='Vyhledat pizzu...'
onChange={handlePizzaChange}
onBlur={_ => { }}
onFocus={_ => { }}
/>
Poznámka: <input ref={pizzaPoznamkaRef} className='mt-3' type="text" onKeyDown={event => {
if (event.key === 'Enter') {
handlePizzaPoznamkaChange();
}
event.stopPropagation();
}} />
<Button
style={{ marginLeft: '20px' }}
disabled={!myOrder?.pizzaList?.length}
onClick={handlePizzaPoznamkaChange}>
Uložit
</Button>
{
data.pizzaDay.state === State.CREATED && data.pizzaDay.creator === auth.login &&
<div>
<p>Pizza Day je založen a spravován uživatelem {data.pizzaDay.creator}.<br />Můžete upravovat své objednávky.</p>
<Button className='danger mb-3' title="Smaže kompletně pizza day, včetně dosud zadaných objednávek." onClick={async () => {
await deletePizzaDay(auth.login);
}}>Smazat Pizza day</Button>
<Button className='mb-3' style={{ marginLeft: '20px' }} title={noOrders ? "Nelze uzamknout - neexistuje žádná objednávka" : "Zamezí přidávat/odebírat objednávky. Použij před samotným objednáním, aby již nemohlo docházet ke změnám."} disabled={noOrders} onClick={async () => {
await lockPizzaDay(auth.login);
}}>Uzamknout</Button>
</div>
}
<PizzaOrderList state={data.pizzaDay.state!} orders={data.pizzaDay.orders!} onDelete={handlePizzaDelete} creator={data.pizzaDay.creator!} />
{
data.pizzaDay.state === PizzaDayState.DELIVERED && myOrder?.hasQr ?
<div className='qr-code'>
<h3>QR platba</h3>
<img src={`/api/qr?login=${auth.login}`} alt='QR kód' />
</div> : null
data.pizzaDay.state === State.LOCKED && data.pizzaDay.creator === auth.login &&
<div>
<p>Objednávky jsou uzamčeny uživatelem {data.pizzaDay.creator}</p>
<Button className='danger mb-3' title="Umožní znovu editovat objednávky." onClick={async () => {
await unlockPizzaDay(auth.login);
}}>Odemknout</Button>
{/* <Button className='danger mb-3' style={{ marginLeft: '20px' }} onClick={async () => {
await addToCart();
}}>Přidat vše do košíku</Button> */}
<Button className='danger mb-3' style={{ marginLeft: '20px' }} title={noOrders ? "Nelze objednat - neexistuje žádná objednávka" : "Použij po objednání. Objednávky zůstanou zamčeny."} disabled={noOrders} onClick={async () => {
await finishOrder(auth.login);
}}>Objednáno</Button>
</div>
}
{
data.pizzaDay.state === State.ORDERED &&
<div>
<p>Pizzy byly objednány uživatelem {data.pizzaDay.creator}</p>
{data.pizzaDay.creator === auth.login &&
<div>
<Button className='danger mb-3' title="Vrátí stav do předchozího kroku (před objednáním)." onClick={async () => {
await lockPizzaDay(auth.login);
}}>Vrátit do "uzamčeno"</Button>
<Button className='danger mb-3' style={{ marginLeft: '20px' }} title="Nastaví stav na 'Doručeno' - koncový stav." onClick={async () => {
await finishDelivery(auth.login);
}}>Doručeno</Button>
</div>
}
</div>
}
{
data.pizzaDay.state === State.DELIVERED &&
<div>
<p>Pizzy byly doručeny.</p>
</div>
}
</div>
}
</div>
}
<SelectSearch
search={true}
options={pizzaSuggestions}
placeholder='Vyhledat pizzu...'
onChange={handlePizzaChange}
disabled={data.pizzaDay.state !== State.CREATED}
/>
<PizzaOrderList state={data.pizzaDay.state} orders={data.pizzaDay.orders} onDelete={handlePizzaDelete} />
</div>
}
</div>
</div>
</> || "Jejda, něco se nám nepovedlo :("}
</>}
</div>
<FallingLeaves
numLeaves={LEAF_PRESETS.NORMAL}
leafVariants={LEAF_COLOR_THEMES.AUTUMN}
/>
<Footer />
<NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} />
</div>
</>
);
}

View File

@@ -1,33 +0,0 @@
import { Routes, Route } from "react-router-dom";
import { ProvideSettings } from "./context/settings";
// import Snowfall from "react-snowfall";
import { ToastContainer } from "react-toastify";
import { SocketContext, socket } from "./context/socket";
import StatsPage from "./pages/StatsPage";
import App from "./App";
export const STATS_URL = '/stats';
export default function AppRoutes() {
return (
<Routes>
<Route path={STATS_URL} element={<StatsPage />} />
<Route path="/" element={
<ProvideSettings>
<SocketContext.Provider value={socket}>
<>
{/* <Snowfall style={{
zIndex: 2,
position: 'fixed',
width: '100vw',
height: '100vh'
}} /> */}
<App />
</>
<ToastContainer />
</SocketContext.Provider>
</ProvideSettings>
} />
</Routes>
);
}

View File

@@ -1,31 +0,0 @@
.falling-leaves {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
z-index: 0;
overflow: hidden;
}
.leaf-scene {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 100%;
transform-style: preserve-3d;
div {
position: absolute;
top: 0;
left: 0;
width: 20px;
height: 20px;
background-repeat: no-repeat;
background-size: 100%;
transform-style: preserve-3d;
backface-visibility: visible;
}
}

View File

@@ -1,317 +0,0 @@
import React, { useEffect, useRef, useCallback } from 'react';
// Různé barevné varianty listů
const LEAF_VARIANTS = [
'leaf.svg', // Původní tmavě hnědá
'leaf-orange.svg', // Oranžová
'leaf-yellow.svg', // Žlutá
'leaf-red.svg', // Červená
'leaf-brown.svg', // Světle hnědá
'leaf-green.svg', // Zelená
] as const;
interface LeafData {
el: HTMLDivElement;
x: number;
y: number;
z: number;
rotation: {
axis: 'X' | 'Y' | 'Z';
value: number;
speed: number;
x: number;
};
xSpeedVariation: number;
ySpeed: number;
path: {
type: number;
start: number;
};
image: number;
}
interface WindOptions {
magnitude: number;
maxSpeed: number;
duration: number;
start: number;
speed: (t: number, y: number) => number;
}
interface LeafSceneOptions {
numLeaves: number;
wind: WindOptions;
}
interface FallingLeavesProps {
/** Počet padających listů (výchozí: 20) */
numLeaves?: number;
/** CSS třída pro kontejner (výchozí: 'falling-leaves') */
className?: string;
/** Barevné varianty listů k použití (výchozí: všechny) */
leafVariants?: readonly string[];
}
class LeafScene {
private viewport: HTMLElement;
private world: HTMLDivElement;
private leaves: LeafData[] = [];
private options: LeafSceneOptions;
private width: number;
private height: number;
private timer: number = 0;
private animationId: number | null = null;
private leafVariants: readonly string[];
constructor(el: HTMLElement, numLeaves: number = 20, leafVariants: readonly string[] = LEAF_VARIANTS) {
this.viewport = el;
this.world = document.createElement('div');
this.leafVariants = leafVariants;
this.options = {
numLeaves,
wind: {
magnitude: 1.2,
maxSpeed: 12,
duration: 300,
start: 0,
speed: () => 0
},
};
this.width = this.viewport.offsetWidth;
this.height = this.viewport.offsetHeight;
}
private resetLeaf = (leaf: LeafData): LeafData => {
// place leaf towards the top left
leaf.x = this.width * 2 - Math.random() * this.width * 1.75;
leaf.y = -10;
leaf.z = Math.random() * 200;
if (leaf.x > this.width) {
leaf.x = this.width + 10;
leaf.y = Math.random() * this.height / 2;
}
// at the start, the leaf can be anywhere
if (this.timer === 0) {
leaf.y = Math.random() * this.height;
}
// Choose axis of rotation.
// If axis is not X, chose a random static x-rotation for greater variability
leaf.rotation.speed = Math.random() * 10;
const randomAxis = Math.random();
if (randomAxis > 0.5) {
leaf.rotation.axis = 'X';
} else if (randomAxis > 0.25) {
leaf.rotation.axis = 'Y';
leaf.rotation.x = Math.random() * 180 + 90;
} else {
leaf.rotation.axis = 'Z';
leaf.rotation.x = Math.random() * 360 - 180;
// looks weird if the rotation is too fast around this axis
leaf.rotation.speed = Math.random() * 3;
}
// random speed
leaf.xSpeedVariation = Math.random() * 0.8 - 0.4;
leaf.ySpeed = Math.random() + 1.5;
// randomly select leaf color variant
const randomVariantIndex = Math.floor(Math.random() * this.leafVariants.length);
leaf.image = randomVariantIndex;
// apply the background image to the leaf element
const leafVariant = this.leafVariants[randomVariantIndex];
leaf.el.style.backgroundImage = `url(${leafVariant})`;
return leaf;
};
private updateLeaf = (leaf: LeafData): void => {
const leafWindSpeed = this.options.wind.speed(this.timer - this.options.wind.start, leaf.y);
const xSpeed = leafWindSpeed + leaf.xSpeedVariation;
leaf.x -= xSpeed;
leaf.y += leaf.ySpeed;
leaf.rotation.value += leaf.rotation.speed;
const transform = `translateX(${leaf.x}px) translateY(${leaf.y}px) translateZ(${leaf.z}px) rotate${leaf.rotation.axis}(${leaf.rotation.value}deg)${leaf.rotation.axis !== 'X' ? ` rotateX(${leaf.rotation.x}deg)` : ''
}`;
leaf.el.style.transform = transform;
// reset if out of view
if (leaf.x < -10 || leaf.y > this.height + 10) {
this.resetLeaf(leaf);
}
};
private updateWind = (): void => {
// wind follows a sine curve: asin(b*time + c) + a
// where a = wind magnitude as a function of leaf position, b = wind.duration, c = offset
// wind duration should be related to wind magnitude, e.g. higher windspeed means longer gust duration
if (this.timer === 0 || this.timer > (this.options.wind.start + this.options.wind.duration)) {
this.options.wind.magnitude = Math.random() * this.options.wind.maxSpeed;
this.options.wind.duration = this.options.wind.magnitude * 50 + (Math.random() * 20 - 10);
this.options.wind.start = this.timer;
const screenHeight = this.height;
this.options.wind.speed = function (t: number, y: number) {
// should go from full wind speed at the top, to 1/2 speed at the bottom, using leaf Y
const a = this.magnitude / 2 * (screenHeight - 2 * y / 3) / screenHeight;
return a * Math.sin(2 * Math.PI / this.duration * t + (3 * Math.PI / 2)) + a;
};
}
};
public init = (): void => {
// Clear existing leaves
this.leaves = [];
this.world.innerHTML = '';
for (let i = 0; i < this.options.numLeaves; i++) {
const leaf: LeafData = {
el: document.createElement('div'),
x: 0,
y: 0,
z: 0,
rotation: {
axis: 'X',
value: 0,
speed: 0,
x: 0
},
xSpeedVariation: 0,
ySpeed: 0,
path: {
type: 1,
start: 0,
},
image: 1
};
this.resetLeaf(leaf);
this.leaves.push(leaf);
this.world.appendChild(leaf.el);
}
this.world.className = 'leaf-scene';
this.viewport.appendChild(this.world);
// set perspective
this.world.style.perspective = "400px";
// reset window height/width on resize
const handleResize = (): void => {
this.width = this.viewport.offsetWidth;
this.height = this.viewport.offsetHeight;
};
window.addEventListener('resize', handleResize);
};
public render = (): void => {
this.updateWind();
for (let i = 0; i < this.leaves.length; i++) {
this.updateLeaf(this.leaves[i]);
}
this.timer++;
this.animationId = requestAnimationFrame(this.render);
};
public destroy = (): void => {
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
if (this.world && this.world.parentNode) {
this.world.parentNode.removeChild(this.world);
}
window.removeEventListener('resize', () => {
this.width = this.viewport.offsetWidth;
this.height = this.viewport.offsetHeight;
});
};
}
/**
* Komponenta pro zobrazení padajících listů na pozadí stránky
*
* @param numLeaves - Počet padajících listů (výchozí: 20)
* @param className - CSS třída pro kontejner (výchozí: 'falling-leaves')
* @param leafVariants - Barevné varianty listů k použití (výchozí: všechny)
*
* @example
* // Základní použití s výchozím počtem listů
* <FallingLeaves />
*
* @example
* // Použití s vlastním počtem listů
* <FallingLeaves numLeaves={50} />
*
* @example
* // Použití s vlastní CSS třídou a pouze podzimními barvami
* <FallingLeaves
* numLeaves={15}
* className="autumn-leaves"
* leafVariants={['leaf-orange.svg', 'leaf-red.svg', 'leaf-yellow.svg']}
* />
*/
const FallingLeaves: React.FC<FallingLeavesProps> = ({
numLeaves = 20,
className = 'falling-leaves',
leafVariants = LEAF_VARIANTS
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const leafSceneRef = useRef<LeafScene | null>(null);
const initializeLeafScene = useCallback(() => {
if (containerRef.current) {
leafSceneRef.current = new LeafScene(containerRef.current, numLeaves, leafVariants);
leafSceneRef.current.init();
leafSceneRef.current.render();
}
}, [numLeaves, leafVariants]);
useEffect(() => {
initializeLeafScene();
return () => {
if (leafSceneRef.current) {
leafSceneRef.current.destroy();
leafSceneRef.current = null;
}
};
}, [initializeLeafScene]);
return <div ref={containerRef} className={className} />;
};
// Přednastavení pro různé účely
export const LEAF_PRESETS = {
LIGHT: 10, // Lehký podzimní efekt
NORMAL: 20, // Standardní množství
HEAVY: 40, // Silný podzimní vítr
BLIZZARD: 80 // Hustý pád listí
} as const;
// Přednastavené barevné kombinace
export const LEAF_COLOR_THEMES = {
ALL: LEAF_VARIANTS, // Všechny barvy
AUTUMN: ['leaf-orange.svg', 'leaf-red.svg', 'leaf-yellow.svg', 'leaf-brown.svg'] as const, // Podzimní barvy
WARM: ['leaf-orange.svg', 'leaf-red.svg', 'leaf-brown.svg'] as const, // Teplé barvy
CLASSIC: ['leaf.svg', 'leaf-brown.svg'] as const, // Klasické hnědé odstíny
BRIGHT: ['leaf-yellow.svg', 'leaf-orange.svg'] as const, // Světlé barvy
} as const;
export default FallingLeaves;

View File

@@ -1,7 +1,6 @@
import React, { useCallback, useEffect, useRef } from 'react';
import React, { useCallback, useRef } from 'react';
import { Button } from 'react-bootstrap';
import { useAuth } from './context/auth';
import { login } from '../../types';
import './Login.css';
/**
@@ -11,31 +10,14 @@ export default function Login() {
const auth = useAuth();
const loginRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (auth && !auth.login) {
// Vyzkoušíme přihlášení "naprázdno", pokud projde, přihlásili nás trusted headers
login().then(response => {
const token = response.data;
if (token) {
auth?.setToken(token as unknown as string); // TODO vyřešit, API definice je špatně, je to skutečně string
}
}).catch(error => {
// nezajímá nás
});
}
}, [auth]);
const doLogin = useCallback(async () => {
const length = loginRef?.current?.value.length && loginRef.current.value.replace(/\s/g, '').length
const doLogin = useCallback(() => {
const length = loginRef?.current?.value && loginRef?.current?.value.length && loginRef.current.value.replace(/\s/g, '').length
if (length) {
const response = await login({ body: { login: loginRef.current?.value } });
if (response.data) {
auth?.setToken(response.data as unknown as string); // TODO vyřešit
}
auth?.setLogin(loginRef.current.value);
}
}, [auth]);
if (!auth?.login) {
if (!auth || !auth.login) {
return <div className='login'>
<h1>Luncher</h1>
<h4 style={{ marginBottom: "50px" }}>Aplikace pro profesionální management obědů</h4>

67
client/src/Types.tsx Normal file
View File

@@ -0,0 +1,67 @@
// TODO všechno v tomto souboru jsou duplicity se serverem, ale aktuálně nevím jaký je nejlepší způsob jejich sdílení
export interface PizzaSize {
varId: number, // unikátní ID varianty pizzy
size: string, // velikost pizzy, např. "30cm"
pizzaPrice: number, // cena samotné pizzy
boxPrice: number, // cena krabice
price: number, // celková cena (pizza + krabice)
}
/** Jedna konkrétní pizza */
export interface Pizza {
name: string, // název pizzy
ingredients: string[], // seznam ingrediencí
sizes: PizzaSize[], // dostupné velikosti pizzy
}
/** Objednávka jedné konkrétní pizzy */
export interface PizzaOrder {
varId: number, // unikátní ID varianty pizzy
name: string, // název pizzy
size: string, // velikost pizzy jako string (30cm)
price: number, // cena pizzy v Kč, včetně krabice
}
/** Celková objednávka jednoho člověka */
export interface Order {
customer: string, // jméno objednatele
pizzaList: PizzaOrder[], // seznam objednaných pizz
totalPrice: number, // celková cena všech objednaných pizz a krabic
}
export interface Choices {
[location: string]: string[],
}
/** Údaje o Pizza day. */
export interface PizzaDay {
state: State,
creator: string,
orders: Order[]
}
export interface ClientData {
date: string, // dnešní datum pro zobrazení
isWeekend: boolean, // příznak zda je dnešní den víkend
choices: Choices, // seznam voleb
pizzaDay?: PizzaDay, // údaje o pizza day, pokud je pro dnešek založen
}
export enum Locations {
SLADOVNICKA = 'Sladovnická',
UMOTLIKU = 'U Motlíků',
TECHTOWER = 'TechTower',
SPSE = 'SPŠE',
VLASTNI = 'Mám vlastní',
OBJEDNAVAM = 'Objednávám',
NEOBEDVAM = 'Neobědvám',
}
export enum State {
NOT_CREATED, // Pizza day nebyl založen
CREATED, // Pizza day je založen
LOCKED, // Objednávky uzamčeny
ORDERED, // Objednáno
DELIVERED, // Doručeno
}

View File

@@ -1,112 +1,38 @@
import { DepartureTime } from "../../types";
const TOKEN_KEY = "token";
/**
* Uloží token do local storage prohlížeče.
* Vrátí kořenovou URL serveru na základě aktuálního prostředí (vývojovou či produkční).
*
* @param token token
* @returns kořenová URL serveru
*/
export const storeToken = (token: string) => {
localStorage.setItem(TOKEN_KEY, token);
}
/**
* Vrátí token z local storage, pokud tam je.
*
* @returns token nebo null
*/
export const getToken = (): string | undefined => {
return localStorage.getItem(TOKEN_KEY) ?? undefined;
}
/**
* Odstraní token z local storage, pokud tam je.
*/
export const deleteToken = () => {
localStorage.removeItem(TOKEN_KEY);
}
/**
* Vrátí human-readable reprezentaci předaného data a času pro zobrazení.
* Příklady:
* - dnes 10:52
* - 10.05.2023 10:52
*/
export function getHumanDateTime(datetime: Date) {
let hours = String(datetime.getHours()).padStart(2, '0');
let minutes = String(datetime.getMinutes()).padStart(2, "0");
if (new Date().toDateString() === datetime.toDateString()) {
return `dnes ${hours}:${minutes}`;
} else {
let day = String(datetime.getDate()).padStart(2, '0');
let month = String(datetime.getMonth() + 1).padStart(2, "0");
let year = datetime.getFullYear();
return `${day}.${month}.${year} ${hours}:${minutes}`;
export const getBaseUrl = (): string => {
if (process.env.PUBLIC_URL) {
return process.env.PUBLIC_URL;
}
return 'http://localhost:3001';
}
/**
* Vrátí true, pokud je předaný čas větší než aktuální čas.
*/
export function isInTheFuture(time: DepartureTime) {
const now = new Date();
const currentHours = now.getHours();
const currentMinutes = now.getMinutes();
const currentDate = now.toDateString();
const [hours, minutes] = time.split(':').map(Number);
if (currentDate === now.toDateString()) {
return hours > currentHours || (hours === currentHours && minutes > currentMinutes);
}
return true;
}
const LOGIN_KEY = "login";
/**
* Vrátí index dne v týdnu, kde pondělí=0, neděle=6
* Uloží login do local storage prohlížeče.
*
* @param date datum
* @returns index dne v týdnu
* @param login login
*/
export const getDayOfWeekIndex = (date: Date) => {
// https://stackoverflow.com/a/4467559
return (((date.getDay() - 1) % 7) + 7) % 7;
export const storeLogin = (login: string) => {
localStorage.setItem(LOGIN_KEY, login);
}
/** Vrátí true, pokud je předané datum o víkendu. */
export function getIsWeekend(date: Date) {
const index = getDayOfWeekIndex(date);
return index == 5 || index == 6;
/**
* Vrátí login z local storage, pokud tam je.
*
* @returns login nebo null
*/
export const getLogin = (): string | null => {
return localStorage.getItem(LOGIN_KEY);
}
/** Vrátí první pracovní den v týdnu předaného data. */
export function getFirstWorkDayOfWeek(date: Date) {
const firstDay = new Date(date.getTime());
firstDay.setDate(date.getDate() - getDayOfWeekIndex(date));
return firstDay;
}
/** Vrátí poslední pracovní den v týdnu předaného data. */
export function getLastWorkDayOfWeek(date: Date) {
const lastDay = new Date(date.getTime());
lastDay.setDate(date.getDate() + (4 - getDayOfWeekIndex(date)));
return lastDay;
}
/** Vrátí datum v ISO formátu. */
export function formatDate(date: Date, format?: string) {
let day = String(date.getDate()).padStart(2, '0');
let month = String(date.getMonth() + 1).padStart(2, "0");
let year = String(date.getFullYear());
const f = format ?? 'YYYY-MM-DD';
return f.replace('DD', day).replace('MM', month).replace('YYYY', year);
}
/** Vrátí human-readable reprezentaci předaného data pro zobrazení. */
export function getHumanDate(date: Date) {
let currentDay = String(date.getDate()).padStart(2, '0');
let currentMonth = String(date.getMonth() + 1).padStart(2, "0");
let currentYear = date.getFullYear();
return `${currentDay}.${currentMonth}.${currentYear}`;
/**
* Odstraní login z local storage, pokud tam je.
*/
export const deleteLogin = () => {
localStorage.removeItem(LOGIN_KEY);
}

View File

@@ -1,12 +0,0 @@
import { Navbar } from "react-bootstrap";
export default function Footer() {
return <Navbar className="text-light" variant='dark' expand="lg" style={{
display: "flex",
justifyContent: "center",
marginTop: "auto", // Pushne footer na spodek
flexShrink: 0 // Zabrání zmenšování při malém obsahu
}}>
<span>🄯 Žádná práva nevyhrazena. TODO a zdrojové kódy dostupné <a href="https://gitea.melancholik.eu/mates/Luncher">zde</a>.</span>
</Navbar >
}

View File

@@ -1,137 +1,59 @@
import { useEffect, useState } from "react";
import { Navbar, Nav, NavDropdown } from "react-bootstrap";
import React, { useRef, useState } from "react";
import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap";
import { useAuth } from "../context/auth";
import SettingsModal from "./modals/SettingsModal";
import { useSettings } from "../context/settings";
import FeaturesVotingModal from "./modals/FeaturesVotingModal";
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
import RefreshMenuModal from "./modals/RefreshMenuModal";
import { useNavigate } from "react-router";
import { STATS_URL } from "../AppRoutes";
import { FeatureRequest, getVotes, updateVote } from "../../../types";
import { useBank } from "../context/bank";
export default function Header() {
const auth = useAuth();
const settings = useSettings();
const navigate = useNavigate();
const [settingsModalOpen, setSettingsModalOpen] = useState<boolean>(false);
const [votingModalOpen, setVotingModalOpen] = useState<boolean>(false);
const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false);
const [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState<boolean>(false);
const [featureVotes, setFeatureVotes] = useState<FeatureRequest[] | undefined>([]);
const bank = useBank();
const [modalOpen, setModalOpen] = useState<boolean>(false);
const bankAccountRef = useRef<HTMLInputElement>(null);
const nameRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (auth?.login) {
getVotes().then(response => {
setFeatureVotes(response.data);
})
}
}, [auth?.login]);
const closeSettingsModal = () => {
setSettingsModalOpen(false);
const openBankSettings = () => {
setModalOpen(true);
}
const closeVotingModal = () => {
setVotingModalOpen(false);
const closeModal = () => {
setModalOpen(false);
}
const closePizzaModal = () => {
setPizzaModalOpen(false);
}
const closeRefreshMenuModal = () => {
setRefreshMenuModalOpen(false);
}
const isValidInteger = (str: string) => {
str = str.trim();
if (!str) {
return false;
}
str = str.replace(/^0+/, "") || "0";
const n = Math.floor(Number(str));
return n !== Infinity && String(n) === str && n >= 0;
}
const saveSettings = (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean) => {
if (bankAccountNumber) {
try {
// Validace kódu banky
if (bankAccountNumber.indexOf('/') < 0) {
throw Error("Číslo účtu neobsahuje lomítko/kód banky")
}
const split = bankAccountNumber.split("/");
if (split[1].length !== 4) {
throw Error("Kód banky musí být 4 číslice")
}
if (!isValidInteger(split[1])) {
throw Error("Kód banky není číslo")
}
// Validace čísla a předčíslí
let cislo = split[0];
if (cislo.indexOf('-') > 0) {
cislo = cislo.replace('-', '');
}
if (!isValidInteger(cislo)) {
throw Error("Předčíslí nebo číslo účtu neobsahuje pouze číslice")
}
if (cislo.length < 16) {
cislo = cislo.padStart(16, '0');
}
let sum = 0;
for (let i = 0; i < cislo.length; i++) {
const char = cislo.charAt(i);
const order = (cislo.length - 1) - i;
const weight = (2 ** order) % 11;
sum += Number.parseInt(char) * weight
}
if (sum % 11 !== 0) {
throw Error("Číslo účtu je neplatné")
}
} catch (e: any) {
alert(e.message)
return
}
}
settings?.setBankAccountNumber(bankAccountNumber);
settings?.setBankAccountHolderName(bankAccountHolderName);
settings?.setHideSoupsOption(hideSoupsOption);
closeSettingsModal();
}
const saveFeatureVote = async (option: FeatureRequest, active: boolean) => {
await updateVote({ body: { option, active } });
const votes = [...featureVotes || []];
if (active) {
votes.push(option);
} else {
votes.splice(votes.indexOf(option), 1);
}
setFeatureVotes(votes);
const save = () => {
// TODO validace na modulo 11
bank?.setBankAccountNumber(bankAccountRef.current?.value);
bank?.setBankAccountHolderName(nameRef.current?.value);
closeModal();
}
return <Navbar variant='dark' expand="lg">
<Navbar.Brand href="/">Luncher</Navbar.Brand>
<Navbar.Brand>Luncher</Navbar.Brand>
<Navbar.Toggle aria-controls="basic-navbar-nav" />
<Navbar.Collapse id="basic-navbar-nav">
<Nav className="nav">
<NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown">
<NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item>
<NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item>
<NavDropdown.Item onClick={() => setVotingModalOpen(true)}>Hlasovat o nových funkcích</NavDropdown.Item>
<NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item>
<NavDropdown.Divider />
<NavDropdown.Item onClick={auth?.logout}>Odhlásit se</NavDropdown.Item>
<NavDropdown.Item onClick={openBankSettings}>Nastavit číslo účtu</NavDropdown.Item>
<NavDropdown.Item onClick={auth?.clearLogin}>Odhlásit se</NavDropdown.Item>
</NavDropdown>
</Nav>
</Navbar.Collapse>
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
<FeaturesVotingModal isOpen={votingModalOpen} onClose={closeVotingModal} onChange={saveFeatureVote} initialValues={featureVotes} />
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
<Modal show={modalOpen} onHide={closeModal}>
<Modal.Header closeButton>
<Modal.Title>Bankovní účet</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>Nastavením čísla účtu umožníte automatické generování QR kódů pro úhradu za vámi provedené objednávky v rámci Pizza day.<br />Pokud vaše číslo účtu neobsahuje předčíslí, je možné ho zcela vynechat.<br /><br />Poznámka: Číslo účtu není aktuálně nijak validováno. Ověřte si jeho správnost.</p>
Číslo účtu: <input ref={bankAccountRef} type="text" placeholder="123456-1234567890/1234" /> <br />
Název příjemce (nepovinné): <input ref={nameRef} type="text" placeholder="Jan Novák" />
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={closeModal}>
Storno
</Button>
<Button variant="primary" onClick={save}>
Uložit
</Button>
</Modal.Footer>
</Modal>
</Navbar>
}

View File

@@ -1,19 +0,0 @@
import { IconDefinition } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
type Props = {
title?: string,
icon: IconDefinition,
description: string,
animation?: string,
}
function Loader(props: Readonly<Props>) {
return <div className='loader'>
<h1>{props.title ?? 'Prosím čekejte...'}</h1>
<FontAwesomeIcon icon={props.icon} className={`loader-icon mb-3 ` + (props.animation ?? '')} />
<p>{props.description}</p>
</div>
}
export default Loader;

View File

@@ -1,43 +1,41 @@
import React from "react";
import { Table } from "react-bootstrap";
import PizzaOrderRow from "./PizzaOrderRow";
import { PizzaDayState, PizzaOrder, PizzaVariant, updatePizzaFee } from "../../../types";
import { Order, PizzaOrder, State } from "../Types";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrashCan } from "@fortawesome/free-regular-svg-icons";
import { useAuth } from "../context/auth";
type Props = {
state: PizzaDayState,
orders: PizzaOrder[],
onDelete: (pizzaOrder: PizzaVariant) => void,
creator: string,
}
export default function PizzaOrderList({ state, orders, onDelete, creator }: Readonly<Props>) {
const saveFees = async (customer: string, text?: string, price?: number) => {
await updatePizzaFee({ body: { login: customer, text, price } });
}
export default function PizzaOrderList({ state, orders, onDelete }: { state: State, orders: Order[], onDelete: (pizzaOrder: PizzaOrder) => void }) {
const auth = useAuth();
if (!orders?.length) {
return <p className="mt-3"><i>Zatím žádné objednávky...</i></p>
return <p><i>Zatím žádné objednávky...</i></p>
}
const total = orders.reduce((total, order) => total + order.totalPrice, 0);
return <Table className="mt-3" striped bordered hover>
<thead>
<tr>
<th>Jméno</th>
<th>Objednávka</th>
<th>Poznámka</th>
<th>Příplatek</th>
<th>Cena</th>
</tr>
</thead>
<tbody>
{orders.map(order => <tr key={order.customer}>
<PizzaOrderRow creator={creator} state={state} order={order} onDelete={onDelete} onFeeModalSave={saveFees} />
<td>{order.customer}</td>
<td>{order.pizzaList.map<React.ReactNode>((pizzaOrder, index) =>
<span key={index}>
{`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price} Kč)`}
{auth?.login === order.customer && state === State.CREATED &&
<FontAwesomeIcon onClick={() => {
onDelete(pizzaOrder);
}} title='Odstranit' className='trash-icon' icon={faTrashCan} />
}
</span>)
.reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])}
</td>
<td>{order.totalPrice} </td>
</tr>)}
<tr style={{ fontWeight: 'bold' }}>
<td colSpan={4}>Celkem</td>
<td>{`${total}`}</td>
</tr>
</tbody>
</Table>
}

View File

@@ -1,47 +0,0 @@
import React, { useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faMoneyBill1, faTrashCan } from "@fortawesome/free-regular-svg-icons";
import { useAuth } from "../context/auth";
import PizzaAdditionalFeeModal from "./modals/PizzaAdditionalFeeModal";
import { PizzaDayState, PizzaOrder, PizzaVariant } from "../../../types";
type Props = {
creator: string,
order: PizzaOrder,
state: PizzaDayState,
onDelete: (order: PizzaVariant) => void,
onFeeModalSave: (customer: string, name?: string, price?: number) => void,
}
export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeModalSave }: Readonly<Props>) {
const auth = useAuth();
const [isFeeModalOpen, setIsFeeModalOpen] = useState<boolean>(false);
const saveFees = (customer: string, text?: string, price?: number) => {
onFeeModalSave(customer, text, price);
setIsFeeModalOpen(false);
}
return <>
<td>{order.customer}</td>
<td>{order.pizzaList!.map<React.ReactNode>(pizzaOrder =>
<span key={pizzaOrder.name}>
{`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price} Kč)`}
{auth?.login === order.customer && state === PizzaDayState.CREATED &&
<span title='Odstranit'>
<FontAwesomeIcon onClick={() => {
onDelete(pizzaOrder);
}} className='action-icon' icon={faTrashCan} />
</span>
}
</span>)
.reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])}
</td>
<td style={{ maxWidth: "200px" }}>{order.note ?? '-'}</td>
<td style={{ maxWidth: "200px" }}>{order.fee?.price ? `${order.fee.price}${order.fee.text ? ` (${order.fee.text})` : ''}` : '-'}</td>
<td>
{order.totalPrice} {auth?.login === creator && state === PizzaDayState.CREATED && <span title='Nastavit příplatek'><FontAwesomeIcon onClick={() => { setIsFeeModalOpen(true) }} className='action-icon' icon={faMoneyBill1} /></span>}
</td>
<PizzaAdditionalFeeModal customerName={order.customer} isOpen={isFeeModalOpen} onClose={() => setIsFeeModalOpen(false)} onSave={saveFees} initialValues={{ text: order.fee?.text, price: order.fee?.price?.toString() }} />
</>
}

View File

@@ -1,45 +0,0 @@
import { Modal, Button, Form } from "react-bootstrap"
import { FeatureRequest } from "../../../../types";
type Props = {
isOpen: boolean,
onClose: () => void,
onChange: (option: FeatureRequest, active: boolean) => void,
initialValues?: FeatureRequest[],
}
/** Modální dialog pro hlasování o nových funkcích. */
export default function FeaturesVotingModal({ isOpen, onClose, onChange, initialValues }: Readonly<Props>) {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.currentTarget.value as FeatureRequest, e.currentTarget.checked);
}
return <Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title>
Hlasujte pro nové funkce
<p style={{ fontSize: '12px' }}>Je možno vybrat maximálně 4 možnosti</p>
</Modal.Title>
</Modal.Header>
<Modal.Body>
{(Object.keys(FeatureRequest) as Array<keyof typeof FeatureRequest>).map(key => {
return <Form.Check
key={key}
type='checkbox'
id={key}
label={FeatureRequest[key]}
onChange={handleChange}
value={key}
defaultChecked={initialValues?.includes(key as FeatureRequest)}
/>
})}
<p className="mt-3" style={{ fontSize: '12px' }}>Něco jiného? Dejte vědět.</p>
</Modal.Body>
<Modal.Footer>
<Button variant="primary" onClick={onClose}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
}

View File

@@ -1,36 +0,0 @@
import { useRef } from "react";
import { Modal, Button, Form } from "react-bootstrap"
type Props = {
isOpen: boolean,
onClose: () => void,
onSave: (note?: string) => void,
}
/** Modální dialog pro úpravu obecné poznámky. */
export default function NoteModal({ isOpen, onClose, onSave }: Readonly<Props>) {
const note = useRef<HTMLInputElement>(null);
const save = () => {
onSave(note?.current?.value);
}
return <Modal show={isOpen} onHide={onClose}>
<Modal.Header closeButton>
<Modal.Title>Úprava poznámky</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form.Control ref={note} autoFocus={true} type="text" id="note" onKeyDown={event => {
if (event.key === 'Enter') {
save();
}
event.stopPropagation();
}} />
</Modal.Body>
<Modal.Footer>
<Button variant="primary" onClick={save}>
Uložit
</Button>
</Modal.Footer>
</Modal>
}

View File

@@ -1,45 +0,0 @@
import { useRef } from "react";
import { Modal, Button } from "react-bootstrap"
type Props = {
customerName: string,
isOpen: boolean,
onClose: () => void,
onSave: (customer: string, name?: string, price?: number) => void,
initialValues?: { text?: string, price?: string },
}
/** Modální dialog pro nastavení příplatků za pizzu. */
export default function PizzaAdditionalFeeModal({ customerName, isOpen, onClose, onSave, initialValues }: Readonly<Props>) {
const textRef = useRef<HTMLInputElement>(null);
const priceRef = useRef<HTMLInputElement>(null);
const doSubmit = () => {
onSave(customerName, textRef.current?.value, parseInt(priceRef.current?.value ?? "0"));
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
onSave(customerName, textRef.current?.value, parseInt(priceRef.current?.value ?? "0"));
}
}
return <Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title>Příplatky za objednávku pro {customerName}</Modal.Title>
</Modal.Header>
<Modal.Body>
Popis: <input className="mb-3" ref={textRef} type="text" placeholder="např. kuřecí maso" defaultValue={initialValues?.text} onKeyDown={handleKeyDown} /> <br />
Cena v : <input ref={priceRef} type="number" placeholder="0" defaultValue={initialValues?.price} onKeyDown={handleKeyDown} /> <br />
<div className="mt-3" style={{ fontSize: 'small' }}>Je možné zadávat i záporné částky (např. v případě slev)</div>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>
Storno
</Button>
<Button variant="primary" onClick={doSubmit}>
Uložit
</Button>
</Modal.Footer>
</Modal>
}

View File

@@ -1,137 +0,0 @@
import { useRef, useState } from "react";
import { Modal, Button, Row, Col } from "react-bootstrap"
type Props = {
isOpen: boolean,
onClose: () => void,
}
type Result = {
pizza1?: {
diameter?: number,
area?: number,
pricePerM?: number,
},
pizza2?: {
diameter?: number,
area?: number,
pricePerM?: number,
}
choice?: number,
ratio?: number,
diameterDiff?: number,
}
/** Modální dialog pro výpočet výhodnosti pizzy. */
export default function PizzaCalculatorModal({ isOpen, onClose }: Readonly<Props>) {
const diameter1Ref = useRef<HTMLInputElement>(null);
const price1Ref = useRef<HTMLInputElement>(null);
const diameter2Ref = useRef<HTMLInputElement>(null);
const price2Ref = useRef<HTMLInputElement>(null);
const [result, setResult] = useState<Result | null>(null);
const recalculate = () => {
const r: Result = { ...result }
// 1. pizza
if (diameter1Ref.current?.value) {
const diameter1 = parseInt(diameter1Ref.current?.value);
r.pizza1 ??= {};
if (diameter1 && diameter1 > 0) {
r.pizza1.diameter = diameter1;
r.pizza1.area = Math.PI * Math.pow(diameter1 / 2, 2);
if (price1Ref.current?.value) {
const price1 = parseInt(price1Ref.current?.value);
if (price1) {
r.pizza1.pricePerM = price1 / r.pizza1.area;
} else {
r.pizza1.pricePerM = undefined;
}
}
} else {
r.pizza1.area = undefined;
}
}
// 2. pizza
if (diameter2Ref.current?.value) {
const diameter2 = parseInt(diameter2Ref.current?.value);
r.pizza2 ??= {};
if (diameter2 && diameter2 > 0) {
r.pizza2.diameter = diameter2;
r.pizza2.area = Math.PI * Math.pow(diameter2 / 2, 2);
if (price2Ref.current?.value) {
const price2 = parseInt(price2Ref.current?.value);
if (price2) {
r.pizza2.pricePerM = price2 / r.pizza2.area;
} else {
r.pizza2.pricePerM = undefined;
}
}
} else {
r.pizza2.area = undefined;
}
}
// Srovnání
if (r.pizza1?.pricePerM && r.pizza2?.pricePerM && r.pizza1.diameter && r.pizza2.diameter) {
r.choice = r.pizza1.pricePerM < r.pizza2.pricePerM ? 1 : 2;
const bigger = r.pizza1.pricePerM > r.pizza2.pricePerM ? r.pizza1.pricePerM : r.pizza2.pricePerM;
const smaller = r.pizza1.pricePerM < r.pizza2.pricePerM ? r.pizza1.pricePerM : r.pizza2.pricePerM;
r.ratio = (bigger / smaller) - 1;
r.diameterDiff = Math.abs(r.pizza1.diameter - r.pizza2.diameter);
} else {
r.choice = undefined;
r.ratio = undefined;
r.diameterDiff = undefined;
}
setResult(r);
}
const close = () => {
setResult(null);
onClose();
}
return <Modal show={isOpen} onHide={onClose}>
<Modal.Header closeButton>
<Modal.Title>Pizza kalkulačka</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>Zadejte parametry pizzy pro jejich srovnání.</p>
<Row>
<Col size="6">
<input className="mb-3" ref={diameter1Ref} type="number" step="1" min="1" placeholder="Průměr 1. pizzy (cm)" onChange={recalculate} onKeyDown={e => e.stopPropagation()} />
</Col>
<Col size="6">
<input className="mb-3" ref={diameter2Ref} type="number" step="1" min="1" placeholder="Průměr 2. pizzy (cm)" onChange={recalculate} onKeyDown={e => e.stopPropagation()} />
</Col>
</Row>
<Row>
<Col size="6">
<input className="mb-3" ref={price1Ref} type="number" min="1" placeholder="Cena 1. pizzy (Kč)" onChange={recalculate} onKeyDown={e => e.stopPropagation()} />
</Col>
<Col size="6">
<input className="mb-3" ref={price2Ref} type="number" min="1" placeholder="Cena 2. pizzy (Kč)" onChange={recalculate} onKeyDown={e => e.stopPropagation()} />
</Col>
</Row>
<Row>
<Col size="6">
{result?.pizza1?.area && <p>Plocha: <b>{Math.round(result.pizza1.area * 10) / 10}</b> cm²</p>}
{result?.pizza1?.pricePerM && <p>Cena za m²: <b>{Math.round(result.pizza1.pricePerM * 1000000) / 100}</b> </p>}
</Col>
<Col size="6">
{result?.pizza2?.area && <p>Plocha: <b>{Math.round(result.pizza2.area * 10) / 10}</b> cm²</p>}
{result?.pizza2?.pricePerM && <p>Cena za m²: <b>{Math.round(result.pizza2.pricePerM * 1000000) / 100}</b> </p>}
</Col>
</Row>
{(result?.choice && result?.ratio && result?.ratio > 0 && result?.diameterDiff != null && <p><b>{result.choice}. pizza</b> je zhruba o <b>{Math.round(result.ratio * 1000) / 10}%</b> výhodnější než {result.choice === 1 ? "2" : "1"}. pizza.</p>) || ''}
</Modal.Body>
<Modal.Footer>
<Button variant="primary" onClick={close}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
}

View File

@@ -1,105 +0,0 @@
import { useRef, useState } from "react";
import { Modal, Button, Alert } from "react-bootstrap";
type Props = {
isOpen: boolean;
onClose: () => void;
};
/** Modální dialog pro přenačtení menu z restaurací. */
export default function RefreshMenuModal({ isOpen, onClose }: Readonly<Props>) {
const refreshPassRef = useRef<HTMLInputElement>(null);
const refreshTypeRef = useRef<HTMLSelectElement>(null);
const [refreshLoading, setRefreshLoading] = useState(false);
const [refreshMessage, setRefreshMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
const handleRefresh = async () => {
const password = refreshPassRef.current?.value;
const type = refreshTypeRef.current?.value;
if (!password || !type) {
setRefreshMessage({ type: 'error', text: 'Zadejte heslo a typ refresh.' });
return;
}
setRefreshLoading(true);
setRefreshMessage(null);
try {
const res = await fetch(`/api/food/refresh?type=${type}&heslo=${encodeURIComponent(password)}`);
const data = await res.json();
if (res.ok) {
setRefreshMessage({ type: 'success', text: 'Uspesny fetch' });
if (refreshPassRef.current) {
// Clean hesla xd
refreshPassRef.current.value = '';
}
} else {
setRefreshMessage({ type: 'error', text: data.error || 'Chyba při obnovování jídelníčku.' });
}
} catch (error) {
console.error('Error refreshing menu:', error);
setRefreshMessage({ type: 'error', text: 'Chyba při obnovování jídelníčku.' });
} finally {
setRefreshLoading(false);
}
};
const handleClose = () => {
setRefreshMessage(null);
onClose();
};
return (
<Modal show={isOpen} onHide={handleClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Přenačtení menu</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
<p>Ruční refresh dat z restaurací.</p>
{refreshMessage && (
<Alert variant={refreshMessage.type === 'success' ? 'success' : 'danger'}>
{refreshMessage.text}
</Alert>
)}
<div className="mb-3">
Heslo: <input
ref={refreshPassRef}
type="password"
placeholder="Zadejte heslo"
className="form-control d-inline-block"
style={{ width: 'auto', marginLeft: '10px' }}
onKeyDown={e => e.stopPropagation()}
/>
</div>
<div className="mb-3">
Typ refreshe: <select
ref={refreshTypeRef}
className="form-select d-inline-block"
style={{ width: 'auto', marginLeft: '10px' }}
defaultValue="week"
>
<option value="week">Týden</option>
<option value="day">Den</option>
</select>
</div>
<Button
variant="info"
onClick={handleRefresh}
disabled={refreshLoading}
className="mb-3"
>
{refreshLoading ? 'Refreshing...' : 'Refresh'}
</Button>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={handleClose}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
);
}

View File

@@ -1,42 +0,0 @@
import { useRef } from "react";
import { Modal, Button } from "react-bootstrap"
import { useSettings } from "../../context/settings";
type Props = {
isOpen: boolean,
onClose: () => void,
onSave: (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean) => void,
}
/** Modální dialog pro uživatelská nastavení. */
export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Props>) {
const settings = useSettings();
const bankAccountRef = useRef<HTMLInputElement>(null);
const nameRef = useRef<HTMLInputElement>(null);
const hideSoupsRef = useRef<HTMLInputElement>(null);
return <Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Nastavení</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
<h4>Obecné</h4>
<span title="V nabídkách nebudou zobrazovány polévky. Tato funkce je experimentální, a zejména u TechTower bývá často problém polévky spolehlivě rozeznat. V případě využití této funkce průběžně nahlašujte stále se zobrazující polévky." style={{ "cursor": "help" }}>
<input ref={hideSoupsRef} type="checkbox" defaultChecked={settings?.hideSoups} /> Skrýt polévky
</span>
<hr />
<h4>Bankovní účet</h4>
<p>Nastavením čísla účtu umožníte automatické generování QR kódů pro úhradu za vámi provedené objednávky v rámci Pizza day.<br />Pokud vaše číslo účtu neobsahuje předčíslí, je možné ho zcela vynechat.<br /><br />Číslo účtu není ukládáno na serveru, posílá se na něj pouze za účelem vygenerování QR kódů.</p>
Číslo účtu: <input className="mb-3" ref={bankAccountRef} type="text" placeholder="123456-1234567890/1234" defaultValue={settings?.bankAccount} onKeyDown={e => e.stopPropagation()} /> <br />
Název příjemce (jméno majitele účtu): <input ref={nameRef} type="text" placeholder="Jan Novák" defaultValue={settings?.holderName} onKeyDown={e => e.stopPropagation()} />
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>
Storno
</Button>
<Button variant="primary" onClick={() => onSave(bankAccountRef.current?.value, nameRef.current?.value, hideSoupsRef.current?.checked)}>
Uložit
</Button>
</Modal.Footer>
</Modal>
}

View File

@@ -1,12 +1,12 @@
import React, { ReactNode, useContext, useEffect, useState } from "react"
import { useJwt } from "react-jwt";
import { deleteToken, getToken, storeToken } from "../Utils";
import React, { ReactNode, useContext, useState } from "react"
import { useEffect } from "react"
const LOGIN_KEY = 'login';
export type AuthContextProps = {
login?: string,
trusted?: boolean,
setToken: (name: string) => void,
logout: () => void,
setLogin: (name: string) => void,
clearLogin: () => void,
}
type ContextProps = {
@@ -15,7 +15,7 @@ type ContextProps = {
const authContext = React.createContext<AuthContextProps | null>(null);
export function ProvideAuth(props: Readonly<ContextProps>) {
export function ProvideAuth(props: ContextProps) {
const auth = useProvideAuth();
return <authContext.Provider value={auth}>{props.children}</authContext.Provider>
}
@@ -26,43 +26,33 @@ export const useAuth = () => {
function useProvideAuth(): AuthContextProps {
const [loginName, setLoginName] = useState<string | undefined>();
const [trusted, setTrusted] = useState<boolean | undefined>();
const [token, setToken] = useState<string | undefined>(getToken());
const { decodedToken } = useJwt(token ?? '');
useEffect(() => {
if (token && token.length > 0) {
storeToken(token);
} else {
deleteToken();
const login = localStorage.getItem(LOGIN_KEY);
if (login) {
setLogin(login);
}
}, [token]);
}, [])
useEffect(() => {
if (decodedToken) {
setLoginName((decodedToken as any).login);
setTrusted((decodedToken as any).trusted);
if (loginName) {
localStorage.setItem(LOGIN_KEY, loginName)
} else {
setLoginName(undefined);
setTrusted(undefined);
localStorage.removeItem(LOGIN_KEY);
}
}, [decodedToken]);
}, [loginName]);
function logout() {
const trusted = (decodedToken as any).trusted;
const logoutUrl = (decodedToken as any).logoutUrl;
setToken(undefined);
function setLogin(login: string) {
setLoginName(login);
}
function clearLogin() {
setLoginName(undefined);
setTrusted(undefined);
if (trusted && logoutUrl?.length) {
window.location.replace(logoutUrl);
}
}
return {
login: loginName,
trusted,
setToken,
logout,
setLogin,
clearLogin
}
}

View File

@@ -1,37 +1,34 @@
import React, { ReactNode, useContext, useEffect, useState } from "react"
import React, { ReactNode, useContext, useState } from "react"
import { useEffect } from "react"
const BANK_ACCOUNT_NUMBER_KEY = 'bank_account_number';
const BANK_ACCOUNT_HOLDER_KEY = 'bank_account_holder_name';
const HIDE_SOUPS_KEY = 'hide_soups';
export type SettingsContextProps = {
export type BankContextProps = {
bankAccount?: string,
holderName?: string,
hideSoups?: boolean,
setBankAccountNumber: (accountNumber?: string) => void,
setBankAccountHolderName: (holderName?: string) => void,
setHideSoupsOption: (hideSoups?: boolean) => void,
}
type ContextProps = {
children: ReactNode
}
const settingsContext = React.createContext<SettingsContextProps | null>(null);
const bankContext = React.createContext<BankContextProps | null>(null);
export function ProvideSettings(props: Readonly<ContextProps>) {
const settings = useProvideSettings();
return <settingsContext.Provider value={settings}>{props.children}</settingsContext.Provider>
export function ProvideBank(props: ContextProps) {
const bank = useProvideBank();
return <bankContext.Provider value={bank}>{props.children}</bankContext.Provider>
}
export const useSettings = () => {
return useContext(settingsContext);
export const useBank = () => {
return useContext(bankContext);
}
function useProvideSettings(): SettingsContextProps {
function useProvideBank(): BankContextProps {
const [bankAccount, setBankAccount] = useState<string | undefined>();
const [holderName, setHolderName] = useState<string | undefined>();
const [hideSoups, setHideSoups] = useState<boolean | undefined>();
useEffect(() => {
const accountNumber = localStorage.getItem(BANK_ACCOUNT_NUMBER_KEY);
@@ -42,10 +39,6 @@ function useProvideSettings(): SettingsContextProps {
if (holderName) {
setHolderName(holderName);
}
const hideSoups = localStorage.getItem(HIDE_SOUPS_KEY);
if (hideSoups !== null) {
setHideSoups(hideSoups === 'true');
}
}, [])
useEffect(() => {
@@ -64,14 +57,6 @@ function useProvideSettings(): SettingsContextProps {
}
}, [holderName]);
useEffect(() => {
if (hideSoups) {
localStorage.setItem(HIDE_SOUPS_KEY, hideSoups ? 'true' : 'false');
} else {
localStorage.removeItem(HIDE_SOUPS_KEY);
}
}, [hideSoups]);
function setBankAccountNumber(bankAccount?: string) {
setBankAccount(bankAccount);
}
@@ -80,16 +65,10 @@ function useProvideSettings(): SettingsContextProps {
setHolderName(holderName);
}
function setHideSoupsOption(hideSoups?: boolean) {
setHideSoups(hideSoups);
}
return {
bankAccount,
holderName,
hideSoups,
setBankAccountNumber,
setBankAccountHolderName,
setHideSoupsOption,
}
}

View File

@@ -1,23 +0,0 @@
import { useEffect, useState } from "react";
import { AuthContextProps } from "./auth";
import { EasterEgg, getEasterEgg } from "../../../types";
export const useEasterEgg = (auth?: AuthContextProps | null): [EasterEgg | undefined, boolean] => {
const [result, setResult] = useState<EasterEgg | undefined>();
const [loading, setLoading] = useState(false);
useEffect(() => {
async function fetchEasterEgg() {
if (auth?.login) {
setLoading(true);
const egg = (await getEasterEgg())?.data;
setResult(egg);
setLoading(false);
}
}
fetchEasterEgg();
}, [auth?.login]);
return [result, loading];
}

View File

@@ -1,20 +1,17 @@
import React from 'react';
import socketio from "socket.io-client";
import { getBaseUrl } from "../Utils";
let socketUrl;
let socketPath;
if (process.env.NODE_ENV === 'development') {
socketUrl = `http://localhost:3001`;
socketPath = undefined;
} else {
socketUrl = `${window.location.host}`;
socketPath = `${window.location.pathname}socket.io`;
}
export const socket = socketio.connect(socketUrl, { path: socketPath, transports: ["websocket"] });
// Záměrně omezeno jen na websocket, aby se případně odhalilo chybné nastavení proxy serveru
export const socket = socketio.connect(getBaseUrl(), { transports: ["websocket"] });
export const SocketContext = React.createContext();
// Konstanty websocket eventů, musí odpovídat těm na serveru!
export const EVENT_CONNECT = 'connect';
export const EVENT_DISCONNECT = 'disconnect';
export const EVENT_MESSAGE = 'message';
// export const EVENT_CONFIG = 'config';
// export const EVENT_TOASTER = 'toaster';
// export const EVENT_VOTING = 'voting';
// export const EVENT_VOTE_CONFIG = 'voteSettings';
// export const EVENT_ADMIN = 'admin';

View File

@@ -1,41 +0,0 @@
import { LunchChoice, Restaurant } from "../../types";
export function getRestaurantName(restaurant: Restaurant) {
switch (restaurant) {
case Restaurant.SLADOVNICKA:
return "Sladovnická";
case Restaurant.TECHTOWER:
return "TechTower";
case Restaurant.ZASTAVKAUMICHALA:
return "Zastávka u Michala";
case Restaurant.SENKSERIKOVA:
return "Šenk Šeříková";
default:
return restaurant;
}
}
export function getLunchChoiceName(location: LunchChoice) {
switch (location) {
case Restaurant.SLADOVNICKA:
return "Sladovnická";
case Restaurant.TECHTOWER:
return "TechTower";
case Restaurant.ZASTAVKAUMICHALA:
return "Zastávka u Michala";
case Restaurant.SENKSERIKOVA:
return "Šenk Šeříková";
case LunchChoice.SPSE:
return "SPŠE";
case LunchChoice.PIZZA:
return "Pizza day";
case LunchChoice.OBJEDNAVAM:
return "Budu objednávat";
case LunchChoice.NEOBEDVAM:
return "Mám vlastní/neobědvám";
case LunchChoice.ROZHODUJI:
return "Rozhoduji se";
default:
return location;
}
}

View File

@@ -1,38 +1,25 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { SocketContext, socket } from './context/socket';
import { ProvideAuth } from './context/auth';
import { ToastContainer } from 'react-toastify';
import { ProvideBank } from './context/bank';
import 'react-toastify/dist/ReactToastify.css';
import './index.css';
import AppRoutes from './AppRoutes';
import { BrowserRouter } from 'react-router';
import { client } from '../../types/gen/client.gen';
import { getToken } from './Utils';
import { toast } from 'react-toastify';
client.setConfig({
auth: () => getToken(),
baseUrl: '/api', // openapi-ts si to z nějakého důvodu neumí převzít z api.yml
});
// Interceptor na vyhození toasteru při chybě
client.interceptors.response.use(async response => {
// TODO opravit - login je zatím výjimka, voláme ho "naprázdno" abychom zjistili, zda nás nepřihlásily trusted headers
if (!response.ok && response.url.indexOf("/login") == -1) {
const json = await response.json();
toast.error(json.error, { theme: "colored" });
}
return response;
});
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<BrowserRouter>
<ProvideAuth>
<AppRoutes />
</ProvideAuth>
</BrowserRouter>
<ProvideAuth>
<ProvideBank>
<SocketContext.Provider value={socket}>
<App />
<ToastContainer />
</SocketContext.Provider>
</ProvideBank>
</ProvideAuth>
</React.StrictMode>
);

View File

@@ -1,16 +0,0 @@
.stats-page {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
.week-navigator {
display: flex;
align-items: center;
font-size: xx-large;
.date-range {
margin: 5px 20px;
}
}
}

View File

@@ -1,129 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import Footer from "../components/Footer";
import Header from "../components/Header";
import { useAuth } from "../context/auth";
import Login from "../Login";
import { formatDate, getFirstWorkDayOfWeek, getHumanDate, getLastWorkDayOfWeek } from "../Utils";
import { WeeklyStats, LunchChoice, getStats } from "../../../types";
import Loader from "../components/Loader";
import { faChevronLeft, faChevronRight, faGear } from "@fortawesome/free-solid-svg-icons";
import { Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { getLunchChoiceName } from "../enums";
import './StatsPage.scss';
const CHART_WIDTH = 1400;
const CHART_HEIGHT = 700;
const STROKE_WIDTH = 2.5;
const COLORS = [
// Komentáře jsou kvůli vizualizaci barev ve VS Code
'#ff1493', // #ff1493
'#1e90ff', // #1e90ff
'#c5a700', // #c5a700
'#006400', // #006400
'#b300ff', // #b300ff
'#ff4500', // #ff4500
'#bc8f8f', // #bc8f8f
'#00ff00', // #00ff00
'#7c7c7c', // #7c7c7c
]
export default function StatsPage() {
const auth = useAuth();
const [dateRange, setDateRange] = useState<Date[]>();
const [data, setData] = useState<WeeklyStats>();
// Prvotní nastavení aktuálního týdne
useEffect(() => {
const today = new Date();
setDateRange([getFirstWorkDayOfWeek(today), getLastWorkDayOfWeek(today)]);
}, []);
// Přenačtení pro zvolený týden
useEffect(() => {
if (dateRange) {
getStats({ query: { startDate: formatDate(dateRange[0]), endDate: formatDate(dateRange[1]) } }).then(response => {
setData(response.data);
});
}
}, [dateRange]);
const renderLine = (location: LunchChoice) => {
const index = Object.values(LunchChoice).indexOf(location);
return <Line key={location} name={getLunchChoiceName(location)} type="monotone" dataKey={data => data.locations[location] ?? 0} stroke={COLORS[index]} strokeWidth={STROKE_WIDTH} />
}
const handlePreviousWeek = () => {
if (dateRange) {
const previousStartDate = new Date(dateRange[0]);
previousStartDate.setDate(previousStartDate.getDate() - 7);
const previousEndDate = new Date(previousStartDate);
previousEndDate.setDate(previousEndDate.getDate() + 4);
setDateRange([previousStartDate, previousEndDate]);
}
}
const handleNextWeek = () => {
if (dateRange) {
const nextStartDate = new Date(dateRange[0]);
nextStartDate.setDate(nextStartDate.getDate() + 7);
const nextEndDate = new Date(nextStartDate);
nextEndDate.setDate(nextEndDate.getDate() + 4);
setDateRange([nextStartDate, nextEndDate]);
}
}
const handleKeyDown = useCallback((e: any) => {
if (e.keyCode === 37) {
handlePreviousWeek();
} else if (e.keyCode === 39) {
handleNextWeek()
}
}, [dateRange]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
}
}, [handleKeyDown]);
if (!auth?.login) {
return <Login />;
}
if (!dateRange) {
return <Loader
icon={faGear}
description={'Načítám data...'}
animation={'fa-bounce'}
/>
}
return (
<>
<Header />
<div className="stats-page">
<h1>Statistiky</h1>
<div className="week-navigator">
<span title="Předchozí týden">
<FontAwesomeIcon icon={faChevronLeft} style={{ cursor: "pointer" }} onClick={handlePreviousWeek} />
</span>
<h2 className="date-range">{getHumanDate(dateRange[0])} - {getHumanDate(dateRange[1])}</h2>
<span title="Následující týden">
<FontAwesomeIcon icon={faChevronRight} style={{ cursor: "pointer" }} onClick={handleNextWeek} />
</span>
</div>
<LineChart width={CHART_WIDTH} height={CHART_HEIGHT} data={data}>
{Object.values(LunchChoice).map(location => renderLine(location))}
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Legend />
</LineChart>
</div>
<Footer />
</>
);
}

1
client/src/react-app-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="react-scripts" />

View File

@@ -1,13 +1,11 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"types": [
"vite/client"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
@@ -15,13 +13,14 @@
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "bundler",
"module": "ESNext",
"target": "ESNext",
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"jsx": "react-jsx"
}
},
"include": [
"client/src"
]
}

View File

@@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@@ -1,16 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import viteTsconfigPaths from 'vite-tsconfig-paths'
export default defineConfig({
// depending on your application, base can also be "/"
base: '',
plugins: [react(), viteTsconfigPaths()],
server: {
open: true,
port: 3000,
proxy: {
'/api': 'http://localhost:3001',
}
},
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,51 +0,0 @@
version: "3"
networks:
proxy:
name: traefik_proxy
services:
traefik:
image: "traefik:latest"
container_name: "traefik"
command:
# - "--log.level=DEBUG"
#- "--log.filePath=/log/traefik.log"
- "--accesslog=true"
#- "--accessLog.filePath=/log/access.log"
- "--api.insecure=false" # pokud chci dashboard
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
#- "--entryPoints.websecure.address=:443"
restart: unless-stopped
networks:
- proxy
ports:
- "${HTTP_PORT:-80}:80"
#- "443:443"
volumes:
#- "./traefik/log:/log"
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "/etc/timezone:/etc/timezone:ro"
environment:
- "TZ=Europe/Prague"
server:
build:
context: ./server
networks:
- proxy
labels:
- 'traefik.enable=true'
- 'traefik.http.routers.server.rule=Host(`${DOMAIN:-localhost}`) && (PathPrefix(`/socket.io`) || PathPrefix(`/api`))'
client:
build:
context: ./client
ports:
- 3000:3000
networks:
- proxy
labels:
- 'traefik.enable=true'
- 'traefik.http.routers.client.rule=Host(`${DOMAIN:-localhost}`)'

View File

@@ -1,27 +0,0 @@
services:
redis:
image: redis/redis-stack-server:7.2.0-RC3
restart: always
ports:
- '6379:6379'
#expose:
# - 6379
environment:
- REDIS_ARGS=--save 3600 1 --loglevel warning
volumes:
- redis:/data
luncher:
depends_on:
- redis
restart: always
build:
context: ./
ports:
- 3001:3001
environment:
- TZ=Europe/Prague
volumes:
- "/etc/timezone:/etc/timezone:ro"
volumes:
redis:
driver: local

29
docker-compose.yml Normal file
View File

@@ -0,0 +1,29 @@
version: '3.8'
services:
food_api:
build:
context: ./food_api
# ports:
# - "3002:80"
server:
depends_on:
- food_api
build:
context: ./server
# ports:
# - "3001:3001"
client:
build:
context: ./client
# ports:
# - "3000:3000"
nginx:
depends_on:
- server
- client
restart: always
build:
context: ./nginx
ports:
- 3005:80

10
food_api/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM python:3.9
WORKDIR /app
COPY ./requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir --upgrade -r /app/requirements.txt
COPY ./food_service.py /app
COPY ./food_api.py /app
CMD ["uvicorn", "food_api:app", "--host", "0.0.0.0", "--port", "3002"]

43
food_api/README.md Normal file
View File

@@ -0,0 +1,43 @@
# TODO
Následující informace jsou neaktuální. Už nemáme Flask, místo WSGI jedeme přes ASGI apod. Místo tohoto dokumentu využijte nadřazený README.md.
# POMPSZČPS
POMPSZČPS, neboli Parser Obědových Menu Plzeňských Stravovacích Zařízení v Části Plzeň-Slovany, je Python aplikace poskytující na jednom místě aktuální obědové menu pro několik stravovacích zařízení v městské části Plzeň 2-Slovany. Aktuálně podporuje následující podniky:
- [Pivnice Sladovnická](https://sladovnicka.unasplzenchutna.cz)
- [Restaurace U Motlíků](https://www.umotliku.cz)
- [Restaurace TechTower](https://www.equifarm.cz/restaurace-techtower)
Pro tyto podniky umožňuje získání aktuálního obědového menu, a to buďto barevným výpisem do konzole (přímým spuštěním `food_service.py`) nebo v podobě [WSGI](https://cs.wikipedia.org/wiki/Web_Server_Gateway_Interface) endpointu (`wsgi.py`), který vrací zmíněná menu jako strukturovaný JSON objekt pro další použití v jiných aplikacích.
## Závislosti
- [Python 3.x](https://www.python.org)
Pro použití jako konzolová aplikace
- [beautifulsoup4](https://pypi.org/project/beautifulsoup4)
Pro použití jako API endpoint
- [beautifulsoup4](https://pypi.org/project/beautifulsoup4)
- [Flask](https://pypi.org/project/Flask)
- [gunicorn](https://pypi.org/project/gunicorn)
## Použití
```bash
python -m venv venv
(Unix): source venv/bin/activate
(Windows): venv\Scripts\activate.bat
pip install -r requirements.txt
```
- Jako konzolová aplikace: `python food_service.py`
- Vypíše přehledně pod sebe menu všech aktuálně integrovaných podniků
- Jako JSON API endpoint
- TODO
## TODO
- Umožnit zadat a zobrazit menu pro jiné dny
- umožnit zadání datumem nebo názvem dne v týdnu
- validace - žádná sobota, neděle
- validace - datum musí být tento týden
- minimálně pro Motlíky to znamená úpravu URL a parseru
- Otestovat rozchození - vytvoření venv, instalace requirements, spuštění jako konzole
- Umožnit konfiguračně určit pro které podniky se bude menu získávat a zobrazovat (vyberu si jen ty, které mě zajímají)
- Umožnit konfiguračně nastavit výrazy pro detekci polévky

20
food_api/food_api.py Normal file
View File

@@ -0,0 +1,20 @@
from food_service import getMenuSladovnicka, getMenuTechTower, getMenuUMotliku
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/")
def read_root(mock: bool = False):
return {
'sladovnicka': getMenuSladovnicka(mock),
'uMotliku': getMenuUMotliku(mock),
'techTower': getMenuTechTower(mock)
}

278
food_api/food_service.py Executable file
View File

@@ -0,0 +1,278 @@
#!/usr/bin/env python3
import datetime
from typing import List
from bs4 import BeautifulSoup
import tempfile
import sys
import os
import urllib.request
from datetime import date, timedelta
URL_SLADOVNICKA = "https://sladovnicka.unasplzenchutna.cz/cz/denni-nabidka"
URL_MOTLICI = "https://www.umotliku.cz"
URL_TECHTOWER = "https://www.equifarm.cz/restaurace-techtower"
DAY_NAMES = ['pondělí', 'úterý', 'středa',
'čtvrtek', 'pátek', 'sobota', 'neděle']
# Fráze v názvech jídel, které naznačují že se jedná o polévku
SOUP_NAMES = ['polévka', 'česnečka', 'česnekový krém', 'cibulačka', 'vývar']
class bcolors:
HEADER = '\033[95m'
OKBLUE = '\033[94m'
OKCYAN = '\033[96m'
OKGREEN = '\033[92m'
WARNING = '\033[93m'
FAIL = '\033[91m'
ENDC = '\033[0m'
BOLD = '\033[1m'
UNDERLINE = '\033[4m'
class Food:
name = None
amount = None
price = None
is_soup = False
def __init__(self, name, amount, price, is_soup=False) -> None:
self.name = name
self.amount = amount
self.price = price
self.is_soup = is_soup
def getOrDownloadHtml(prefix: str, url: str):
'''Vrátí HTML pro daný prefix pro aktuální den.
Pokud v tempu neexistuje, provede jeho stažení z předané URL a uložení.'''
filename = prefix + "_" + date.today().strftime("%Y_%m_%d") + ".html"
filepath = os.path.join(tempfile.gettempdir(), filename)
if not os.path.isfile(filepath):
urllib.request.urlretrieve(url, filepath)
file = open(filepath, "r")
contents = file.read()
file.close()
return contents
def isNameOfDay(text: str):
'''Vrátí True, pokud předaný text představuje název dne v týdnu (např. "pondělí")'''
return text.strip().lower() in DAY_NAMES
def getDayNameOfDate(date: datetime.datetime):
'''Vrátí název dne v týdnu - např. pondělí, úterý, ...'''
return DAY_NAMES[date.weekday()]
def getStartOfWeekDate():
'''Vrátí datetime představující pondělí v aktuálním týdnu.'''
today = datetime.datetime.now()
return today - timedelta(days=today.weekday())
def isTextSoupName(text: str):
'''Vrátí True, pokud se předaný text jeví jako název polévky.
Používá se tam, kde nemáme lepší způsob detekce (TechTower).'''
for name in SOUP_NAMES:
if name in text.lower():
return True
return False
def printMenu(name: str, foodList: List[Food]):
'''Vytiskne jídelní lístek na obrazovku.'''
print(f"{bcolors.OKGREEN}{name}{bcolors.ENDC}\n---------------------------------------------------------------------------------")
maxLength = 0
for jidlo in foodList:
if len(jidlo.name) > maxLength:
maxLength = len(jidlo.name)
for jidlo in foodList:
barva = bcolors.HEADER if jidlo.is_soup else bcolors.WARNING
print(f"{barva}{jidlo.amount}\t{jidlo.name.ljust(maxLength)}\t{bcolors.ENDC}{bcolors.OKCYAN}{jidlo.price}{bcolors.ENDC}")
print('\n')
def getMenuSladovnicka(mock: bool = False) -> List[Food]:
if mock:
foodList: List[Food] = []
foodList.append(Food("Zelná polévka s klobásou", "0,25l", "35 Kč", True))
foodList.append(Food("Hovězí na česneku s bramborovým knedlíkem", "150g", "135 Kč"))
foodList.append(Food("Přírodní holandský řízek s bramborovou kaší, rajčatový salát", "250g", "135 Kč"))
foodList.append(Food("Bagel s vinnou klobásou, cibulový konfit, kysané zelí, slanina a hořčicová mayo, hranolky, curry omáčka", "350g", "135 Kč"))
return foodList
if getDayNameOfDate(date.today()).lower() == 'sobota' or getDayNameOfDate(date.today()).lower() == 'neděle':
return []
html = getOrDownloadHtml('sladovnicka', URL_SLADOVNICKA)
soup = BeautifulSoup(html, "html.parser")
div = soup.select_one("div.tab-pane.fade.in.active")
datumDen = div.find("h2").text
split = datumDen.split(".")
denMesic = split[0] + "." + split[1] + "."
# nazevDen = split[2]
dnesniDatum = date.today().strftime("%-d.%-m.")
if denMesic != dnesniDatum:
print('Chyba: neočekávané datum na stránce Sladovnické (' +
denMesic + '), očekáváno ' + dnesniDatum, file=sys.stderr)
sys.exit(1)
tables = div.find_all("table", {"class": "simple"})
if len(tables) != 2:
print('Chyba: neočekávaný počet tabulek na stránce Sladovnické (' +
str(len(tables)) + '), očekávány 2', file=sys.stderr)
sys.exit(1)
foodList: List[Food] = []
polevkaValues = tables[0].find_all("td")
amount = polevkaValues[0].text.strip()
name = polevkaValues[1].text.strip()
price = polevkaValues[2].text.strip()
foodList.append(Food(name, amount, price, True))
foodTables = tables[1].find_all("tr")
for food in foodTables:
rows = food.find_all("td")
if (len(rows) != 3):
print("Neočekávaný počet řádek hlavního jídla Sladovnické (" +
str(len(rows)) + ", očekávány 3, přeskakuji...")
continue
amount = rows[0].text.strip()
name = rows[1].text.strip()
price = rows[2].text.strip()
foodList.append(Food(name, amount, price))
return foodList
def getMenuUMotliku(mock: bool = False) -> List[Food]:
if mock:
foodList: List[Food] = []
foodList.append(Food("Hovězí vývar s nudlemi", "0,33l", "35 Kč", True))
foodList.append(Food("Opečený párek, čočka, sázené vejce, okurka", "150g", "135 Kč"))
foodList.append(Food("Hovězí líčka na červeném víně, bramborová kaše", "150g", "145 Kč"))
foodList.append(Food("Tortilla s trhaným kuřecím masem, uzeným sýrem, dipem a kukuřicí, míchaný salát", "150g", "135 Kč"))
return foodList
if getDayNameOfDate(date.today()).lower() == 'sobota' or getDayNameOfDate(date.today()).lower() == 'neděle':
return []
html = getOrDownloadHtml('u_motliku', URL_MOTLICI)
soup = BeautifulSoup(html, "html.parser")
table = soup.find("table", {"class": "Xtable-striped"})
rows = table.find_all("tr")
if len(rows) < 4:
print('Chyba: neočekávaný celkový počet řádek tabulky (' +
str(len(rows)) + '), očekáváno 4 a více', file=sys.stderr)
sys.exit(1)
foodList: List[Food] = []
if rows[0].td.text.strip() == 'Polévka':
tds = rows[1].find_all("td")
if len(tds) != 3:
print('Chyba: neočekávaný počet <td> elementů v řádce polévky (' +
str(len(tds)) + '), očekáváno 3', file=sys.stderr)
sys.exit(1)
amount = tds[0].text.strip()
name = tds[1].text.strip()
price = tds[2].text.strip().replace(',-', '')
foodList.append(Food(name, amount, price, True))
rows = rows[2:]
if rows[0].td.text.strip() == 'Hlavní jídlo':
for i in range(1, len(rows)):
tds = rows[i].find_all("td")
if len(tds) != 3:
print("Neočekávaný počet <td> elementů (" + str(len(tds)
) + ") pro hlavní jídlo " + str(i) + ", přeskakuji")
continue
amount = tds[0].text.strip()
name = tds[1].text.strip()
price = tds[2].text.strip().replace(',-', '')
foodList.append(Food(name, amount, price))
return foodList
def getMenuTechTower(mock: bool = False) -> List[Food]:
if mock:
foodList: List[Food] = []
foodList.append(Food("Bavorská gulášová polévka s kroupami", "-", "40 Kč", True))
foodList.append(Food("Vepřové výpečky, kedlubnové zelí, bramborový knedlík", "-", "120 Kč"))
foodList.append(Food("Hambuger Black Angus s čedarem a slaninou, cibulové kroužky", "-", "220 Kč"))
return foodList
if getDayNameOfDate(date.today()).lower() == 'sobota' or getDayNameOfDate(date.today()).lower() == 'neděle':
return []
html = getOrDownloadHtml('techtower', URL_TECHTOWER)
soup = BeautifulSoup(html, "html.parser")
fonts = soup.find_all("font", {"class": ["wsw-41"]})
font = None
for f in fonts:
if (f.text.strip().startswith("Obědy")):
font = f
if font is None:
print('Chyba: nenalezen <font> pro obědy v HTML Techtower.', file=sys.stderr)
sys.exit(1)
siblings = font.parent.parent.find_next_siblings("p")
# dayNumber = date.today().strftime("%w")
currentDayName = getDayNameOfDate(datetime.datetime.now())
foodList: List[Food] = []
doParse = False
for i in range(0, len(siblings)):
text = siblings[i].text.strip().replace('\t', '').replace('\n', ' ')
if isNameOfDay(text):
if text == currentDayName:
# Našli jsme dnešní den, odtud začínáme parsovat jídla
doParse = True
elif doParse == True:
# Už parsujeme jídla, ale narazili jsme na následující den - končíme
break
elif doParse:
if len(text.strip()) == 0:
# Prázdná řádka - končíme (je za pátečním menu TechTower)
break
price = '? Kč'
if text.endswith(''):
split = text.rsplit(' ', 2)
price = " ".join(split[1:])
text = split[0]
foodList.append(Food(text, '-', price, isTextSoupName(text)))
return foodList
if __name__ == "__main__":
if len(sys.argv) > 1:
input = sys.argv[1].lower()
selectedDate = None
if input[0].isalpha():
matches = []
for day in DAY_NAMES:
if day.startswith(input):
matches.append(day)
if len(matches) == 1:
print("Match - den v týdnu - " + matches[0])
selectedDate = getStartOfWeekDate(
) + timedelta(DAY_NAMES.index(matches[0]))
elif len(matches) == 0:
# TODO zkusit v, z (včera, zítra)
if 'zítra'.startswith(input):
print("Match - zítra")
selectedDate = datetime.datetime.now() + timedelta(days=1)
elif 'včera'.startswith(input):
print("Match - včera")
selectedDate = datetime.datetime.now() + timedelta(days=-1)
elif 'dneska'.startswith(input):
print("Match - dnes")
selectedDate = datetime.datetime.now()
else:
print('Nejasný parametr "' + input +
'" - může znamenat jednu z možností: ' + ', '.join(matches), file=sys.stderr)
sys.exit(1)
else:
# TODO implementovat zadání datem
print('Zadání datem není aktuálně implementováno', file=sys.stderr)
sys.exit(1)
print("Datum: " + selectedDate.strftime('%d.%m.%Y'))
print("Den: " + getDayNameOfDate(selectedDate))
# printMenu('Sladovnická', getMenuSladovnicka())
# printMenu('U Motlíků', getMenuUMotliku())
# printMenu('TechTower', getMenuTechTower())

View File

@@ -0,0 +1,3 @@
beautifulsoup4==4.12.2
fastapi==0.95.2
uvicorn==0.22.0

8
food_api/run_dev.sh Executable file
View File

@@ -0,0 +1,8 @@
#!/bin/bash
dir="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
cd $dir
python3 -m venv venv
source venv/bin/activate
pip3 install -r requirements.txt
uvicorn food_api:app --port 3002 --reload

2
nginx/Dockerfile Normal file
View File

@@ -0,0 +1,2 @@
FROM nginx
COPY ./default.conf /etc/nginx/conf.d/default.conf

47
nginx/default.conf Normal file
View File

@@ -0,0 +1,47 @@
upstream client {
server client:3000;
}
upstream server {
server server:3001;
}
upstream food_api {
server food_api:3002;
}
server {
listen 80;
location / {
proxy_pass http://client;
}
location /static {
proxy_pass http://client;
}
location /sockjs-node {
proxy_pass http://client;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}
location /socket.io {
proxy_pass http://server;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}
location /api/food {
rewrite /api/food(.*) /$1 break;
proxy_pass http://food_api;
}
location /api {
# rewrite /api/(.*) /$1 break;
proxy_pass http://server;
}
}

View File

@@ -1,18 +1,5 @@
#!/bin/bash
# Spustí server a klienta v samostatných panelech uvnitř stejného tmux okna.
# Pokud už daná tmux session existuje, pouze se k ní připojí.
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
SESSION="luncher"
if ! tmux has-session -t $SESSION 2>/dev/null; then
cd types && yarn openapi-ts && cd ..
tmux new-session -d -s $SESSION
tmux send-keys -t $SESSION:0 "cd $SCRIPT_DIR" Enter
tmux split-window -v
tmux send-keys -t $SESSION:0.0 "cd server && export NODE_ENV=development && yarn startReload" Enter
tmux send-keys -t $SESSION:0.1 "cd client && yarn start" Enter
fi
tmux attach-session -t $SESSION
export NODE_ENV=development
./food_api/run_dev.sh &
cd server && yarn install && yarn start &
cd client && yarn install && yarn start &
wait

View File

@@ -1,40 +1,8 @@
# Secret pro podepisování JWT tokenů. Minimální délka 32 znaků.
# JWT_SECRET='CHANGE_ME'
# URL na kterém je dostupný Food API parser.
# Pro vývoj není potřeba, bude použita výchozí hodnota http://localhost:3002
# FOOD_API_URL=http://nginx/api/food
# URL pro externí odhlášení, kam bude uživatel při odhlášení přesměrován pokud byl přihlášen pomocí Trusted Headers.
# LOGOUT_URL='https://auth.example.com/logout'
# Datové úložiště. Musí být 'json' nebo 'redis' (není case sensitive).
# json - Data jsou ukládána do JSON souboru. Pomalé (práce se souborem), ale vhodné pro vývoj (snadnější prohlížení dat).
# redis - Data jsou ukládána v Redis serveru. Dle potřeby může být nutné upravit REDIS_ proměnné viz dále.
# STORAGE='json'
# Hostname/IP Redis serveru, pokud je použit STORAGE='redis'. Výchozí hodnota je 'localhost'.
# REDIS_HOST='localhost'
# Port Redis serveru, pokud je použit STORAGE='redis', výchozí hodnota je 6379.
# REDIS_PORT=6379
# Zapne režim mockování obědových menu.
# Zapne režim mockování jídelních lístků.
# Vhodné pro vývoj o víkendech, svátcích a dalších dnech, pro které podniky nenabízejí obědové menu.
# V tomto režimu vrací server vždy falešné datum (pracovní den) a pevně nadefinovanou, smyšlenou nabídku jídel.
# V tomto režimu vrací server vždy falešné datum (pracovní den) a Food API pevně nadefinovanou, smyšlenou nabídku jídel.
# MOCK_DATA=true
# Určuje servery Gotify a příslušné klíče API.
# Formát je pole objektů, kde každý objekt obsahuje adresu serveru a pole klíčů API.
# To je užitečné pro odesílání upozornění na různé servery Gotify s různými klíči API.
# Struktura dat je ve formátu JSON a je uložena jako řetězec.
# GOTIFY_SERVERS_AND_KEYS='[{"server":"https://notification.server.eu", "api_keys":["key1", "key2"]},{"server":"https://notification.server2.eu", "api_keys":["key3", "key4"]}]'
#NTFY_HOST = "http://192.168.0.113:80"
#NTFY_USERNAME="username"
#NTFY_PASSWD="password"
# Zapne přihlašování pomocí důvěryhodných hlaviček (trusted headers). Výchozí hodnota je false.
# V případě zapnutí je nutno vyplnit také HTTP_REMOTE_TRUSTED_IPS.
# HTTP_REMOTE_USER_ENABLED=true
# Seznam IP adres nebo rozsahů oddělených čárkou, ze kterých budou akceptovány důvěryhodné hlavičky.
# HTTP_REMOTE_TRUSTED_IPS=127.0.0.1,192.168.1.0/24
# Název důvěryhodné hlavičky obsahující login uživatele. Výchozí hodnota je 'remote-user'.
# HTTP_REMOTE_USER_HEADER_NAME=remote-user

6
server/.gitignore vendored
View File

@@ -1,7 +1,5 @@
/data
/node_modules
/dist
/resources/easterEggs
/src/gen
data.json
.env.production
.env.development
.easter-eggs.json

16
server/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM node:18-alpine3.18
ENV LANG cs_CZ.UTF-8
WORKDIR /app
COPY package.json .
COPY yarn.lock .
COPY .env.production .
COPY tsconfig.json .
COPY src ./src
RUN yarn install --frozen-lockfile
RUN yarn build
CMD [ "node", "/app/dist/index.js" ]

View File

@@ -1,6 +0,0 @@
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
'@babel/preset-typescript',
],
};

3
server/build.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/bin/bash
yarn install --frozen-lockfile && yarn build
docker build -t luncher-server .

View File

@@ -1,38 +1,26 @@
{
"name": "@luncher/server",
"name": "luncher-server",
"version": "1.0.0",
"main": "src/index.ts",
"license": "MIT",
"private": true,
"scripts": {
"start": "ts-node src/index.ts",
"startReload": "nodemon --watch src src/index.ts",
"build": "tsc -p .",
"test": "jest"
"build": "tsc -p ."
},
"devDependencies": {
"@babel/core": "^7.28.5",
"@babel/preset-env": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@types/express": "^5.0.5",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.10.0",
"@types/express": "^4.17.17",
"@types/node": "^20.2.5",
"@types/request-promise": "^4.1.48",
"babel-jest": "^30.2.0",
"jest": "^30.2.0",
"nodemon": "^3.1.10",
"ts-node": "^10.9.1",
"typescript": "^5.9.3"
"typescript": "^5.0.2"
},
"dependencies": {
"axios": "^1.13.2",
"cheerio": "^1.1.2",
"axios": "^1.4.0",
"cheerio": "^1.0.0-rc.12",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"jsonwebtoken": "^9.0.0",
"redis": "^5.9.0",
"dotenv": "^16.1.3",
"express": "^4.18.2",
"simple-json-db": "^2.0.0",
"socket.io": "^4.6.1"
}

View File

@@ -1,71 +0,0 @@
import jwt from 'jsonwebtoken';
/**
* Vygeneruje a vrátí podepsaný JWT token pro daný login.
*
* @param login přihlašovací jméno uživatele
* @param trusted příznak, zda se jedná o ověřeného uživatele
* @returns JWT token
*/
export function generateToken(login?: string, trusted?: boolean): string {
if (!process.env.JWT_SECRET) {
throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
}
if (process.env.JWT_SECRET.length < 32) {
throw Error("Proměnná prostředí JWT_SECRET musí být minimálně 32 znaků");
}
if (!login || login.trim().length === 0) {
throw Error("Nebyl předán login");
}
const payload = { login, trusted: trusted || false, logoutUrl: process.env.LOGOUT_URL };
return jwt.sign(payload, process.env.JWT_SECRET);
}
/**
* Vrátí true, pokud je předaný JWT token platný.
*
* @param token JWT token
*/
export function verify(token: string): boolean {
if (!process.env.JWT_SECRET) {
throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
}
try {
jwt.verify(token, process.env.JWT_SECRET);
return true;
} catch (err) {
return false;
}
}
/**
* Vrátí login z daného JWT tokenu, pokud je token platný.
*
* @param token JWT token
*/
export function getLogin(token?: string): string {
if (!process.env.JWT_SECRET) {
throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
}
if (!token) {
throw Error("Nebyl předán token");
}
const payload: any = jwt.verify(token, process.env.JWT_SECRET);
return payload.login;
}
/**
* Vrátí zda je uživatel používající daný token ověřený, pokud je token platný.
*
* @param token JWT token
*/
export function getTrusted(token?: string): boolean {
if (!process.env.JWT_SECRET) {
throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
}
if (!token) {
throw Error("Nebyl předán token");
}
const payload: any = jwt.verify(token, process.env.JWT_SECRET);
return payload.trusted || false;
}

View File

@@ -1,8 +1,9 @@
import $ from 'cheerio';
import os from 'os';
import path from 'path';
import fs from 'fs';
import axios from 'axios';
import { load } from 'cheerio';
import { getPizzaListMock } from './mock';
// TODO přesunout do types
type PizzaSize = {
varId: number,
size: string,
@@ -11,7 +12,6 @@ type PizzaSize = {
price: number
}
// TODO přesunout do types
type Pizza = {
name: string,
ingredients: string[],
@@ -36,29 +36,22 @@ const boxPrices: { [key: string]: number } = {
/**
* Stáhne a scrapne aktuální pizzy ze stránek Pizza Chefie.
*
* @param mock zda vrátit pouze mock data
*/
export async function downloadPizzy(mock: boolean): Promise<Pizza[]> {
if (mock) {
// Záměrné zpoždění pro testování
return new Promise((resolve) => setTimeout(() => resolve(getPizzaListMock()), 3000));
}
const downloadPizzy = async () => {
// Získáme seznam pizz
const html = await axios.get(pizzyUrl).then(res => res.data);
const $ = load(html);
const links = $('.vypisproduktu > div > h4 > a');
const links = $('.vypisproduktu > div > h4 > a', html)
const urls = [];
for (const element of links) {
if (element.name === 'a' && element.attribs?.href) {
const pizzaUrl = element.attribs?.href;
for (let i = 0; i < links.length; i++) {
if (links[i].name === 'a' && links[i].attribs?.href) {
const pizzaUrl = links[i].attribs?.href;
urls.push(buildPizzaUrl(pizzaUrl));
}
}
// Scrapneme jednotlivé pizzy
const result: Pizza[] = [];
for (const element of urls) {
const pizzaUrl = element;
for (let i = 0; i < urls.length; i++) {
const pizzaUrl = urls[i];
const pizzaHtml = await axios.get(pizzaUrl).then(res => res.data);
// Název
const name = $('.produkt > h2', pizzaHtml).first().text()
@@ -85,3 +78,28 @@ export async function downloadPizzy(mock: boolean): Promise<Pizza[]> {
}
return result;
}
/**
* Vrátí pizzy z tempu, nebo čerstvě stažené, pokud v tempu nejsou.
*/
export const fetchPizzy = async (): Promise<Pizza[]> => {
const tmpDir = os.tmpdir();
const date_ob = new Date();
const date = ("0" + date_ob.getDate()).slice(-2);
const month = ("0" + (date_ob.getMonth() + 1)).slice(-2);
const year = date_ob.getFullYear();
const dateStr = year + "-" + month + "-" + date;
const dataPath = path.join(tmpDir, `chefie-${dateStr}.json`);
if (fs.existsSync(dataPath)) {
console.log(`Soubor pro ${dataPath} již existuje, bude použit.`);
const rawdata = fs.readFileSync(dataPath);
return JSON.parse(rawdata.toString());
} else {
console.log(`Soubor pro ${dataPath} neexistuje, stahuji...`);
const pizzy = await downloadPizzy();
fs.writeFileSync(dataPath, JSON.stringify(pizzy));
console.log(`Zapsán ${dataPath}`);
return pizzy;
}
}

3
server/src/database.ts Normal file
View File

@@ -0,0 +1,3 @@
import JSONdb from 'simple-json-db';
export const db = new JSONdb('./data.json');

View File

@@ -1,171 +1,170 @@
import express from "express";
import { Server } from "socket.io";
import bodyParser from "body-parser";
import { fetchPizzy } from "./chefie";
import cors from 'cors';
import { getData, getDateForWeekIndex } from "./service";
import { addPizzaOrder, createPizzaDay, deletePizzaDay, finishPizzaDelivery, finishPizzaOrder, getData, lockPizzaDay, removePizzaOrder, unlockPizzaDay, updateChoice } from "./service";
import dotenv from 'dotenv';
import path from 'path';
import { getQr } from "./qr";
import { generateToken, verify } from "./auth";
import { InsufficientPermissions } from "./utils";
import { initWebsocket } from "./websocket";
import pizzaDayRoutes from "./routes/pizzaDayRoutes";
import foodRoutes, { refreshMetoda } from "./routes/foodRoutes";
import votingRoutes from "./routes/votingRoutes";
import easterEggRoutes from "./routes/easterEggRoutes";
import statsRoutes from "./routes/statsRoutes";
import { fetchMenus } from "./restaurants";
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
const ENVIRONMENT = process.env.NODE_ENV || 'production'
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
// Validace nastavení JWT tokenu - nemá bez něj smysl vůbec povolit server spustit
if (!process.env.JWT_SECRET) {
throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
}
const app = express();
const server = require("http").createServer(app);
initWebsocket(server);
const io = new Server(server, {
cors: {
origin: "*",
},
});
// Body-parser middleware for parsing JSON
app.use(bodyParser.json());
// app.use(express.json());
app.use(cors({
origin: '*'
}));
// Zapínatelný login přes hlavičky - pokud je zapnutý nepovolí "basicauth"
const HTTP_REMOTE_USER_ENABLED = process.env.HTTP_REMOTE_USER_ENABLED === 'true' || false;
const HTTP_REMOTE_USER_HEADER_NAME = process.env.HTTP_REMOTE_USER_HEADER_NAME ?? 'remote-user';
if (HTTP_REMOTE_USER_ENABLED) {
if (!process.env.HTTP_REMOTE_TRUSTED_IPS) {
throw new Error('Je zapnutý login z hlaviček, ale není nastaven rozsah adres ze kterých hlavička může přijít.');
}
const HTTP_REMOTE_TRUSTED_IPS = process.env.HTTP_REMOTE_TRUSTED_IPS.split(',').map(ip => ip.trim());
//TODO: nevim jak udelat console.log pouze pro "debug"
//console.log("Budu věřit hlavičkám z: " + HTTP_REMOTE_TRUSTED_IPS);
app.set('trust proxy', HTTP_REMOTE_TRUSTED_IPS);
console.log('Zapnutý login přes hlavičky z proxy.');
}
// ----------- Metody nevyžadující token --------------
app.get("/api/whoami", (req, res) => {
if (!HTTP_REMOTE_USER_ENABLED) {
res.status(403).json({ error: 'Není zapnuté přihlášení z hlaviček' });
}
if(process.env.ENABLE_HEADERS_LOGGING === 'yes'){
delete req.headers["cookie"]
console.log(req.headers)
}
res.send(req.header(HTTP_REMOTE_USER_HEADER_NAME));
})
app.post("/api/login", (req, res) => {
if (HTTP_REMOTE_USER_ENABLED) { // je rovno app.enabled('trust proxy')
// Autentizace pomocí trusted headers
const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
//const remoteName = req.header('remote-name');
if (remoteUser && remoteUser.length > 0 ) {
res.status(200).json(generateToken(Buffer.from(remoteUser, 'latin1').toString(), true));
} else {
throw Error("Je zapnuto přihlášení přes hlavičky, ale nepřišla hlavička nebo ??");
}
} else {
// Klasická autentizace loginem
if (!req.body?.login || req.body.login.trim().length === 0) {
throw Error("Nebyl předán login");
}
// TODO zavést podmínky pro délku loginu (min i max)
res.status(200).json(generateToken(req.body.login, false));
}
/** Vrátí data pro aktuální den. */
app.get("/api/data", (req, res) => {
res.status(200).json(getData());
});
// TODO dočasné řešení - QR se zobrazuje přes <img>, nemáme sem jak dostat token
app.get("/api/qr", (req, res) => {
if (!req.query?.login) {
/** Vrátí obědové menu pro dostupné podniky. */
app.get("/api/food", (req, res) => {
fetchMenus().then(food => {
res.status(200).json(food);
})
});
/** Vrátí seznam dostupných pizz. */
app.get("/api/pizza", (req, res) => {
fetchPizzy().then(pizzaList => {
// console.log("Výsledek", pizzaList);
res.status(200).json(pizzaList);
});
});
/** Založí pizza day pro aktuální den, za předpokladu že dosud neexistuje. */
app.post("/api/createPizzaDay", (req, res) => {
if (!req.body?.creator) {
throw Error("Nebyl předán název zakládajícího");
}
const data = createPizzaDay(req.body.creator);
res.status(200).json(data);
io.emit("message", data);
});
/** Smaže pizza day pro aktuální den, za předpokladu že existuje. */
app.post("/api/deletePizzaDay", (req, res) => {
if (!req.body?.login) {
throw Error("Nebyl předán login uživatele");
}
const data = deletePizzaDay(req.body.login);
io.emit("message", data);
});
app.post("/api/addPizza", (req, res) => {
if (!req.body?.login) {
throw Error("Nebyl předán login");
}
const img = getQr(req.query.login as string);
res.writeHead(200, {
'Content-Type': 'image/png',
'Content-Length': img.length
if (isNaN(req.body?.pizzaIndex)) {
throw Error("Nebyl předán index pizzy");
}
const pizzaIndex = req.body.pizzaIndex;
if (isNaN(req.body?.pizzaSizeIndex)) {
throw Error("Nebyl předán index velikosti pizzy");
}
const pizzaSizeIndex = req.body.pizzaSizeIndex;
fetchPizzy().then(pizzy => {
if (!pizzy[pizzaIndex]) {
throw Error("Neplatný index pizzy: " + pizzaIndex);
}
if (!pizzy[pizzaIndex].sizes[pizzaSizeIndex]) {
throw Error("Neplatný index velikosti pizzy: " + pizzaSizeIndex);
}
const data = addPizzaOrder(req.body.login, pizzy[pizzaIndex], pizzy[pizzaIndex].sizes[pizzaSizeIndex]);
io.emit("message", data);
res.status(200).json({});
})
});
app.post("/api/removePizza", (req, res) => {
if (!req.body?.login) {
throw Error("Nebyl předán login");
}
if (!req.body?.pizzaOrder) {
throw Error("Nebyla předána objednávka");
}
const data = removePizzaOrder(req.body.login, req.body?.pizzaOrder);
io.emit("message", data);
res.status(200).json({});
});
app.post("/api/lockPizzaDay", (req, res) => {
if (!req.body?.login) {
throw Error("Nebyl předán login");
}
const data = lockPizzaDay(req.body.login);
io.emit("message", data);
res.status(200).json({});
});
app.post("/api/unlockPizzaDay", (req, res) => {
if (!req.body?.login) {
throw Error("Nebyl předán login");
}
const data = unlockPizzaDay(req.body.login);
io.emit("message", data);
res.status(200).json({});
});
app.post("/api/finishOrder", (req, res) => {
if (!req.body?.login) {
throw Error("Nebyl předán login");
}
const data = finishPizzaOrder(req.body.login);
io.emit("message", data);
res.status(200).json({});
});
app.post("/api/finishDelivery", (req, res) => {
if (!req.body?.login) {
throw Error("Nebyl předán login");
}
const data = finishPizzaDelivery(req.body.login);
io.emit("message", data);
res.status(200).json({});
});
app.post("/api/updateChoice", (req, res) => {
if (!req.body.hasOwnProperty('name')) {
res.status(400).json({});
}
const data = updateChoice(req.body.name, req.body.choice);
io.emit("message", data);
res.status(200).json(data);
});
io.on("connection", (socket) => {
console.log(`New client connected: ${socket.id}`);
socket.on("message", (message) => {
io.emit("message", message);
});
socket.on("disconnect", () => {
console.log(`Client disconnected: ${socket.id}`);
});
res.end(img);
});
// ----------------------------------------------------
// Přeskočení auth pro refresh dat xd
app.use("/api/food/refresh", refreshMetoda);
/** Middleware ověřující JWT token */
app.use("/api/", (req, res, next) => {
if (HTTP_REMOTE_USER_ENABLED) {
// Autentizace pomocí trusted headers
const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
if(process.env.ENABLE_HEADERS_LOGGING === 'yes'){
delete req.headers["cookie"]
console.log(req.headers)
}
if (remoteUser && remoteUser.length > 0) {
const remoteName = Buffer.from(remoteUser, 'latin1').toString();
if (ENVIRONMENT !== "production") {
console.log("Tvuj username: %s.", remoteName);
}
}
}
if (!req.headers.authorization) {
return res.status(401).json({ error: 'Nebyl předán autentizační token' });
}
const token = req.headers.authorization.split(' ')[1];
if (!verify(token)) {
return res.status(403).json({ error: 'Neplatný autentizační token' });
}
next();
});
/** Vrátí data pro aktuální den. */
app.get("/api/data", async (req, res) => {
let date = undefined;
if (req.query.dayIndex != null && typeof req.query.dayIndex === 'string') {
const index = parseInt(req.query.dayIndex);
if (!isNaN(index)) {
date = getDateForWeekIndex(parseInt(req.query.dayIndex));
}
}
res.status(200).json(await getData(date));
});
// Ostatní routes
app.use("/api/pizzaDay", pizzaDayRoutes);
app.use("/api/food", foodRoutes);
app.use("/api/voting", votingRoutes);
app.use("/api/easterEggs", easterEggRoutes);
app.use("/api/stats", statsRoutes);
app.use('/stats', express.static('public'));
app.use(express.static('public'));
// Middleware pro zpracování chyb
app.use((err: any, req: any, res: any, next: any) => {
if (err instanceof InsufficientPermissions) {
res.status(403).send({ error: err.message })
} else {
res.status(500).send({ error: err.message })
}
next();
});
const PORT = process.env.PORT ?? 3001;
const HOST = process.env.HOST ?? '0.0.0.0';
const PORT = process.env.PORT || 3001;
const HOST = process.env.HOST || '0.0.0.0';
server.listen(PORT, () => {
console.log(`Server listening on ${HOST}, port ${PORT}`);
});
// Umožníme vypnutí serveru přes SIGINT, jinak Docker čeká než ho sestřelí
process.on('SIGINT', function () {
console.log("\nSIGINT (Ctrl-C), vypínám server");
process.exit(0);
});
console.log(process.env.API_KEY)

File diff suppressed because it is too large Load Diff

View File

@@ -1,113 +1,57 @@
import axios from 'axios';
import dotenv from 'dotenv';
import path from 'path';
import { getClientData, getToday } from "./service";
import { getUsersByLocation, getHumanTime } from "./utils";
import { NotifikaceData, NotifikaceInput } from '../../types';
/** Notifikace pro gotify*/
import axios, {AxiosError, AxiosResponse} from 'axios';
import {NotififaceInput, NotifikaceData, UdalostEnum} from "./types";
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
const url = 'your_URL_here'; // Place your URL here
export const ntfyCall = async (data: NotifikaceInput) => {
const url = process.env.NTFY_HOST
const username = process.env.NTFY_USERNAME;
const password = process.env.NTFY_PASSWD;
if (!url) {
console.log("NTFY_HOST není definován v env")
return
}
if (!username) {
console.log("NTFY_USERNAME není definován v env")
return
}
if (!password) {
console.log("NTFY_PASSWD není definován v env")
return
}
let clientData = await getClientData(getToday());
const userByCLocation = getUsersByLocation(clientData.choices, data.user)
export const gotifyCall = async (data: NotififaceInput): Promise<void> => {
const token = Buffer.from(`${username}:${password}`, 'utf8').toString('base64');
const promises = userByCLocation.map(async user => {
try {
// Odstraníme mezery a diakritiku a převedeme na lowercase
const topic = user.normalize('NFD').replace(' ', '').replace(/[\u0300-\u036f]/g, '').toLowerCase();
const response = await axios({
url: `${url}/${topic}`,
method: 'POST',
data: `${data.udalost} - spustil:${data.user}`,
headers: {
'Authorization': `Basic ${token}`,
'Tag': 'meat_on_bone'
}
});
console.log(response.data);
} catch (error) {
console.error(error);
}
})
return promises;
}
export const teamsCall = async (data: NotifikaceInput) => {
const url = process.env.TEAMS_WEBHOOK_URL;
const title = data.udalost;
let time = new Date();
time.setTime(time.getTime() + 1000 * 60);
const message = 'Odcházíme v ' + getHumanTime(time) + ', ' + data.user;
const card = {
'@type': 'MessageCard',
'@context': 'http://schema.org/extensions',
'themeColor': "0072C6", // light blue
summary: 'Summary description',
sections: [
{
activityTitle: title,
text: message,
},
],
const options = {
headers: {"Content-Type": "application/json"},
data: {
title: "Luncher",
message: `${data.udalost} - spustil:${data.user}`,
priority: 7,
},
};
if (!url) {
console.log("TEAMS_WEBHOOK_URL není definován v env")
return
}
try {
const response = await axios.post(url, card, {
headers: {
'content-type': 'application/vnd.microsoft.teams.card.o365connector'
},
});
return `${response.status} - ${response.statusText}`;
} catch (err) {
return err;
const response = await axios.post(url, options);
response.data = {
success: true,
message: "Notifikace doručena",
};
console.log(response)
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError;
if (axiosError.response) {
axiosError.response.data = {
success: false,
message: "fail",
};
console.log(axiosError.response);
}
}
// Handle unknown error without a response
console.log("unkown error")
}
}
};
/** Zavolá notifikace na všechny konfigurované způsoby notifikace, přetížení proměných na false pro jednotlivé způsoby je vypne*/
export const callNotifikace = async ({ input, teams = true, gotify = false, ntfy = true }: NotifikaceData) => {
export const callNotifikace = async ({input, teams = true, gotify = true}: NotifikaceData) => {
const notifications = [];
if (ntfy) {
const ntfyPromises = await ntfyCall(input);
if (ntfyPromises) {
notifications.push(...ntfyPromises);
}
if (gotify) {
notifications.push(gotifyCall(input));
}
/* Zatím není
if (teams) {
const teamsPromises = await teamsCall(input);
if (teamsPromises) {
notifications.push(teamsPromises);
}
}
// gotify bych řekl, že už je deprecated
// if (gotify) {
// const gotifyPromises = await gotifyCall(input, gotifyData);
// notifications.push(...gotifyPromises);
// }
notifications.push(teamsCall(input));
}*/
// Add more notifications as necessary
try {
const results = await Promise.all(notifications);

View File

@@ -1,310 +0,0 @@
import { formatDate } from "./utils";
import { callNotifikace } from "./notifikace";
import { generateQr } from "./qr";
import getStorage from "./storage";
import { downloadPizzy } from "./chefie";
import { getClientData, getToday, initIfNeeded } from "./service";
import { Pizza, ClientData, PizzaDayState, PizzaSize, PizzaOrder, PizzaVariant, UdalostEnum } from "../../types/gen/types.gen";
const storage = getStorage();
/**
* Vrátí seznam dostupných pizz pro dnešní den.
* Stáhne je, pokud je pro dnešní den nemá.
*/
export async function getPizzaList(): Promise<Pizza[] | undefined> {
await initIfNeeded();
let clientData = await getClientData(getToday());
if (!clientData.pizzaList) {
const mock = process.env.MOCK_DATA === 'true';
clientData = await savePizzaList(await downloadPizzy(mock));
}
return Promise.resolve(clientData.pizzaList);
}
/**
* Uloží seznam dostupných pizz pro dnešní den.
*
* @param pizzaList seznam dostupných pizz
*/
export async function savePizzaList(pizzaList: Pizza[]): Promise<ClientData> {
await initIfNeeded();
const today = formatDate(getToday());
const clientData = await getClientData(getToday());
clientData.pizzaList = pizzaList;
clientData.pizzaListLastUpdate = formatDate(new Date());
await storage.setData(today, clientData);
return clientData;
}
/**
* Vytvoří pizza day pro aktuální den a vrátí data pro klienta.
*/
export async function createPizzaDay(creator: string): Promise<ClientData> {
await initIfNeeded();
const clientData = await getClientData(getToday());
if (clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den již existuje");
}
// TODO berka rychlooprava, vyřešit lépe - stahovat jednou, na jediném místě!
const pizzaList = await getPizzaList();
const data: ClientData = { pizzaDay: { state: PizzaDayState.CREATED, creator, orders: [] }, pizzaList, ...clientData };
const today = formatDate(getToday());
await storage.setData(today, data);
callNotifikace({ input: { udalost: UdalostEnum.ZAHAJENA_PIZZA, user: creator } })
return data;
}
/**
* Smaže pizza day pro aktuální den.
*/
export async function deletePizzaDay(login: string): Promise<ClientData> {
const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
if (clientData.pizzaDay.creator !== login) {
throw Error("Login uživatele se neshoduje se zakladatelem Pizza Day");
}
delete clientData.pizzaDay;
const today = formatDate(getToday());
await storage.setData(today, clientData);
return clientData;
}
/**
* Přidá objednávku pizzy uživateli.
*
* @param login login uživatele
* @param pizza zvolená pizza
* @param size zvolená velikost pizzy
*/
export async function addPizzaOrder(login: string, pizza: Pizza, size: PizzaSize) {
const today = formatDate(getToday());
const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
}
let order: PizzaOrder | undefined = clientData.pizzaDay?.orders?.find(o => o.customer === login);
if (!order) {
order = {
customer: login,
pizzaList: [],
totalPrice: 0,
hasQr: false,
}
clientData.pizzaDay.orders ??= [];
clientData.pizzaDay.orders.push(order);
}
const pizzaOrder: PizzaVariant = {
varId: size.varId,
name: pizza.name,
size: size.size,
price: size.price,
}
order.pizzaList ??= [];
order.pizzaList.push(pizzaOrder);
order.totalPrice += pizzaOrder.price;
await storage.setData(today, clientData);
return clientData;
}
/**
* Odstraní danou objednávku pizzy.
*
* @param login login uživatele
* @param pizzaOrder objednávka pizzy
*/
export async function removePizzaOrder(login: string, pizzaOrder: PizzaVariant) {
const today = formatDate(getToday());
const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
const orderIndex = clientData.pizzaDay.orders!.findIndex(o => o.customer === login);
if (orderIndex < 0) {
throw Error("Nebyly nalezeny žádné objednávky pro uživatele " + login);
}
const order = clientData.pizzaDay.orders![orderIndex];
const index = order.pizzaList!.findIndex(o => o.name === pizzaOrder.name && o.size === pizzaOrder.size);
if (index < 0) {
throw Error("Objednávka s danými parametry nebyla nalezena");
}
const price = order.pizzaList![index].price;
order.pizzaList!.splice(index, 1);
order.totalPrice -= price;
if (order.pizzaList!.length == 0) {
clientData.pizzaDay.orders!.splice(orderIndex, 1);
}
await storage.setData(today, clientData);
return clientData;
}
/**
* Uzamkne možnost editovat objednávky pizzy.
*
* @param login login uživatele
* @returns aktuální data pro uživatele
*/
export async function lockPizzaDay(login: string) {
const today = formatDate(getToday());
const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
if (clientData.pizzaDay.creator !== login) {
throw Error("Pizza day není spravován uživatelem " + login);
}
if (clientData.pizzaDay.state !== PizzaDayState.CREATED && clientData.pizzaDay.state !== PizzaDayState.ORDERED) {
throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED + " nebo " + PizzaDayState.ORDERED);
}
clientData.pizzaDay.state = PizzaDayState.LOCKED;
await storage.setData(today, clientData);
return clientData;
}
/**
* Odekmne možnost editovat objednávky pizzy.
*
* @param login login uživatele
* @returns aktuální data pro uživatele
*/
export async function unlockPizzaDay(login: string) {
const today = formatDate(getToday());
const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
if (clientData.pizzaDay.creator !== login) {
throw Error("Pizza day není spravován uživatelem " + login);
}
if (clientData.pizzaDay.state !== PizzaDayState.LOCKED) {
throw Error("Pizza day není ve stavu " + PizzaDayState.LOCKED);
}
clientData.pizzaDay.state = PizzaDayState.CREATED;
await storage.setData(today, clientData);
return clientData;
}
/**
* Nastaví stav pizza day na "pizzy objednány".
*
* @param login login uživatele
* @returns aktuální data pro uživatele
*/
export async function finishPizzaOrder(login: string) {
const today = formatDate(getToday());
const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
if (clientData.pizzaDay.creator !== login) {
throw Error("Pizza day není spravován uživatelem " + login);
}
if (clientData.pizzaDay.state !== PizzaDayState.LOCKED) {
throw Error("Pizza day není ve stavu " + PizzaDayState.LOCKED);
}
clientData.pizzaDay.state = PizzaDayState.ORDERED;
await storage.setData(today, clientData);
callNotifikace({ input: { udalost: UdalostEnum.OBJEDNANA_PIZZA, user: clientData?.pizzaDay?.creator } })
return clientData;
}
/**
* Nastaví stav pizza day na "pizzy doručeny".
* Vygeneruje QR kódy pro všechny objednatele, pokud objednávající má vyplněné číslo účtu a jméno.
*
* @param login login uživatele
* @returns aktuální data pro uživatele
*/
export async function finishPizzaDelivery(login: string, bankAccount?: string, bankAccountHolder?: string) {
const today = formatDate(getToday());
const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
if (clientData.pizzaDay.creator !== login) {
throw Error("Pizza day není spravován uživatelem " + login);
}
if (clientData.pizzaDay.state !== PizzaDayState.ORDERED) {
throw Error("Pizza day není ve stavu " + PizzaDayState.ORDERED);
}
clientData.pizzaDay.state = PizzaDayState.DELIVERED;
// Vygenerujeme QR kód, pokud k tomu máme data
if (bankAccount?.length && bankAccountHolder?.length) {
for (const order of clientData.pizzaDay.orders!) {
if (order.customer !== login) { // zatím platí creator = objednávající, a pro toho nemá QR kód smysl
let message = order.pizzaList!.map(pizza => `Pizza ${pizza.name} (${pizza.size})`).join(', ');
await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice, message);
order.hasQr = true;
}
}
}
await storage.setData(today, clientData);
return clientData;
}
/**
* Aktualizuje poznámku k Pizza day uživatele.
*
* @param login přihlašovací jméno uživatele
* @param note nová poznámka k Pizza day
* @returns aktuální klientská data
*/
export async function updatePizzaDayNote(login: string, note?: string) {
const today = formatDate(getToday());
let clientData = await getClientData(getToday());
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
}
const myOrder = clientData.pizzaDay.orders!.find(o => o.customer === login);
if (!myOrder?.pizzaList?.length) {
throw Error("Pizza day neobsahuje žádné objednávky uživatele " + login);
}
myOrder.note = note;
await storage.setData(today, clientData);
return clientData;
}
/**
* Aktualizuje příplatek uživatele k objednávce pizzy.
* V případě nevyplnění ceny je příplatek odebrán.
*
* @param login přihlašovací jméno aktuálního uživatele
* @param targetLogin přihlašovací jméno uživatele, kterému je nastavován příplatek
* @param text text popisující příplatek
* @param price celková cena příplatku
*/
export async function updatePizzaFee(login: string, targetLogin: string, text?: string, price?: number) {
const today = formatDate(getToday());
let clientData = await getClientData(getToday());
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
throw Error(`Pizza day není ve stavu ${PizzaDayState.CREATED}`);
}
if (clientData.pizzaDay.creator !== login) {
throw Error("Příplatky může měnit pouze zakladatel Pizza day");
}
const targetOrder = clientData.pizzaDay.orders!.find(o => o.customer === targetLogin);
if (!targetOrder?.pizzaList?.length) {
throw Error(`Pizza day neobsahuje žádné objednávky uživatele ${targetLogin}`);
}
if (!price) {
delete targetOrder.fee;
} else {
targetOrder.fee = { text, price };
}
// Přepočet ceny
targetOrder.totalPrice = targetOrder.pizzaList.reduce((price, pizzaOrder) => price + pizzaOrder.price, 0) + (targetOrder.fee?.price ?? 0);
await storage.setData(today, clientData);
return clientData;
}

View File

@@ -1,98 +0,0 @@
import fs from "fs";
import axios from "axios";
import os from "os";
import path from "path";
import crypto from "crypto";
import { formatDate } from "./utils";
const QR_GENERATOR_URL = 'https://api.paylibo.com/paylibo/generator/image';
const COUNTRY_CODE = 'CZ';
const CURRENCY_CODE = 'CZK';
const QR_PIXEL_SIZE = 256;
const tmpDir = os.tmpdir();
/**
* Převede číslo účtu z BBAN do IBAN. Automaticky dopočítá kontrolní číslice.
*
* @param bankAccountNumber číslo účtu ve formátu BBAN (123456-0123456789/0100)
*/
function convertBbanToIban(bankAccountNumber: string): string {
// TODO validovat číslo účtu stejně jako na klientovi, pro případ že sem někdo pošle nesmysl
let prefix: string = '';
let accountNumber: string = bankAccountNumber;
if (bankAccountNumber.indexOf('-') >= 0) {
const split = bankAccountNumber.split('-');
prefix = split[0];
accountNumber = split[1];
}
prefix = prefix.padStart(6, '0');
const split = accountNumber.split('/');
accountNumber = split[0].padStart(10, '0');
const bankCode = split[1].padStart(4, '0');
let iban = `${bankCode}${prefix}${accountNumber}${COUNTRY_CODE}00`;
// Zatím napevno, nemá smysl řešit nic jiného než CZ
iban = iban.replace('C', '12').replace('Z', '35');
const remainder = BigInt(iban) % BigInt(97);
const checkDigits = BigInt(98) - remainder;
iban = `${COUNTRY_CODE}${checkDigits.toString()}${bankCode}${prefix}${accountNumber}`;
if (iban.length !== 24) {
throw Error("Neplatná délka sestaveného IBAN: " + iban.length + ", očekáváno 24");
}
return iban;
}
function createNameHash(customerName: string): string {
return crypto.createHash('md5').update(customerName).digest('hex');
}
function createFilePath(nameHash: string): string {
const fileName = `${formatDate(new Date())}_${nameHash}.png`;
return path.join(tmpDir, fileName);
}
/**
* Vygeneruje, uloží a vrátí unikátní ID obrázku platebního QR kódu s danými parametry.
*
* @param customerName jméno uživatele, pro kterého je QR kód generován
* @param bankAccountNumber číslo cílového bankovního účtu ve formátu BBAN
* @param bankAccountHolder jméno držitele cílového bankovního účtu
* @param amount částka v Kč
* @param message zpráva pro příjemce
* @returns hash, pomocí kterého lze následně získat vygenerovaný obrázek
*/
export async function generateQr(customerName: string, bankAccountNumber: string, bankAccountHolder: string, amount: number, message: string): Promise<string> {
// Zpráva pro příjemce nesmí dle standardu obsahovat '*' a být delší než 60 znaků
if (message.indexOf('*') >= 0) {
message = message.replace('*', '');
}
if (message.length > 60) {
message = message.substring(0, 60);
}
const payload = {
iban: convertBbanToIban(bankAccountNumber),
amount,
currency: CURRENCY_CODE,
message,
recipientName: bankAccountHolder,
branding: false,
compress: false,
size: QR_PIXEL_SIZE,
}
const response = await axios.get(QR_GENERATOR_URL, { responseType: 'stream', params: { ...payload } });
// Použijeme hash, abychom nemuseli řešit nepovolené znaky ve jménu uživatele
const nameHash = createNameHash(customerName);
const imgPath = createFilePath(nameHash);
response.data.pipe(fs.createWriteStream(imgPath));
return nameHash;
}
/**
* Vrátí obrázek s QR kódem, pokud existuje.
*
* @param customerName jméno uživatele
* @returns data obrázku
*/
export function getQr(customerName: string): Buffer {
const imgPath = createFilePath(createNameHash(customerName));
return fs.readFileSync(imgPath);
}

View File

@@ -1,478 +1,13 @@
import axios from "axios";
import { load } from 'cheerio';
import { getMenuSladovnickaMock, getMenuTechTowerMock, getMenuUMotlikuMock, getMenuZastavkaUmichalaMock, getMenuSenkSerikovaMock } from "./mock";
import { formatDate } from "./utils";
import { Food } from "../../types/gen/types.gen";
// Fráze v názvech jídel, které naznačují že se jedná o polévku
const SOUP_NAMES = [
'polévka',
'česnečka',
'česnekový krém',
'cibulačka',
'vývar',
'fazolová',
'cuketový krém',
'boršč',
'slepičí s ',
'zeleninová s ',
'hovězí s ',
'kachní kaldoun',
'dršťková'
];
const DAYS_IN_WEEK = ['pondělí', 'úterý', 'středa', 'čtvrtek', 'pátek', 'sobota', 'neděle'];
// URL na Food API - získání jídelních lístků restaurací
const foodUrl = process.env.FOOD_API_URL || 'http://localhost:3002';
// URL na týdenní menu jednotlivých restaurací
const SLADOVNICKA_URL = 'https://sladovnicka.unasplzenchutna.cz/cz/#denni-nabidka';
const U_MOTLIKU_URL = 'https://www.umotliku.cz/menu';
const TECHTOWER_URL = 'https://www.equifarm.cz/restaurace-techtower';
const ZASTAVKAUMICHALA_URL = 'https://www.zastavkaumichala.cz';
const SENKSERIKOVA_URL = 'https://www.menicka.cz/6561-pivovarsky-senk-serikova.html';
/**
* Vrátí true, pokud předaný text obsahuje některé ze slov, které naznačuje, že se jedná o polévku.
* Využito tam, kde nelze polévku identifikovat lepším způsobem (TechTower).
*
* @param text vstupní text
* @returns true, pokud text představuje polévku
*/
const isTextSoupName = (text: string): boolean => {
for (const name of SOUP_NAMES) {
if (text.toLowerCase().includes(name)) {
return true;
}
export const fetchMenus = async () => {
try {
return await axios.get(foodUrl, { params: { mock: !!process.env.MOCK_DATA } }).then(res => res.data);
} catch (error) {
console.error("Chyba při volání Food API", error);
return {};
}
return false;
}
const capitalize = (word: string): string => {
return word.charAt(0).toUpperCase() + word.slice(1);
}
const sanitizeText = (text: string): string => {
return text.replace('\t', '').replace(' , ', ', ').trim();
}
/**
* Parsuje čísla alergenů z názvu jídla a vrací vyčištěný název spolu s polem alergenů.
* Alergeny jsou očekávány na konci názvu ve formátu číslic oddělených čárkami.
*
* @param name původní název jídla
* @returns objekt obsahující vyčištěný název a pole alergenů
*/
const parseAllergens = (name: string): { cleanName: string, allergens: number[] } => {
// Regex pro nalezení čísel na konci řetězce oddělených čárkami a případnými mezerami
const regex = /\s+(\d+(?:\s*,\s*\d+)*)\s*$/;
const match = regex.exec(name);
if (match) {
const allergenString = match[1];
const allergens = allergenString.split(',').map(num => Number.parseInt(num.trim(), 10)).filter(num => !Number.isNaN(num));
const cleanName = name.replace(regex, '').trim();
return { cleanName, allergens };
}
return { cleanName: name, allergens: [] };
}
/**
* Stáhne a vrátí aktuální HTML z dané URL.
*
* @param url URL pro stažení
* @returns stažené HTML
*/
const getHtml = async (url: string): Promise<any> => {
return await axios.get(url).then(res => res.data).then(content => content);
}
/**
* Získá obědovou nabídku Sladovnické pro jeden týden.
*
* @param firstDayOfWeek první den v týdnu, pro který získat menu
* @param mock zda vrátit mock data
* @returns seznam jídel pro daný týden
*/
export const getMenuSladovnicka = async (firstDayOfWeek: Date, mock: boolean = false): Promise<Food[][]> => {
if (mock) {
return getMenuSladovnickaMock();
}
const html = await getHtml(SLADOVNICKA_URL);
const $ = load(html);
// Nejdříve zjistíme, které dny jsou k dispozici z tab elementů
const tabElements = $('#daily-menu-tab-list').children('button[id^="daily-menu-tab-"]');
const availableDays: { [dayIndex: number]: number } = {}; // mapování dayIndex -> contentIndex
tabElements.each((contentIndex, tabElement) => {
const dayText = $(tabElement).find('.daily-menu-tab__day').text().toLowerCase();
const dayIndex = DAYS_IN_WEEK.indexOf(dayText);
if (dayIndex !== -1 && dayIndex < 5) { // pouze pracovní dny (0-4)
availableDays[dayIndex] = contentIndex;
}
});
const menuContentElements = $('#daily-menu-content-list').children('[id^="daily-menu-content-"]');
const result: Food[][] = [];
// Inicializujeme všechny pracovní dny (0-4) prázdnými poli
for (let dayIndex = 0; dayIndex < 5; dayIndex++) {
result[dayIndex] = [];
}
// Projdeme pouze dostupné dny
for (const [dayIndex, contentIndex] of Object.entries(availableDays)) {
const dayIndexNum = Number.parseInt(dayIndex);
const contentIndexNum = contentIndex;
if (contentIndexNum >= menuContentElements.length) {
continue; // Přeskočíme, pokud content element neexistuje
}
const dayChildren = $(menuContentElements[contentIndexNum]).children();
// Ověříme, že má element očekávanou strukturu
if (dayChildren.length < 2) {
console.warn(`Neočekávaný počet children v menu Sladovnické pro den ${dayIndexNum}: ${dayChildren.length}, očekávány alespoň 2 (polévka a hlavní jídlo)`);
continue;
}
// Parsování polévky
const soupElement = dayChildren.get(0);
const soupTable = $(soupElement).find('table tbody tr');
const soupCells = soupTable.children('td');
if (soupCells.length !== 3) {
console.warn(`Neočekávaný počet buněk v tabulce polévky pro den ${dayIndexNum}: ${soupCells.length}, ale očekávány byly 3`);
continue;
}
const soupAmount = sanitizeText($(soupCells.get(0)).text());
const soupNameRaw = sanitizeText($(soupCells.get(1)).text());
const soupPrice = sanitizeText($(soupCells.get(2)).text().replace(' ', '\xA0'));
const soupParsed = parseAllergens(soupNameRaw);
// Parsování hlavních jídel
const mainCourseElement = dayChildren.get(1);
const mainCourseTable = $(mainCourseElement).find('table tbody');
const mainCourseRows = mainCourseTable.children('tr');
const currentDayFood: Food[] = [];
// Přidáme polévku do seznamu jídel
currentDayFood.push({
amount: soupAmount,
name: soupParsed.cleanName,
price: soupPrice,
isSoup: true,
allergens: soupParsed.allergens.length > 0 ? soupParsed.allergens : undefined,
});
// Projdeme všechny řádky hlavních jídel
mainCourseRows.each((i, row) => {
const cells = $(row).children('td');
const amount = sanitizeText($(cells.get(0)).text());
const nameRaw = sanitizeText($(cells.get(1)).text());
const price = sanitizeText($(cells.get(2)).text().replace(' ', '\xA0'));
const parsed = parseAllergens(nameRaw);
// Přeskočíme prázdné řádky (první řádek může být prázdný)
if (parsed.cleanName.trim().length > 0) {
currentDayFood.push({
amount,
name: parsed.cleanName,
price,
isSoup: false,
allergens: parsed.allergens.length > 0 ? parsed.allergens : undefined,
});
}
});
result[dayIndexNum] = currentDayFood;
}
return result;
}
/**
* Získá obědovou nabídku restaurace U Motlíků pro jeden týden.
*
* @param firstDayOfWeek první den v týdnu, pro který získat menu
* @param mock zda vrátit mock data
* @returns seznam jídel pro dané datum
*/
export const getMenuUMotliku = async (firstDayOfWeek: Date, mock: boolean = false): Promise<Food[][]> => {
if (mock) {
return getMenuUMotlikuMock();
}
const html = await getHtml(U_MOTLIKU_URL);
const $ = load(html);
// Najdeme první tabulku, nad kterou je v H3 datum začínající co nejdřívějším dnem v aktuálním týdnu
const tables = $('table.table.table-hover.Xtable-striped');
let usedTable;
let usedDate = new Date(firstDayOfWeek);
for (let i = 0; i < 4; i++) {
const dayOfWeekString = `${usedDate.getDate()}.${usedDate.getMonth() + 1}.`;
for (const tableNode of tables) {
const table = $(tableNode);
const h3 = table.parent().prev();
const s1 = h3.text().split("-")[0].split(".");
const foundFirstDayString = `${s1[0]}.${s1[1]}.`;
if (foundFirstDayString === dayOfWeekString) {
usedTable = table;
}
}
if (usedTable != null) {
break;
}
usedDate.setDate(usedDate.getDate() + 1);
}
if (usedTable == null) {
const firstDayOfWeekString = `${firstDayOfWeek.getDate()}.${firstDayOfWeek.getMonth() + 1}.`;
throw new Error(`Nepodařilo se najít tabulku pro týden začínající ${firstDayOfWeekString}`);
}
const body = usedTable.children().first();
const rows = body.children();
const result: Food[][] = [];
for (let dayIndex = 0; dayIndex < 5; dayIndex++) {
if (!(dayIndex in result)) {
result[dayIndex] = [];
}
let parsing = false;
let isSoup = false;
rows.each((i, row) => {
const firstChild = $(row).children().get(0);
if (firstChild?.name == 'th') {
const childText = $(firstChild).text();
if (capitalize(DAYS_IN_WEEK[dayIndex]) === childText) {
parsing = true;
} else if (parsing) {
// Narazili jsme na další den - konec parsování
parsing = false;
return;
}
} else if (parsing) {
const children = $(row).children();
if (children.length === 1) { // Nadpis "Polévka" nebo "Hlavní jídlo"
const foodType = children.first().text();
if (foodType === 'Polévka') {
isSoup = true;
} else if (foodType === 'Hlavní jídlo') {
isSoup = false;
} else {
throw new Error("Neočekáváný typ jídla: " + foodType);
}
} else {
if (children.length !== 3) {
throw new Error("Neočekávaný počet child elementů pro jídlo: " + children.length + ", očekávány 3");
}
const amount = sanitizeText($(children.get(0)).text());
const name = sanitizeText($(children.get(1)).text());
const price = sanitizeText($(children.get(2)).text()).replace(',-', '').replace(' ', '\xA0');
result[dayIndex].push({
amount,
name,
price,
isSoup,
})
}
}
})
}
return result;
}
/**
* Získá obědovou nabídku TechTower pro jeden týden.
*
* @param firstDayOfWeek první den v týdnu, pro který získat menu
* @param mock zda vrátit mock data
* @returns seznam jídel pro dané datum
*/
export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = false): Promise<Food[][]> => {
if (mock) {
return getMenuTechTowerMock();
}
const html = await getHtml(TECHTOWER_URL);
const $ = load(html);
let secondTry = false;
// První pokus - varianta "Obědy"
let fonts = $('font.wsw-41');
let font = undefined;
fonts.each((i, f) => {
if ($(f).text().trim().startsWith('Obědy')) {
font = f;
}
})
// Druhý pokus - varianta "Jídelní lístek"
if (!font) {
fonts = $('font.wnd-font-size-90');
fonts.each((i, f) => {
if ($(f).text().trim().startsWith('Jídelní lístek')) {
font = f;
secondTry = true;
}
})
}
if (!font) {
throw new Error('Chyba: nenalezen <font> pro obědy v HTML Techtower.');
}
const result: Food[][] = [];
// TODO validovat, že v textu nalezeného <font> je rozsah, do kterého spadá vstupní datum
const siblings = secondTry ? $(font).parent().parent().parent().siblings('p') : $(font).parent().parent().siblings();
let parsing = false;
let currentDayIndex = 0;
for (let i = 0; i < siblings.length; i++) {
const text = $(siblings.get(i)).text().trim().replace('\t', '').replace('\n', ' ');
if (DAYS_IN_WEEK.includes(text.toLocaleLowerCase())) {
// Zjistíme aktuální index
currentDayIndex = DAYS_IN_WEEK.indexOf(text.toLocaleLowerCase());
if (!parsing) {
// Našli jsme libovolný den v týdnu a ještě neparsujeme, tak začneme
parsing = true;
}
} else if (parsing) {
if (text.length == 0) {
// Prázdná řádka - bývá na zcela náhodných místech ¯\_(ツ)_/¯
continue;
}
let price = 'na\xA0váhu';
let nameRaw = text.replace('•', '');
if (text.toLowerCase().endsWith('kč')) {
const tmp = text.replace('\xA0', ' ').split(' ');
const split = [tmp.slice(0, -2).join(' ')].concat(tmp.slice(-2));
price = `${split.slice(1)[0]}\xA0Kč`
nameRaw = split[0].replace('•', '');
}
if (nameRaw.endsWith('')) {
nameRaw = nameRaw.slice(0, -1).trim();
}
const parsed = parseAllergens(nameRaw);
result[currentDayIndex] ??= [];
result[currentDayIndex].push({
amount: '-',
name: parsed.cleanName,
price,
isSoup: isTextSoupName(parsed.cleanName),
allergens: parsed.allergens.length > 0 ? parsed.allergens : undefined,
})
}
}
return result;
}
/**
* Získá obědovou nabídku ZastavkaUmichala pro jeden týden.
*
* @param firstDayOfWeek první den v týdnu, pro který získat menu
* @param mock zda vrátit mock data
* @returns seznam jídel pro dané datum
*/
export const getMenuZastavkaUmichala = async (firstDayOfWeek: Date, mock: boolean = false): Promise<Food[][]> => {
if (mock) {
return getMenuZastavkaUmichalaMock();
}
const today = new Date();
today.setHours(0,0,0,0);
const headers = {
"Cookie": "_nss=1; PHPSESSID=9e37de17e0326b0942613d6e67a30e69",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36",
};
const result: Food[][] = [];
for (let dayIndex = 0; dayIndex < 5; dayIndex++) {
const currentDate = new Date(firstDayOfWeek);
currentDate.setDate(firstDayOfWeek.getDate() + dayIndex);
currentDate.setHours(0,0,0,0);
if (currentDate < today || (currentDate.getTime() === today.getTime() && new Date().getHours() >= 14)) {
result[dayIndex] = [{
amount: undefined,
name: "Pro tento den není uveřejněna nabídka jídel",
price: "",
isSoup: false,
}];
} else {
const url = (currentDate.getTime() === today.getTime())
? ZASTAVKAUMICHALA_URL
: ZASTAVKAUMICHALA_URL + '/?do=dailyMenu-changeDate&dailyMenu-dateString=' + formatDate(currentDate, 'DD.MM.YYYY');
const html = await axios.get(url, {
headers,
}).then(res => res.data).then(content => content);
const $ = load(html);
const currentDayFood: Food[] = [];
$('.foodsList li').each((index, element) => {
currentDayFood.push({
amount: '-',
name: sanitizeText($(element).contents().not('span').text()),
price: sanitizeText($(element).find('span').text()),
isSoup: (index === 0),
});
});
result[dayIndex] = currentDayFood;
}
}
return result;
}
/**
* Získá obědovou nabídku SenkSerikova pro jeden týden.
*
* @param firstDayOfWeek první den v týdnu, pro který získat menu
* @param mock zda vrátit mock data
* @returns seznam jídel pro dané datum
*/
export const getMenuSenkSerikova = async (firstDayOfWeek: Date, mock: boolean = false): Promise<Food[][]> => {
if (mock) {
return getMenuSenkSerikovaMock();
}
const decoder = new TextDecoder('windows-1250');
const html = await axios.get(SENKSERIKOVA_URL, {
responseType: 'arraybuffer',
responseEncoding: 'binary'
}).then(res => decoder.decode(new Uint8Array(res.data))).then(content => content);
const $ = load(html);
const today = new Date();
today.setHours(0,0,0,0);
const currentDate = new Date(firstDayOfWeek);
const result: Food[][] = [];
let dayIndex = 0;
currentDate.setHours(0,0,0,0);
while (currentDate < today) {
result[dayIndex] = [{
amount: undefined,
name: "Pro tento den není uveřejněna nabídka jídel",
price: "",
isSoup: false,
}];
dayIndex = dayIndex + 1;
currentDate.setDate(firstDayOfWeek.getDate() + dayIndex);
}
$('.menicka').each((i, element) => {
const currentDayFood: Food[] = [];
$(element).find('.popup-gallery li').each((j, element) => {
const rawName = $(element).children('div.polozka').text();
const nameWithoutNumber = rawName.replace(/^\d+\.\s*/, '');
currentDayFood.push({
amount: '-',
name: nameWithoutNumber,
price: $(element).children('div.cena').text().replaceAll(' ', '\xA0'),
isSoup: $(element).hasClass('polevka'),
});
});
result[dayIndex++] = currentDayFood;
});
return result;
}

View File

@@ -1,146 +0,0 @@
import express, { NextFunction } from "express";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import path from "path";
import fs from "fs";
import { EasterEgg } from "../../../types/gen/types.gen";
const EASTER_EGGS_JSON_PATH = path.join(__dirname, "../../resources/.easter-eggs.json");
const IMAGES_PATH = '../../resources/easterEggs';
type EasterEggsJson = {
[key: string]: EasterEgg[]
}
function generateUrl() {
let result = '';
const characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
const charactersLength = characters.length;
let counter = 0;
while (counter < 32) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
counter += 1;
}
return result;
}
/**
* Vrátí náhodně jeden z definovaných easter egg obrázků pro přihlášeného uživatele.
*
* @param req request
* @param res response
* @param next next
* @returns náhodný easter egg obrázek, nebo 404 pokud žádný není definován
*/
function getEasterEggImage(req: any, res: any, next: NextFunction) {
const login = getLogin(parseToken(req));
try {
if (login in easterEggs) {
const imagePath = easterEggs[login][Math.floor(Math.random() * easterEggs[login].length)].path;
res.sendFile(path.join(__dirname, IMAGES_PATH, imagePath));
return;
}
res.sendStatus(404);
} catch (e: any) { next(e) }
}
function getRandomPosition(startOffset: number, endOffset: number) {
const choice = Math.floor(Math.random() * 4);
if (choice === 0) {
// Vlevo nahoře
return {
left: `${startOffset}px`,
startLeft: `${startOffset}px`,
"--start-left": `${startOffset}px`,
top: `${startOffset}px`,
startTop: `${startOffset}px`,
"--start-top": `${startOffset}px`,
endLeft: `${endOffset}px`,
"--end-left": `${endOffset}px`,
endTop: `${endOffset}px`,
"--end-top": `${endOffset}px`,
rotate: '135deg',
}
} else if (choice === 1) {
// Vpravo nahoře
return {
right: `${startOffset}px`,
startRight: `${startOffset}px`,
"--start-right": `${startOffset}px`,
top: `${startOffset}px`,
startTop: `${startOffset}px`,
"--start-top": `${startOffset}px`,
endRight: `${endOffset}px`,
"--end-right": `${endOffset}px`,
endTop: `${endOffset}px`,
"--end-top": `${endOffset}px`,
rotate: '-135deg',
}
} else if (choice === 2) {
// Vpravo dole
return {
right: `${startOffset}px`,
startRight: `${startOffset}px`,
"--start-right": `${startOffset}px`,
bottom: `${startOffset}px`,
startBottom: `${startOffset}px`,
"--start-bottom": `${startOffset}px`,
endRight: `${endOffset}px`,
"--end-right": `${endOffset}px`,
endBottom: `${endOffset}px`,
"--end-bottom": `${endOffset}px`,
rotate: '-45deg',
}
} else if (choice === 3) {
// Vlevo dole
return {
left: `${startOffset}px`,
startLeft: `${startOffset}px`,
"--start-left": `${startOffset}px`,
bottom: `${startOffset}px`,
startBottom: `${startOffset}px`,
"--start-bottom": `${startOffset}px`,
endLeft: `${endOffset}px`,
"--end-left": `${endOffset}px`,
endBottom: `${endOffset}px`,
"--end-bottom": `${endOffset}px`,
rotate: '45deg',
}
}
}
const router = express.Router();
let easterEggs: EasterEggsJson;
// Registrace náhodných URL pro všechny existující easter eggy
if (fs.existsSync(EASTER_EGGS_JSON_PATH)) {
const content = fs.readFileSync(EASTER_EGGS_JSON_PATH, 'utf-8');
easterEggs = JSON.parse(content);
for (const [_, eggs] of Object.entries(easterEggs)) {
for (const easterEgg of eggs) {
const url = generateUrl();
easterEgg.url = url;
router.get(`/${url}`, async (req, res, next) => {
return getEasterEggImage(req, res, next);
});
}
}
}
// Získání náhodného easter eggu pro přihlášeného uživatele
router.get("/", async (req, res, next) => {
const login = getLogin(parseToken(req));
try {
if (easterEggs && login in easterEggs) {
const randomEasterEgg = easterEggs[login][Math.floor(Math.random() * easterEggs[login].length)];
const { path, startOffset, endOffset, ...strippedEasterEgg } = randomEasterEgg; // Path klient k ničemu nepotřebuje a nemá ho znát
return res.status(200).json({ ...strippedEasterEgg, ...getRandomPosition(startOffset, endOffset) });
}
return res.status(200).send();
} catch (e: any) { next(e) }
});
export default router;

View File

@@ -1,266 +0,0 @@
import express, { Request, Response } from "express";
import { getLogin, getTrusted } from "../auth";
import { addChoice, getDateForWeekIndex, getToday, removeChoice, removeChoices, updateDepartureTime, updateNote, fetchRestaurantWeekMenuData, saveRestaurantWeekMenu } from "../service";
import { getDayOfWeekIndex, parseToken, getFirstWorkDayOfWeek } from "../utils";
import { getWebsocket } from "../websocket";
import { callNotifikace } from "../notifikace";
import { AddChoiceData, ChangeDepartureTimeData, RemoveChoiceData, RemoveChoicesData, UdalostEnum, UpdateNoteData } from "../../../types/gen/types.gen";
// RateLimit na refresh endpoint
interface RateLimitEntry {
count: number;
resetTime: number;
}
const rateLimits: Record<string, RateLimitEntry> = {};
const RATE_LIMIT = 1; // maximální počet požadavků za minutu
const RATE_LIMIT_WINDOW = 30 * 60 * 1000; // je to v ms (x * 1min)
// Kontrola ratelimitu
function checkRateLimit(key: string, limit: number = RATE_LIMIT): boolean {
const now = Date.now();
// Vyčištění starých záznamů
Object.keys(rateLimits).forEach(k => {
if (rateLimits[k].resetTime < now) {
delete rateLimits[k];
}
});
// Kontrola, že záznam existuje a platí
if (rateLimits[key] && rateLimits[key].resetTime > now) {
// Záznam platí a kontroluje se limit
if (rateLimits[key].count >= limit) {
return false; // Překročen limit
}
// ++ xd
rateLimits[key].count++;
return true;
} else {
// + klic
rateLimits[key] = {
count: 1,
resetTime: now + RATE_LIMIT_WINDOW
};
return true;
}
}
/**
* Ověří a vrátí index dne v týdnu z požadavku, za předpokladu, že byl předán, a je zároveň
* roven nebo vyšší indexu dnešního dne.
*
* @param req request
* @returns index dne v týdnu
*/
const parseValidateFutureDayIndex = (req: Request<{}, any, AddChoiceData["body"] | UpdateNoteData["body"]>) => {
if (req.body.dayIndex == null) {
throw Error(`Nebyl předán index dne v týdnu.`);
}
const todayDayIndex = getDayOfWeekIndex(getToday());
const dayIndex = req.body.dayIndex;
if (isNaN(dayIndex)) {
throw Error(`Neplatný index dne v týdnu: ${req.body.dayIndex}`);
}
if (dayIndex < todayDayIndex) {
throw Error(`Předaný index dne v týdnu (${dayIndex}) nesmí být nižší než dnešní den (${todayDayIndex})`);
}
return dayIndex;
}
const router = express.Router();
router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
let date = undefined;
if (req.body.dayIndex != null) {
let dayIndex;
try {
dayIndex = parseValidateFutureDayIndex(req);
} catch (e: any) {
return res.status(400).json({ error: e.message });
}
date = getDateForWeekIndex(dayIndex);
}
try {
const data = await addChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date);
getWebsocket().emit("message", data);
return res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["body"]>, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
let date = undefined;
if (req.body.dayIndex != null) {
let dayIndex;
try {
dayIndex = parseValidateFutureDayIndex(req);
} catch (e: any) {
return res.status(400).json({ error: e.message });
}
date = getDateForWeekIndex(dayIndex);
}
try {
const data = await removeChoices(login, trusted, req.body.locationKey, date);
getWebsocket().emit("message", data);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/removeChoice", async (req: Request<{}, any, RemoveChoiceData["body"]>, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
let date = undefined;
if (req.body.dayIndex != null) {
let dayIndex;
try {
dayIndex = parseValidateFutureDayIndex(req);
} catch (e: any) {
return res.status(400).json({ error: e.message });
}
date = getDateForWeekIndex(dayIndex);
}
try {
const data = await removeChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date);
getWebsocket().emit("message", data);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/updateNote", async (req: Request<{}, any, UpdateNoteData["body"]>, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
const note = req.body.note;
try {
if (note && note.length > 70) {
throw Error("Poznámka může mít maximálně 70 znaků");
}
let date = undefined;
if (req.body.dayIndex != null) {
let dayIndex;
try {
dayIndex = parseValidateFutureDayIndex(req);
} catch (e: any) {
return res.status(400).json({ error: e.message });
}
date = getDateForWeekIndex(dayIndex);
}
const data = await updateNote(login, trusted, note, date);
getWebsocket().emit("message", data);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/changeDepartureTime", async (req: Request<{}, any, ChangeDepartureTimeData["body"]>, res, next) => {
const login = getLogin(parseToken(req));
let date = undefined;
if (req.body.dayIndex != null) {
let dayIndex;
try {
dayIndex = parseValidateFutureDayIndex(req);
} catch (e: any) {
return res.status(400).json({ error: e.message });
}
date = getDateForWeekIndex(dayIndex);
}
try {
const data = await updateDepartureTime(login, req.body?.time, date);
getWebsocket().emit("message", data);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/jdemeObed", async (req, res, next) => {
const login = getLogin(parseToken(req));
try {
await callNotifikace({ input: { user: login, udalost: UdalostEnum.JDEME_NA_OBED }, gotify: false })
res.status(200).json({});
} catch (e: any) { next(e) }
});
// /api/food/refresh?type=week&heslo=docasnyheslo
export const refreshMetoda = async (req: Request, res: Response) => {
const { type, heslo } = req.query as { type?: string; heslo?: string };
if (heslo !== "docasnyheslo" && heslo !== "tohleheslopavelnesmizjistit123") {
return res.status(403).json({ error: "Neplatné heslo" });
}
if (!checkRateLimit("refresh") && heslo !== "tohleheslopavelnesmizjistit123") {
return res.status(429).json({ error: "Refresh už se zavolal, chvíli počkej :))" });
}
if (type !== "week" && type !== "day") {
return res.status(400).json({ error: "Neznámý typ refresh" });
}
if (type === "day") {
return res.status(400).json({ error: "ještě neumim TODO..." });
}
try {
// Pro všechny restaurace refreshni menu na aktuální týden
const restaurants = ["SLADOVNICKA", "TECHTOWER", "ZASTAVKAUMICHALA", "SENKSERIKOVA"] as const;
const firstDay = getFirstWorkDayOfWeek(getToday());
const results: Record<string, any> = {};
const successfulRestaurants: string[] = [];
const failedRestaurants: string[] = [];
// Nejdříve načíst všechna data bez ukládání
for (const rest of restaurants) {
try {
const weekData = await fetchRestaurantWeekMenuData(rest, firstDay);
results[rest] = weekData;
// Kontrola validity dat
if (weekData && weekData.length > 0 &&
weekData.some(dayMenu => dayMenu && dayMenu.length > 0)) {
successfulRestaurants.push(rest);
} else {
failedRestaurants.push(rest);
results[rest] = { error: "Žádná validní data" };
}
} catch (error) {
failedRestaurants.push(rest);
results[rest] = { error: `Chyba při načítání: ${error}` };
}
}
// Pokud se nepodařilo načíst žádnou restauraci
if (successfulRestaurants.length === 0) {
return res.status(400).json({
error: "Nepodařilo se získat validní data z žádné restaurace",
failed: failedRestaurants,
results: results
});
}
// Uložit pouze validní data
for (const rest of successfulRestaurants) {
try {
await saveRestaurantWeekMenu(rest as any, firstDay, results[rest]);
} catch (error) {
console.error(`Chyba při ukládání dat pro ${rest}:`, error);
}
}
// Připravit odpověď
const response: any = {
ok: true,
refreshed: results,
successful: successfulRestaurants
};
if (failedRestaurants.length > 0) {
response.warning = `Nepodařilo se načíst: ${failedRestaurants.join(', ')}`;
response.failed = failedRestaurants;
}
res.status(200).json(response);
} catch (e: any) {
res.status(500).json({ error: e?.message || "Chyba při refreshi" });
}
}
router.get("/refresh", refreshMetoda);
export default router;

View File

@@ -1,112 +0,0 @@
import express, { Request } from "express";
import { getLogin } from "../auth";
import { createPizzaDay, deletePizzaDay, getPizzaList, addPizzaOrder, removePizzaOrder, lockPizzaDay, unlockPizzaDay, finishPizzaOrder, finishPizzaDelivery, updatePizzaDayNote, updatePizzaFee } from "../pizza";
import { parseToken } from "../utils";
import { getWebsocket } from "../websocket";
import { AddPizzaData, FinishDeliveryData, RemovePizzaData, UpdatePizzaDayNoteData, UpdatePizzaFeeData } from "../../../types";
const router = express.Router();
/** Založí pizza day pro aktuální den, za předpokladu že dosud neexistuje. */
router.post("/create", async (req: Request<{}, any, undefined>, res) => {
const login = getLogin(parseToken(req));
const data = await createPizzaDay(login);
res.status(200).json(data);
getWebsocket().emit("message", data);
});
/** Smaže pizza day pro aktuální den, za předpokladu že existuje. */
router.post("/delete", async (req: Request<{}, any, undefined>, res) => {
const login = getLogin(parseToken(req));
const data = await deletePizzaDay(login);
getWebsocket().emit("message", data);
});
router.post("/add", async (req: Request<{}, any, AddPizzaData["body"]>, res) => {
const login = getLogin(parseToken(req));
if (isNaN(req.body?.pizzaIndex)) {
throw Error("Nebyl předán index pizzy");
}
const pizzaIndex = req.body.pizzaIndex;
if (isNaN(req.body?.pizzaSizeIndex)) {
throw Error("Nebyl předán index velikosti pizzy");
}
const pizzaSizeIndex = req.body.pizzaSizeIndex;
let pizzy = await getPizzaList();
if (!pizzy) {
throw Error("Selhalo získání seznamu dostupných pizz.");
}
if (!pizzy[pizzaIndex]) {
throw Error("Neplatný index pizzy: " + pizzaIndex);
}
if (!pizzy[pizzaIndex].sizes[pizzaSizeIndex]) {
throw Error("Neplatný index velikosti pizzy: " + pizzaSizeIndex);
}
const data = await addPizzaOrder(login, pizzy[pizzaIndex], pizzy[pizzaIndex].sizes[pizzaSizeIndex]);
getWebsocket().emit("message", data);
res.status(200).json({});
});
router.post("/remove", async (req: Request<{}, any, RemovePizzaData["body"]>, res) => {
const login = getLogin(parseToken(req));
if (!req.body?.pizzaOrder) {
throw Error("Nebyla předána objednávka");
}
const data = await removePizzaOrder(login, req.body?.pizzaOrder);
getWebsocket().emit("message", data);
res.status(200).json({});
});
router.post("/lock", async (req: Request<{}, any, undefined>, res) => {
const login = getLogin(parseToken(req));
const data = await lockPizzaDay(login);
getWebsocket().emit("message", data);
res.status(200).json({});
});
router.post("/unlock", async (req: Request<{}, any, undefined>, res) => {
const login = getLogin(parseToken(req));
const data = await unlockPizzaDay(login);
getWebsocket().emit("message", data);
res.status(200).json({});
});
router.post("/finishOrder", async (req: Request<{}, any, undefined>, res) => {
const login = getLogin(parseToken(req));
const data = await finishPizzaOrder(login);
getWebsocket().emit("message", data);
res.status(200).json({});
});
router.post("/finishDelivery", async (req: Request<{}, any, FinishDeliveryData["body"]>, res) => {
const login = getLogin(parseToken(req));
const data = await finishPizzaDelivery(login, req.body.bankAccount, req.body.bankAccountHolder);
getWebsocket().emit("message", data);
res.status(200).json({});
});
router.post("/updatePizzaDayNote", async (req: Request<{}, any, UpdatePizzaDayNoteData["body"]>, res, next) => {
const login = getLogin(parseToken(req));
try {
if (req.body.note && req.body.note.length > 70) {
throw Error("Poznámka může mít maximálně 70 znaků");
}
const data = await updatePizzaDayNote(login, req.body.note);
getWebsocket().emit("message", data);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/updatePizzaFee", async (req: Request<{}, any, UpdatePizzaFeeData["body"]>, res, next) => {
const login = getLogin(parseToken(req));
if (!req.body.login) {
return res.status(400).json({ error: "Nebyl předán login cílového uživatele" });
}
try {
const data = await updatePizzaFee(login, req.body.login, req.body.text, req.body.price);
getWebsocket().emit("message", data);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
export default router;

View File

@@ -1,22 +0,0 @@
import express, { Request, Response } from "express";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import { getStats } from "../stats";
import { WeeklyStats } from "../../../types/gen/types.gen";
const router = express.Router();
router.get("/", async (req: Request<{}, any, undefined>, res: Response<WeeklyStats>) => {
getLogin(parseToken(req));
if (typeof req.query.startDate === 'string' && typeof req.query.endDate === 'string') {
try {
const data = await getStats(req.query.startDate, req.query.endDate);
return res.status(200).json(data);
} catch (e) {
// necháme to zatím spadnout na 400
}
}
res.sendStatus(400);
});
export default router;

View File

@@ -1,26 +0,0 @@
import express, { Request } from "express";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import { getUserVotes, updateFeatureVote } from "../voting";
import { GetVotesData, UpdateVoteData } from "../../../types";
const router = express.Router();
router.get("/getVotes", async (req: Request<{}, any, GetVotesData["body"]>, res) => {
const login = getLogin(parseToken(req));
const data = await getUserVotes(login);
res.status(200).json(data);
});
router.post("/updateVote", async (req: Request<{}, any, UpdateVoteData["body"]>, res, next) => {
const login = getLogin(parseToken(req));
if (req.body?.option == null || req.body?.active == null) {
res.status(400).json({ error: "Chybné parametry volání" });
}
try {
const data = await updateFeatureVote(login, req.body.option, req.body.active);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
export default router;

View File

@@ -1,487 +1,254 @@
import { InsufficientPermissions, formatDate, getDayOfWeekIndex, getFirstWorkDayOfWeek, getIsWeekend, getWeekNumber } from "./utils";
import getStorage from "./storage";
import { getMenuSladovnicka, getMenuTechTower, getMenuZastavkaUmichala, getMenuSenkSerikova } from "./restaurants";
import { getTodayMock } from "./mock";
import { ClientData, DepartureTime, LunchChoice, Restaurant, RestaurantDayMenu, WeekMenu } from "../../types/gen/types.gen";
const storage = getStorage();
const MENU_PREFIX = 'menu';
import {ClientData, Locations, Order, Pizza, PizzaDayState, PizzaOrder, PizzaSize, UdalostEnum} from "./types";
import {db} from "./database";
import {formatDate, getHumanDate, getIsWeekend} from "./utils";
import {callNotifikace} from "./notifikace";
/** Vrátí dnešní datum, případně fiktivní datum pro účely vývoje a testování. */
export function getToday(): Date {
if (process.env.MOCK_DATA === 'true') {
return getTodayMock();
function getToday(): Date {
if (process.env.MOCK_DATA) {
return new Date('2023-05-31');
}
return new Date();
}
/** Vrátí datum v aktuálním týdnu na základě předaného indexu (0 = pondělí). */
export const getDateForWeekIndex = (index: number) => {
if (index < 0 || index > 4) {
// Nechceme shodit server, vrátíme dnešek
console.log('Neplatný index dne v týdnu: ' + index);
return getToday();
}
const date = getToday();
date.setDate(date.getDate() - getDayOfWeekIndex(date) + index);
return date;
}
/** Vrátí "prázdná" (implicitní) data pro předaný den. */
function getEmptyData(date?: Date): ClientData {
const usedDate = date || getToday();
return {
date: usedDate.toISOString().split('T')[0],
choices: {},
};
/** Vrátí "prázdná" (implicitní) data, pokud ještě nikdo nehlasoval. */
function getEmptyData(): ClientData {
return { date: getHumanDate(getToday()), isWeekend: getIsWeekend(getToday()), choices: {} };
}
/**
* Vrátí veškerá klientská data pro předaný den, nebo aktuální den, pokud není předán.
* Vrátí veškerá klientská data pro aktuální den.
*/
export async function getData(date?: Date): Promise<ClientData> {
const clientData = await getClientData(date);
clientData.menus = {
SLADOVNICKA: await getRestaurantMenu('SLADOVNICKA', date),
// UMOTLIKU: await getRestaurantMenu('UMOTLIKU', date),
TECHTOWER: await getRestaurantMenu('TECHTOWER', date),
ZASTAVKAUMICHALA: await getRestaurantMenu('ZASTAVKAUMICHALA', date),
SENKSERIKOVA: await getRestaurantMenu('SENKSERIKOVA', date),
export function getData(): ClientData {
const data = db.get(formatDate(getToday())) || getEmptyData();
return data;
}
/**
* Vytvoří pizza day pro aktuální den a vrátí data pro klienta.
*/
export function createPizzaDay(creator: string): ClientData {
initIfNeeded();
const today = formatDate(getToday());
const clientData: ClientData = db.get(today);
if (clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den již existuje");
}
const data: ClientData = { pizzaDay: { state: PizzaDayState.CREATED, creator, orders: [] }, ...clientData };
db.set(today, data);
callNotifikace({input:{udalost:UdalostEnum.ZAHAJENA_PIZZA,user:creator}})
return data;
}
/**
* Smaže pizza day pro aktuální den.
*/
export function deletePizzaDay(login: string) {
const today = formatDate(getToday());
const clientData: ClientData = db.get(today);
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
if (clientData.pizzaDay.creator !== login) {
throw Error("Login uživatele se neshoduje se zakladatelem Pizza Day");
}
delete clientData.pizzaDay;
db.set(today, clientData);
return clientData;
}
/**
* Vrátí klíč, pod kterým je uloženo menu pro týden příslušící předanému datu.
*
* @param date datum
* @returns databázový klíč
*/
function getMenuKey(date: Date) {
const weekNumber = getWeekNumber(date);
return `${MENU_PREFIX}_${date.getFullYear()}_${weekNumber}`;
}
/**
* Vrátí menu všech podniků pro celý týden do kterého spadá předané datum, pokud již existují.
*
* @param date datum
* @returns menu restaurací pro týden příslušící předanému datu
*/
async function getMenu(date: Date): Promise<WeekMenu | undefined> {
return await storage.getData<WeekMenu | undefined>(getMenuKey(date));
}
// TODO přesun do restaurants.ts
/**
* Načte menu dané restaurace pro celý týden bez ukládání do storage.
* Používá se pro validaci dat před uložením.
*
* @param restaurant restaurace
* @param firstDay první pracovní den týdne
* @returns pole menu pro jednotlivé dny týdne
*/
export async function fetchRestaurantWeekMenuData(restaurant: Restaurant, firstDay: Date): Promise<any[]> {
return await fetchRestaurantWeekMenu(restaurant, firstDay);
}
/**
* Uloží týdenní menu restaurace do storage.
*
* @param restaurant restaurace
* @param date datum z týdne, pro který ukládat menu
* @param weekData data týdenního menu
*/
export async function saveRestaurantWeekMenu(restaurant: Restaurant, date: Date, weekData: any[]): Promise<void> {
const now = new Date().getTime();
let weekMenu = await getMenu(date);
weekMenu ??= [{}, {}, {}, {}, {}];
// Inicializace struktury pro restauraci
for (let i = 0; i < 5; i++) {
weekMenu[i] ??= {};
weekMenu[i][restaurant] ??= {
lastUpdate: now,
closed: false,
food: [],
};
}
// Uložení dat pro všechny dny
for (let i = 0; i < weekData.length && i < weekMenu.length; i++) {
weekMenu[i][restaurant]!.food = weekData[i];
weekMenu[i][restaurant]!.lastUpdate = now;
// Detekce uzavření pro každou restauraci
switch (restaurant) {
case 'SLADOVNICKA':
if (weekData[i].length === 1 && weekData[i][0].name.toLowerCase() === 'pro daný den nebyla nalezena denní nabídka') {
weekMenu[i][restaurant]!.closed = true;
}
break;
case 'TECHTOWER':
if (weekData[i]?.length === 1 && weekData[i][0].name.toLowerCase() === 'svátek') {
weekMenu[i][restaurant]!.closed = true;
}
break;
case 'ZASTAVKAUMICHALA':
if (weekData[i]?.length === 1 && weekData[i][0].name === 'Pro tento den není uveřejněna nabídka jídel.') {
weekMenu[i][restaurant]!.closed = true;
}
break;
case 'SENKSERIKOVA':
if (weekData[i]?.length === 1 && weekData[i][0].name === 'Pro tento den nebylo zadáno menu.') {
weekMenu[i][restaurant]!.closed = true;
}
break;
}
}
// Uložení do storage
await storage.setData(getMenuKey(date), weekMenu);
}
/**
* Načte menu dané restaurace pro celý týden bez ukládání do storage.
*
* @param restaurant restaurace
* @param firstDay první pracovní den týdne
* @returns pole menu pro jednotlivé dny týdne
*/
async function fetchRestaurantWeekMenu(restaurant: Restaurant, firstDay: Date): Promise<any[]> {
const mock = process.env.MOCK_DATA === 'true';
switch (restaurant) {
case 'SLADOVNICKA':
return await getMenuSladovnicka(firstDay, mock);
case 'TECHTOWER':
return await getMenuTechTower(firstDay, mock);
case 'ZASTAVKAUMICHALA':
return await getMenuZastavkaUmichala(firstDay, mock);
case 'SENKSERIKOVA':
return await getMenuSenkSerikova(firstDay, mock);
default:
throw new Error(`Nepodporovaná restaurace: ${restaurant}`);
}
}
/**
* Vrátí menu dané restaurace pro předaný den.
* Pokud neexistuje, provede stažení menu pro příslušný týden a uložení do DB.
*
* @param restaurant restaurace
* @param date datum, ke kterému získat menu
* @param forceRefresh příznak vynuceného obnovení
*/
export async function getRestaurantMenu(restaurant: Restaurant, date?: Date, forceRefresh = false): Promise<RestaurantDayMenu> {
const usedDate = date ?? getToday();
const dayOfWeekIndex = getDayOfWeekIndex(usedDate);
const now = new Date().getTime();
if (getIsWeekend(usedDate)) {
return {
lastUpdate: now,
closed: true,
food: [],
};
}
let weekMenu = await getMenu(usedDate);
weekMenu ??= [{}, {}, {}, {}, {}];
for (let i = 0; i < 5; i++) {
weekMenu[i] ??= {};
weekMenu[i][restaurant] ??= {
lastUpdate: now,
closed: false,
food: [],
};
}
if (forceRefresh || (!weekMenu[dayOfWeekIndex][restaurant]?.food?.length && !weekMenu[dayOfWeekIndex][restaurant]?.closed)) {
const firstDay = getFirstWorkDayOfWeek(usedDate);
try {
const restaurantWeekFood = await fetchRestaurantWeekMenu(restaurant, firstDay);
// Aktualizace menu pro všechny dny
for (let i = 0; i < restaurantWeekFood.length && i < weekMenu.length; i++) {
weekMenu[i][restaurant]!.food = restaurantWeekFood[i];
weekMenu[i][restaurant]!.lastUpdate = now;
// Detekce uzavření pro každou restauraci
switch (restaurant) {
case 'SLADOVNICKA':
if (restaurantWeekFood[i].length === 1 && restaurantWeekFood[i][0].name.toLowerCase() === 'pro daný den nebyla nalezena denní nabídka') {
weekMenu[i][restaurant]!.closed = true;
}
break;
case 'TECHTOWER':
if (restaurantWeekFood[i]?.length === 1 && restaurantWeekFood[i][0].name.toLowerCase() === 'svátek') {
weekMenu[i][restaurant]!.closed = true;
}
break;
case 'ZASTAVKAUMICHALA':
if (restaurantWeekFood[i]?.length === 1 && restaurantWeekFood[i][0].name === 'Pro tento den není uveřejněna nabídka jídel.') {
weekMenu[i][restaurant]!.closed = true;
}
break;
case 'SENKSERIKOVA':
if (restaurantWeekFood[i]?.length === 1 && restaurantWeekFood[i][0].name === 'Pro tento den nebylo zadáno menu.') {
weekMenu[i][restaurant]!.closed = true;
}
break;
}
}
// Uložení do storage
await storage.setData(getMenuKey(usedDate), weekMenu);
} catch (e: any) {
console.error(`Selhalo načtení jídel pro podnik ${restaurant}`, e);
}
}
return weekMenu[dayOfWeekIndex][restaurant]!;
}
/**
* Inicializuje výchozí data pro předané datum, nebo dnešek, pokud není datum předáno.
*
* @param date datum
*/
export async function initIfNeeded(date?: Date) {
const usedDate = formatDate(date ?? getToday());
const hasData = await storage.hasData(usedDate);
if (!hasData) {
await storage.setData(usedDate, getEmptyData(date || getToday()));
}
}
/**
* Odstraní kompletně volbu uživatele (včetně případných podřízených jídel).
* Přidá objednávku pizzy uživateli.
*
* @param login login uživatele
* @param trusted příznak, zda se jedná o ověřeného uživatele
* @param locationKey vybrané "umístění"
* @param date datum, ke kterému se volba vztahuje
* @returns
* @param pizza zvolená pizza
* @param size zvolená velikost pizzy
*/
export async function removeChoices(login: string, trusted: boolean, locationKey: LunchChoice, date?: Date) {
const selectedDay = formatDate(date ?? getToday());
let data = await getClientData(date);
validateTrusted(data, login, trusted);
if (locationKey in data.choices) {
if (data.choices[locationKey] && login in data.choices[locationKey]) {
delete data.choices[locationKey][login]
if (Object.keys(data.choices[locationKey]).length === 0) {
delete data.choices[locationKey]
}
await storage.setData(selectedDay, data);
}
export function addPizzaOrder(login: string, pizza: Pizza, size: PizzaSize) {
const today = formatDate(getToday());
const clientData: ClientData = db.get(today);
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
return data;
}
/**
* Odstraní konkrétní volbu jídla uživatele.
* Neodstraňuje volbu samotnou, k tomu slouží {@link removeChoices}.
*
* @param login login uživatele
* @param trusted příznak, zda se jedná o ověřeného uživatele
* @param locationKey vybrané "umístění"
* @param foodIndex index jídla v jídelním lístku daného umístění, pokud existuje
* @param date datum, ke kterému se volba vztahuje
* @returns
*/
export async function removeChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex: number, date?: Date) {
const selectedDay = formatDate(date ?? getToday());
let data = await getClientData(date);
validateTrusted(data, login, trusted);
if (locationKey in data.choices) {
if (data.choices[locationKey] && login in data.choices[locationKey]) {
const index = data.choices[locationKey][login].selectedFoods?.indexOf(foodIndex);
if (index != null && index > -1) {
data.choices[locationKey][login].selectedFoods?.splice(index, 1);
await storage.setData(selectedDay, data);
}
}
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
}
return data;
}
/**
* Odstraní kompletně volbu uživatele, vyjma ignoredLocationKey (pokud byla předána a existuje).
*
* @param login login uživatele
* @param date datum, ke kterému se volby vztahují
* @param ignoredLocationKey volba, která nebude odstraněna, pokud existuje
*/
async function removeChoiceIfPresent(login: string, date?: Date, ignoredLocationKey?: LunchChoice) {
const usedDate = date ?? getToday();
let data = await getClientData(usedDate);
for (const key of Object.keys(data.choices)) {
const locationKey = key as LunchChoice;
if (ignoredLocationKey != null && ignoredLocationKey == locationKey) {
continue;
}
if (data.choices[locationKey] && login in data.choices[locationKey]) {
delete data.choices[locationKey][login];
if (Object.keys(data.choices[locationKey]).length === 0) {
delete data.choices[locationKey];
}
await storage.setData(formatDate(usedDate), data);
let order: Order | undefined = clientData.pizzaDay.orders.find(o => o.customer === login);
if (!order) {
order = {
customer: login,
pizzaList: [],
totalPrice: 0,
}
clientData.pizzaDay.orders.push(order);
}
return data;
}
/**
* Ověří, zda se neověřený uživatel nepokouší přepsat údaje ověřeného a případně vyhodí chybu.
*
* @param data aktuální klientská data
* @param login přihlašovací jméno uživatele
* @param trusted příznak, zda se jedná o ověřeného uživatele
*/
function validateTrusted(data: ClientData, login: string, trusted: boolean) {
const locations = Object.values(data?.choices);
let found = false;
if (!trusted) {
for (const location of locations) {
if (Object.keys(location).includes(login) && location[login].trusted) {
found = true;
}
}
}
if (!trusted && found) {
throw new InsufficientPermissions("Nelze změnit volbu ověřeného uživatele");
}
}
/**
* Přidá volbu uživatele.
*
* @param login login uživatele
* @param trusted příznak, zda se jedná o ověřeného uživatele
* @param locationKey vybrané "umístění"
* @param foodIndex volitelný index jídla v daném umístění
* @param trusted příznak, zda se jedná o ověřeného uživatele
* @param date datum, ke kterému se volba vztahuje
* @returns aktuální data
*/
export async function addChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex?: number, date?: Date) {
const usedDate = date ?? getToday();
await initIfNeeded(usedDate);
let data = await getClientData(usedDate);
validateTrusted(data, login, trusted);
await validateFoodIndex(locationKey, foodIndex, date);
// Pokud měníme pouze lokaci, mažeme případné předchozí
if (foodIndex == null) {
data = await removeChoiceIfPresent(login, usedDate);
} else {
// Mažeme případné ostatní volby (měla by být maximálně jedna)
removeChoiceIfPresent(login, usedDate, locationKey);
}
// TODO vytáhnout inicializaci "prázdné struktury" do vlastní funkce
data.choices[locationKey] ??= {};
if (!(login in data.choices[locationKey])) {
if (!data.choices[locationKey]) {
data.choices[locationKey] = {}
}
data.choices[locationKey][login] = {
trusted,
selectedFoods: []
};
}
if (foodIndex != null && !data.choices[locationKey][login].selectedFoods?.includes(foodIndex)) {
data.choices[locationKey][login].selectedFoods?.push(foodIndex);
}
const selectedDate = formatDate(usedDate);
await storage.setData(selectedDate, data);
return data;
}
/**
* Zvaliduje platnost indexu jídla pro vybranou lokalitu a datum.
*
* @param locationKey vybraná lokalita
* @param foodIndex index jídla pro danou lokalitu
* @param date datum, pro které je validace prováděna
*/
async function validateFoodIndex(locationKey: LunchChoice, foodIndex?: number, date?: Date) {
if (foodIndex != null) {
if (typeof foodIndex !== 'number') {
throw Error(`Neplatný index ${foodIndex} typu ${typeof foodIndex}`);
}
if (foodIndex < 0) {
throw Error(`Neplatný index ${foodIndex}`);
}
if (!Object.keys(Restaurant).includes(locationKey)) {
throw Error(`Neplatný index ${foodIndex} pro lokalitu ${locationKey} nepodporující indexy`);
}
const usedDate = date ?? getToday();
const menu = await getRestaurantMenu(locationKey as Restaurant, usedDate);
if (menu.food?.length && foodIndex > (menu.food.length - 1)) {
throw new Error(`Neplatný index ${foodIndex} pro lokalitu ${locationKey}`);
}
}
}
/**
* Aktualizuje poznámku k aktuálně vybrané možnosti.
*
* @param login login uživatele
* @param trusted příznak, zda se jedná o ověřeného uživatele
* @param note poznámka
* @param date datum, ke kterému se volba vztahuje
*/
export async function updateNote(login: string, trusted: boolean, note?: string, date?: Date) {
const usedDate = date ?? getToday();
await initIfNeeded(usedDate);
let data = await getClientData(usedDate);
validateTrusted(data, login, trusted);
const userEntry = data.choices != null && Object.entries(data.choices).find(entry => entry[1][login] != null);
if (userEntry) {
if (!note?.length) {
delete userEntry[1][login].note;
} else {
userEntry[1][login].note = note;
}
const selectedDate = formatDate(usedDate);
await storage.setData(selectedDate, data);
}
return data;
}
/**
* Aktualizuje preferovaný čas odchodu strávníka.
*
* @param login login uživatele
* @param time preferovaný čas odchodu
* @param date datum, ke kterému se čas vztahuje
*/
export async function updateDepartureTime(login: string, time?: string, date?: Date) {
const usedDate = date ?? getToday();
let clientData = await getClientData(usedDate);
const found = Object.values(clientData.choices).find(location => login in location);
// TODO validace, že se jedná o restauraci
if (found) {
if (!time?.length) {
delete found[login].departureTime;
} else {
if (!Object.values<string>(DepartureTime).includes(time)) {
throw Error(`Neplatný čas odchodu ${time}`);
}
found[login].departureTime = time;
}
await storage.setData(formatDate(usedDate), clientData);
const pizzaOrder: PizzaOrder = {
varId: size.varId,
name: pizza.name,
size: size.size,
price: size.price,
}
order.pizzaList.push(pizzaOrder);
order.totalPrice += pizzaOrder.price;
db.set(today, clientData);
return clientData;
}
/**
* Vrátí data pro klienta pro předaný nebo aktuální den.
* Odstraní danou objednávku pizzy.
*
* @param date datum pro který vrátit data, pokud není vyplněno, je použit dnešní den
* @returns data pro klienta
* @param login login uživatele
* @param pizzaOrder objednávka pizzy
*/
export async function getClientData(date?: Date): Promise<ClientData> {
const targetDate = date ?? getToday();
const dateString = formatDate(targetDate);
return await storage.getData<ClientData>(dateString) || getEmptyData(date);
export function removePizzaOrder(login: string, pizzaOrder: PizzaOrder) {
const today = formatDate(getToday());
const clientData: ClientData = db.get(today);
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
const orderIndex = clientData.pizzaDay.orders.findIndex(o => o.customer === login);
if (orderIndex < 0) {
throw Error("Nebyly nalezeny žádné objednávky pro uživatele " + login);
}
const order = clientData.pizzaDay.orders[orderIndex];
const index = order.pizzaList.findIndex(o => o.name === pizzaOrder.name && o.size === pizzaOrder.size);
if (index < 0) {
throw Error("Objednávka s danými parametry nebyla nalezena");
}
const price = order.pizzaList[index].price;
order.pizzaList.splice(index, 1);
order.totalPrice -= price;
if (order.pizzaList.length == 0) {
clientData.pizzaDay.orders.splice(orderIndex, 1);
}
db.set(today, clientData);
return clientData;
}
/**
* Uzamkne možnost editovat objednávky pizzy.
*
* @param login login uživatele
* @returns aktuální data pro uživatele
*/
export function lockPizzaDay(login: string) {
const today = formatDate(getToday());
const clientData: ClientData = db.get(today);
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
if (clientData.pizzaDay.creator !== login) {
throw Error("Pizza day není spravován uživatelem " + login);
}
if (clientData.pizzaDay.state !== PizzaDayState.CREATED && clientData.pizzaDay.state !== PizzaDayState.ORDERED) {
throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED + " nebo " + PizzaDayState.ORDERED);
}
clientData.pizzaDay.state = PizzaDayState.LOCKED;
db.set(today, clientData);
return clientData;
}
/**
* Odekmne možnost editovat objednávky pizzy.
*
* @param login login uživatele
* @returns aktuální data pro uživatele
*/
export function unlockPizzaDay(login: string) {
const today = formatDate(getToday());
const clientData: ClientData = db.get(today);
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
if (clientData.pizzaDay.creator !== login) {
throw Error("Pizza day není spravován uživatelem " + login);
}
if (clientData.pizzaDay.state !== PizzaDayState.LOCKED) {
throw Error("Pizza day není ve stavu " + PizzaDayState.LOCKED);
}
clientData.pizzaDay.state = PizzaDayState.CREATED;
db.set(today, clientData);
return clientData;
}
/**
* Nastaví stav pizza day na "pizzy objednány".
*
* @param login login uživatele
* @returns aktuální data pro uživatele
*/
export function finishPizzaOrder(login: string) {
const today = formatDate(getToday());
const clientData: ClientData = db.get(today);
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
if (clientData.pizzaDay.creator !== login) {
throw Error("Pizza day není spravován uživatelem " + login);
}
if (clientData.pizzaDay.state !== PizzaDayState.LOCKED) {
throw Error("Pizza day není ve stavu " + PizzaDayState.LOCKED);
}
clientData.pizzaDay.state = PizzaDayState.ORDERED;
db.set(today, clientData);
callNotifikace({input:{udalost:UdalostEnum.ZAHAJENA_PIZZA,user:clientData?.pizzaDay?.creator}})
return clientData;
}
/**
* Nastaví stav pizza day na "pizzy doručeny".
*
* @param login login uživatele
* @returns aktuální data pro uživatele
*/
export function finishPizzaDelivery(login: string) {
const today = formatDate(getToday());
const clientData: ClientData = db.get(today);
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
if (clientData.pizzaDay.creator !== login) {
throw Error("Pizza day není spravován uživatelem " + login);
}
if (clientData.pizzaDay.state !== PizzaDayState.ORDERED) {
throw Error("Pizza day není ve stavu " + PizzaDayState.ORDERED);
}
clientData.pizzaDay.state = PizzaDayState.DELIVERED;
db.set(today, clientData);
return clientData;
}
export function initIfNeeded() {
const today = formatDate(getToday());
if (!db.has(today)) {
db.set(today, getEmptyData());
}
}
export function removeChoice(login: string, data: ClientData) {
for (let key of Object.keys(data.choices)) {
if (data.choices[key] && data.choices[key].includes(login)) {
const index = data.choices[key].indexOf(login);
data.choices[key].splice(index, 1);
if (data.choices[key].length == 0) {
delete data.choices[key];
}
}
}
return data;
}
export function updateChoice(login: string, choice: Locations | null) {
initIfNeeded();
const today = formatDate(getToday());
let data: ClientData = db.get(today);
data = removeChoice(login, data);
if (choice !== null) {
if (!data.choices?.[choice]) {
data.choices[choice] = [];
}
data.choices[choice].push(login);
}
db.set(today, data);
return data;
}

View File

@@ -1,48 +0,0 @@
import { DailyStats, LunchChoice, WeeklyStats } from "../../types/gen/types.gen";
import { getStatsMock } from "./mock";
import { getClientData } from "./service";
import getStorage from "./storage";
const storage = getStorage();
/**
* Vypočte a vrátí statistiky jednotlivých možností pro předaný rozsah dat.
*
* @param startDate počáteční datum
* @param endDate koncové datum
* @returns statistiky pro zadaný rozsah dat
*/
export async function getStats(startDate: string, endDate: string): Promise<WeeklyStats> {
if (process.env.MOCK_DATA === 'true') {
return getStatsMock();
}
const start = new Date(startDate);
const end = new Date(endDate);
// Dočasná validace, aby to někdo ručně neshodil
const daysDiff = ((end as any) - (start as any)) / (1000 * 60 * 60 * 24);
if (daysDiff > 4) {
throw Error('Neplatný rozsah');
}
const result = [];
for (const date = start; date <= end; date.setDate(date.getDate() + 1)) {
const locationsStats: DailyStats = {
// TODO vytáhnout do utils funkce
date: `${String(date.getDate()).padStart(2, "0")}.${String(date.getMonth() + 1).padStart(2, "0")}.`,
locations: {}
}
const data = await getClientData(date);
if (data?.choices) {
Object.keys(data.choices).forEach(locationKey => {
if (!locationsStats.locations) {
locationsStats.locations = {}
}
// TODO dořešit, tohle je zmatek a té hlášce Sonaru nerozumím
locationsStats.locations[locationKey as LunchChoice] = Object.keys(data.choices[locationKey as LunchChoice]!).length;
})
}
result.push(locationsStats);
}
return result as WeeklyStats;
}

View File

@@ -1,32 +0,0 @@
/**
* Interface pro úložiště dat.
*
* Aktuálně pouze "primitivní" has, get a set odrážející původní JSON DB.
* Postupem času lze předělat pro efektivnější využití Redis.
*/
export interface StorageInterface {
/**
* Inicializuje úložiště, pokud je potřeba (např. připojení k databázi).
*/
initialize?(): Promise<void>;
/**
* Vrátí příznak, zda existují data pro předaný klíč.
* @param key klíč, pro který zjišťujeme data (typicky datum)
*/
hasData(key: string): Promise<boolean>;
/**
* Vrátí veškerá data pro předaný klíč.
* @param key klíč, pro který vrátit data (typicky datum)
*/
getData<Type>(key: string): Promise<Type | undefined>;
/**
* Uloží data pod předaný klíč.
* @param key klíč, pod kterým uložit data (typicky datum)
* @param data data pro uložení
*/
setData<Type>(key: string, data: Type): Promise<void>;
}

View File

@@ -1,31 +0,0 @@
import dotenv from 'dotenv';
import path from 'path';
import { StorageInterface } from "./StorageInterface";
import JsonStorage from "./json";
import RedisStorage from "./redis";
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
dotenv.config({ path: path.resolve(__dirname, `../../.env.${ENVIRONMENT}`) });
const JSON_KEY = 'json';
const REDIS_KEY = 'redis';
let storage: StorageInterface;
if (!process.env.STORAGE || process.env.STORAGE?.toLowerCase() === JSON_KEY) {
storage = new JsonStorage();
} else if (process.env.STORAGE?.toLowerCase() === REDIS_KEY) {
storage = new RedisStorage();
} else {
throw Error("Nepodporovaná hodnota proměnné STORAGE: " + process.env.STORAGE + ", podporované jsou 'json' nebo 'redis'");
}
(async () => {
if (storage.initialize) {
await storage.initialize();
}
})();
export default function getStorage(): StorageInterface {
return storage;
}

View File

@@ -1,32 +0,0 @@
import JSONdb from 'simple-json-db';
import { StorageInterface } from "./StorageInterface";
import * as fs from 'fs';
import * as path from 'path';
const dbPath = path.resolve(__dirname, '../../data/db.json');
const dbDir = path.dirname(dbPath);
// Zajistěte, že adresář existuje
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
const db = new JSONdb(dbPath);
/**
* Implementace úložiště používající JSON soubor.
*/
export default class JsonStorage implements StorageInterface {
hasData(key: string): Promise<boolean> {
return Promise.resolve(db.has(key));
}
getData<Type>(key: string): Promise<Type> {
return db.get(key);
}
setData<Type>(key: string, data: Type): Promise<void> {
db.set(key, data);
return Promise.resolve();
}
}

View File

@@ -1,34 +0,0 @@
import { RedisClientType, createClient } from 'redis';
import { StorageInterface } from "./StorageInterface";
let client: RedisClientType;
/**
* Implementace úložiště využívající Redis server.
*/
export default class RedisStorage implements StorageInterface {
constructor() {
const HOST = process.env.REDIS_HOST ?? 'localhost';
const PORT = process.env.REDIS_PORT ?? 6379;
client = createClient({ url: `redis://${HOST}:${PORT}` });
}
async initialize() {
client.connect();
}
async hasData(key: string) {
const data = await client.json.get(key);
return (!!data);
}
async getData<Type>(key: string) {
const data = await client.json.get(key, { path: '.' });
return data as Type;
}
async setData<Type>(key: string, data: Type) {
await client.json.set(key, '.', data as any);
await client.json.get(key);
}
}

View File

@@ -1,159 +0,0 @@
import { formatDate, getDayOfWeekIndex, getFirstWorkDayOfWeek, getLastWorkDayOfWeek, getWeekNumber } from "../utils";
test('získání indexu dne v týdnu', () => {
let date = new Date("2023-10-01");
expect(getDayOfWeekIndex(date)).toBe(6);
date = new Date("2023-10-02");
expect(getDayOfWeekIndex(date)).toBe(0);
date = new Date("2023-10-03");
expect(getDayOfWeekIndex(date)).toBe(1);
date = new Date("2023-10-04");
expect(getDayOfWeekIndex(date)).toBe(2);
date = new Date("2023-10-05");
expect(getDayOfWeekIndex(date)).toBe(3);
date = new Date("2023-10-06");
expect(getDayOfWeekIndex(date)).toBe(4);
date = new Date("2023-10-07");
expect(getDayOfWeekIndex(date)).toBe(5);
date = new Date("2023-10-08");
expect(getDayOfWeekIndex(date)).toBe(6);
date = new Date("2023-10-09");
expect(getDayOfWeekIndex(date)).toBe(0);
});
test('získání data prvního/posledního dne v týdnu', () => {
let date = new Date("2023-10-02");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-10-02");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-10-06");
date = new Date("2023-10-03");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-10-02");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-10-06");
date = new Date("2023-10-04");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-10-02");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-10-06");
date = new Date("2023-10-05");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-10-02");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-10-06");
date = new Date("2023-10-06");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-10-02");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-10-06");
date = new Date("2023-10-07");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-10-02");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-10-06");
date = new Date("2023-10-08");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-10-02");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-10-06");
date = new Date("2023-01-01");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2022-12-26");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2022-12-30");
date = new Date("2023-01-02");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-01-02");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-01-06");
date = new Date("2023-01-03");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-01-02");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-01-06");
date = new Date("2023-01-04");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-01-02");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-01-06");
date = new Date("2023-01-05");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-01-02");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-01-06");
date = new Date("2023-01-06");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-01-02");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-01-06");
date = new Date("2023-01-07");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-01-02");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-01-06");
date = new Date("2023-01-08");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-01-02");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-01-06");
date = new Date("2023-12-25");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-12-25");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-12-29");
date = new Date("2023-12-26");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-12-25");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-12-29");
date = new Date("2023-12-27");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-12-25");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-12-29");
date = new Date("2023-12-28");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-12-25");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-12-29");
date = new Date("2023-12-29");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-12-25");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-12-29");
date = new Date("2023-12-30");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-12-25");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-12-29");
date = new Date("2023-12-31");
expect(formatDate(getFirstWorkDayOfWeek(date))).toBe("2023-12-25");
expect(formatDate(getLastWorkDayOfWeek(date))).toBe("2023-12-29");
});
test('získání čísla týdne v roce', () => {
let date = new Date("2023-10-01");
expect(getWeekNumber(date)).toBe(39);
date = new Date("2023-10-02");
expect(getWeekNumber(date)).toBe(40);
date = new Date("2023-10-03");
expect(getWeekNumber(date)).toBe(40);
date = new Date("2023-10-04");
expect(getWeekNumber(date)).toBe(40);
date = new Date("2023-10-05");
expect(getWeekNumber(date)).toBe(40);
date = new Date("2023-10-06");
expect(getWeekNumber(date)).toBe(40);
date = new Date("2023-10-07");
expect(getWeekNumber(date)).toBe(40);
date = new Date("2023-10-08");
expect(getWeekNumber(date)).toBe(40);
date = new Date("2023-10-09");
expect(getWeekNumber(date)).toBe(41);
date = new Date("2022-01-01");
expect(getWeekNumber(date)).toBe(52);
date = new Date("2022-12-30");
expect(getWeekNumber(date)).toBe(52);
date = new Date("2022-12-31");
expect(getWeekNumber(date)).toBe(52);
date = new Date("2023-01-01");
expect(getWeekNumber(date)).toBe(52);
date = new Date("2023-01-02");
expect(getWeekNumber(date)).toBe(1);
date = new Date("2023-01-03");
expect(getWeekNumber(date)).toBe(1);
date = new Date("2023-01-04");
expect(getWeekNumber(date)).toBe(1);
date = new Date("2023-01-05");
expect(getWeekNumber(date)).toBe(1);
date = new Date("2023-01-06");
expect(getWeekNumber(date)).toBe(1);
date = new Date("2023-01-07");
expect(getWeekNumber(date)).toBe(1);
date = new Date("2023-01-08");
expect(getWeekNumber(date)).toBe(1);
date = new Date("2023-01-09");
expect(getWeekNumber(date)).toBe(2);
date = new Date("2023-12-24");
expect(getWeekNumber(date)).toBe(51);
date = new Date("2023-12-25");
expect(getWeekNumber(date)).toBe(52);
date = new Date("2023-12-26");
expect(getWeekNumber(date)).toBe(52);
date = new Date("2023-12-27");
expect(getWeekNumber(date)).toBe(52);
date = new Date("2023-12-28");
expect(getWeekNumber(date)).toBe(52);
date = new Date("2023-12-29");
expect(getWeekNumber(date)).toBe(52);
date = new Date("2023-12-30");
expect(getWeekNumber(date)).toBe(52);
date = new Date("2023-12-31");
expect(getWeekNumber(date)).toBe(52);
date = new Date("2024-01-01");
expect(getWeekNumber(date)).toBe(1);
});

85
server/src/types.ts Normal file
View File

@@ -0,0 +1,85 @@
import exp from "constants";
export interface Choices {
[location: string]: string[],
}
/** Velikost konkrétní pizzy */
export interface PizzaSize {
varId: number, // unikátní ID varianty pizzy
size: string, // velikost pizzy, např. "30cm"
pizzaPrice: number, // cena samotné pizzy
boxPrice: number, // cena krabice
price: number, // celková cena (pizza + krabice)
}
/** Jedna konkrétní pizza */
export interface Pizza {
name: string, // název pizzy
ingredients: string[], // seznam ingrediencí
sizes: PizzaSize[], // dostupné velikosti pizzy
}
/** Objednávka jedné konkrétní pizzy */
export interface PizzaOrder {
varId: number, // unikátní ID varianty pizzy
name: string, // název pizzy
size: string, // velikost pizzy jako string (30cm)
price: number, // cena pizzy v Kč, včetně krabice
}
/** Celková objednávka jednoho člověka */
export interface Order {
customer: string, // jméno objednatele
pizzaList: PizzaOrder[], // seznam objednaných pizz
totalPrice: number, // celková cena všech objednaných pizz a krabic
}
/** Stav pizza dne */
export enum PizzaDayState {
NOT_CREATED, // Pizza day nebyl založen
CREATED, // Pizza day je založen
LOCKED, // Objednávky uzamčeny
ORDERED, // Pizzy objednány
DELIVERED // Pizzy doručeny
}
/** Informace o pizza day pro dnešní den */
interface PizzaDay {
state: PizzaDayState, // stav pizza dne
creator: string, // jméno zakladatele
orders: Order[], // seznam objednávek jednotlivých lidí
}
/** Veškerá data pro zobrazení na klientovi */
export interface ClientData {
date: string, // dnešní datum pro zobrazení
isWeekend: boolean, // příznak, zda je dnes víkend
choices: Choices, // seznam voleb
pizzaDay?: PizzaDay, // pizza day pro dnešní den, pokud existuje
}
export enum Locations {
SLADOVNICKA = 'Sladovnická',
UMOTLIKU = 'U Motlíků',
TECHTOWER = 'TechTower',
SPSE = 'SPŠE',
VLASTNI = 'Mám vlastní',
OBJEDNAVAM = 'Objednávám',
NEOBEDVAM = 'Neobědvám',
}
export enum UdalostEnum{
ZAHAJENA_PIZZA="Zahájen pizza day",
OBJEDNANA_PIZZA="Objednána pizza"
}
export interface NotififaceInput{
udalost:UdalostEnum,
user:string,
}
export interface NotifikaceData{
input:NotififaceInput,
gotify?:boolean,
teams?:boolean,
}

View File

@@ -1,15 +1,9 @@
import { LunchChoice, LunchChoices } from "../../types/gen/types.gen";
const DAY_OF_WEEK_FORMAT = new Intl.DateTimeFormat(undefined, { weekday: 'long' });
/** Vrátí datum v ISO formátu. */
export function formatDate(date: Date, format?: string) {
let day = String(date.getDate()).padStart(2, '0');
let month = String(date.getMonth() + 1).padStart(2, "0");
let year = String(date.getFullYear());
const f = format ?? 'YYYY-MM-DD';
return f.replace('DD', day).replace('MM', month).replace('YYYY', year);
export function formatDate(date: Date) {
let currentDay = String(date.getDate()).padStart(2, '0');
let currentMonth = String(date.getMonth() + 1).padStart(2, "0");
let currentYear = date.getFullYear();
return `${currentYear}-${currentMonth}-${currentDay}`;
}
/** Vrátí human-readable reprezentaci předaného data pro zobrazení. */
@@ -17,118 +11,12 @@ export function getHumanDate(date: Date) {
let currentDay = String(date.getDate()).padStart(2, '0');
let currentMonth = String(date.getMonth() + 1).padStart(2, "0");
let currentYear = date.getFullYear();
let currentDayOfWeek = DAY_OF_WEEK_FORMAT.format(date);
let currentDayOfWeek = date.toLocaleDateString("CZ-cs", { weekday: 'long' });
return `${currentDay}.${currentMonth}.${currentYear} (${currentDayOfWeek})`;
}
/** Vrátí human-readable reprezentaci předaného času pro zobrazení. */
export function getHumanTime(time: Date) {
let currentHours = String(time.getHours()).padStart(2, '0');
let currentMinutes = String(time.getMinutes()).padStart(2, "0");
return `${currentHours}:${currentMinutes}`;
}
/**
* Vrátí index dne v týdnu, kde pondělí=0, neděle=6
*
* @param date datum
* @returns index dne v týdnu
*/
export const getDayOfWeekIndex = (date: Date) => {
// https://stackoverflow.com/a/4467559
return (((date.getDay() - 1) % 7) + 7) % 7;
}
/** Vrátí true, pokud je předané datum o víkendu. */
export function getIsWeekend(date: Date) {
const index = getDayOfWeekIndex(date);
return index == 5 || index == 6;
}
/** Vrátí první pracovní den v týdnu předaného data. */
export function getFirstWorkDayOfWeek(date: Date) {
const firstDay = new Date(date.getTime());
firstDay.setDate(date.getDate() - getDayOfWeekIndex(date));
return firstDay;
}
/** Vrátí poslední pracovní den v týdnu předaného data. */
export function getLastWorkDayOfWeek(date: Date) {
const lastDay = new Date(date.getTime());
lastDay.setDate(date.getDate() + (4 - getDayOfWeekIndex(date)));
return lastDay;
}
/** Vrátí pořadové číslo týdne předaného data v roce dle ISO 8601. */
export function getWeekNumber(inputDate: Date) {
const date = new Date(inputDate.getTime());
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() + 3 - (date.getDay() + 6) % 7);
const week1 = new Date(date.getFullYear(), 0, 4);
return 1 + Math.round(((date.getTime() - week1.getTime()) / 86400000 - 3 + (week1.getDay() + 6) % 7) / 7);
}
/**
* Vrátí JWT token z hlaviček, pokud ho obsahují.
*
* @param req request
* @returns token, pokud ho hlavičky requestu obsahují
*/
export const parseToken = (req: any) => {
if (req?.headers?.authorization) {
return req.headers.authorization.split(' ')[1];
}
}
/**
* Ověří přítomnost (not null) předaných parametrů v URL query.
* V případě nepřítomnosti kteréhokoli parametru vyhodí chybu.
*
* @param req request
* @param paramNames pole názvů požadovaných parametrů
*/
export const checkQueryParams = (req: any, paramNames: string[]) => {
for (const name of paramNames) {
if (req.query[name] == null) {
throw Error(`Nebyl předán parametr '${name}' v query požadavku`);
}
}
}
/**
* Ověří přítomnost (not null) předaných parametrů v těle requestu.
* V případě nepřítomnosti kteréhokoli parametru vyhodí chybu.
*
* @param req request
* @param paramNames pole názvů požadovaných parametrů
*/
export const checkBodyParams = (req: any, paramNames: string[]) => {
for (const name of paramNames) {
if (req.body[name] == null) {
throw Error(`Nebyl předán parametr '${name}' v těle požadavku`);
}
}
}
// TODO umístit do samostatného souboru
export class InsufficientPermissions extends Error { }
export const getUsersByLocation = (choices: LunchChoices, login?: string): string[] => {
const result: string[] = [];
for (const location of Object.entries(choices)) {
const locationKey = location[0] as LunchChoice;
const locationValue = location[1];
if (login && locationValue[login]) {
for (const username in choices[locationKey]) {
if (choices[locationKey].hasOwnProperty(username)) {
result.push(username);
}
}
break;
}
}
return result;
const dayName = date.toLocaleDateString("CZ-cs", { weekday: 'long' }).toLowerCase()
return dayName === 'sobota' || dayName === 'neděle'
}

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