1 Commits

Author SHA1 Message Date
6a79e7989b Zbavení se duplicitních Typescript typů 2023-07-09 19:17:20 +02:00
137 changed files with 21557 additions and 13980 deletions

24
.gitignore vendored
View File

@@ -1,2 +1,24 @@
# 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
dist
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

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,40 +1,81 @@
# 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ů.
- api
- společné Typescript definice, pro objekty posílané mezi serverem a klientem
- 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
#### 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
- [Docker](https://www.docker.com)
- [Docker Compose](https://docs.docker.com/compose)
### Spuštění
### Spuštění s nginx
- `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
- [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
- [ ] Zobrazit upozornění před smazáním/zamknutím/odemknutím pizza day
- [x] Umožnit přidat k objednávce poznámku (např. "bez oliv")
- [x] Negenerovat QR kód pro objednávajícího
- [ ] 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í
- [ ] 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 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
- [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ě
- [ ] 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
- [ ] Skripty pro snadné spuštění vývoje na Windows (ekvivalent ./run_dev.sh)
- [ ] Možnost náhledu na jiné dny v týdnu (např. pomocí šipek)
- [ ] Možnost zadat si oběd dopředu na následující dny v týdnu
- [x] Zbavit se Food API, potřebnou funkcionalitu zahrnout do serveru
- [ ] Vyřešit API mezi serverem a klientem, aby nebyl v obou projektech duplicitní kód (viz types.ts a Types.tsx)
- [ ] Mazat z databáze předchozí dny, aktuálně je to k ničemu
- [ ] 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, stačilo by to jednou týdně ("ID" by mohlo být číslo týdne v roce, místo celého datumu)
- [ ] 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)')

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ů

1
api/README.md Normal file
View File

@@ -0,0 +1 @@
# Společné Typescript definice serveru a klienta

14
api/package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "@luncher/api",
"version": "1.0.0",
"main": "src/index.ts",
"scripts": {
"build": "yarn clean && tsc -p .",
"build:watch": "tsc -p . --watch",
"clean": "rm -rf dist"
},
"types": "dist/index.d.ts",
"devDependencies": {
"typescript": "^5.0.2"
}
}

106
api/src/Types.ts Normal file
View File

@@ -0,0 +1,106 @@
/** Výčtový typ pro restaurace, pro které umíme získat a parsovat obědové menu. */
export enum Restaurants {
SLADOVNICKA = 'sladovnicka',
UMOTLIKU = 'uMotliku',
TECHTOWER = 'techTower',
}
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
hasQr?: boolean, // true, pokud je k objednávce vygenerován QR kód pro platbu
note?: string, // volitelná uživatelská poznámka k objednávce
}
/** 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
}
/** Jídlo z obědového menu restaurace. */
export interface Food {
amount?: string, // množství standardní porce, např. 0,33l nebo 150g
name: string, // název/popis jídla
price: string, // cena ve formátu '135 Kč'
isSoup: boolean, // příznak, zda se jedná o polévku
}
export enum Locations {
SLADOVNICKA = 'Sladovnická',
UMOTLIKU = 'U Motlíků',
TECHTOWER = 'TechTower',
SPSE = 'SPŠE',
PIZZA = 'Pizza day',
OBJEDNAVAM = 'Budu objednávat',
NEOBEDVAM = 'Mám vlastní/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,
}
export interface GotifyServer {
server: string;
api_keys: string[];
}

1
api/src/index.ts Normal file
View File

@@ -0,0 +1 @@
export * as Types from './Types';

9
api/tsconfig.json Normal file
View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"declaration": true,
"outDir": "./dist"
},
"include": [
"src/**/*"
]
}

8
api/yarn.lock Normal file
View File

@@ -0,0 +1,8 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
typescript@^5.0.2:
version "5.1.6"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.1.6.tgz#02f8ac202b6dad2c0dd5e0913745b47a37998274"
integrity sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==

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://127.0.0.1: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 .
EXPOSE 3000
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,44 @@
{
"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",
"@luncher/api": "1.0.0",
"react": "^18.2.0",
"react-bootstrap": "^2.7.2",
"react-dom": "^18.2.0",
"react-jwt": "^1.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 +54,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

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

@@ -0,0 +1,81 @@
import { PizzaOrder } from "@luncher/api/dist/Types";
import { getBaseUrl, getToken } from "./Utils";
async function request<TResponse>(
url: string,
config: RequestInit = {}
): Promise<TResponse> {
config.headers = config?.headers ? new Headers(config.headers) : new Headers();
config.headers.set("Authorization", `Bearer ${getToken()}`);
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 getQrUrl = (login: string) => {
return `${getBaseUrl()}/api/qr?login=${login}`;
}
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 () => {
return await api.post<any, any>('/api/createPizzaDay', undefined);
}
export const deletePizzaDay = async () => {
return await api.post<any, any>('/api/deletePizzaDay', undefined);
}
export const lockPizzaDay = async () => {
return await api.post<any, any>('/api/lockPizzaDay', undefined);
}
export const unlockPizzaDay = async () => {
return await api.post<any, any>('/api/unlockPizzaDay', undefined);
}
export const finishOrder = async () => {
return await api.post<any, any>('/api/finishOrder', undefined);
}
export const finishDelivery = async (bankAccount?: string, bankAccountHolder?: string) => {
return await api.post<any, any>('/api/finishDelivery', JSON.stringify({ bankAccount, bankAccountHolder }));
}
export const updateChoice = async (choice: number | null) => {
return await api.post<any, any>('/api/updateChoice', JSON.stringify({ choice }));
}
export const addPizza = async (pizzaIndex: number, pizzaSizeIndex: number) => {
return await api.post<any, any>('/api/addPizza', JSON.stringify({ pizzaIndex, pizzaSizeIndex }));
}
export const removePizza = async (pizzaOrder: PizzaOrder) => {
return await api.post<any, any>('/api/removePizza', JSON.stringify({ pizzaOrder }));
}
export const updateNote = async (note?: string) => {
return await api.post<any, any>('/api/updateNote', JSON.stringify({ note }));
}
export const login = async (login: string) => {
return await api.post<any, any>('/api/login', JSON.stringify({ login }));
}

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

@@ -0,0 +1,82 @@
.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;
}
.qr-code {
text-align: center;
}
.select-search-container {
margin: auto;
}

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,65 @@
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, getQrUrl, lockPizzaDay, removePizza, unlockPizzaDay, updateChoice, updateNote } from './Api';
import { useAuth } from './context/auth';
import Login from './Login';
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, { SelectedOptionValue } 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';
import { useBank } from './context/bank';
import { ClientData, Restaurants, Food, Pizza, Order, Locations, PizzaOrder, PizzaDayState } from '@luncher/api/dist/Types';
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 bank = useBank();
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<{ [key in Restaurants]: Food[] }>();
const [pizzy, setPizzy] = useState<Pizza[]>();
const [myOrder, setMyOrder] = useState<Order>();
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);
const poznamkaRef = useRef<HTMLInputElement>(null);
// Načtení dat po přihlášení
useEffect(() => {
if (!auth?.login) {
if (!auth || !auth.login) {
return
}
getData().then(response => {
const data = response.data
if (data) {
getPizzy().then(pizzy => {
setPizzy(pizzy);
});
getData().then(data => {
setData(data);
const dayIndex = getDayOfWeekIndex(new Date(data.date));
setDayIndex(dayIndex);
dayIndexRef.current = dayIndex;
setFood(data.menus);
}
}).catch(e => {
setFailure(true);
})
getFood().then(food => {
setFood(food);
})
}, [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;
setData(data);
if (data) {
setFood(data.menus);
}
}).catch(e => {
setFailure(true);
})
}, [dayIndex, auth]);
// 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) => {
// 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);
}
});
return () => {
@@ -144,19 +70,18 @@ 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)) {
const value = entry[0] as any as number; // TODO tohle je absurdní
choiceRef.current.value = Object.values(Locations)[value];
}
}
}
}, [auth, auth?.login, data?.choices])
// Reference na mojí objednávku
@@ -167,135 +92,28 @@ function App() {
}
}, [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
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: location, foodIndex, dayIndex } });
}
}
}
const doAddChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => {
const locationKey = event.target.value as LunchChoice;
if (auth?.login) {
await addChoice({ body: { locationKey, dayIndex } });
if (foodChoiceRef.current?.value) {
foodChoiceRef.current.value = "";
}
choiceRef.current?.blur();
await updateChoice(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(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 +123,30 @@ 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') {
if (auth?.login && pizzy) {
if (!(value instanceof String)) {
throw Error('Nepodporovaný typ hodnoty');
}
const s = value.split('|');
const pizzaIndex = Number.parseInt(s[0]);
const pizzaSizeIndex = Number.parseInt(s[1]);
await addPizza({ body: { pizzaIndex, pizzaSizeIndex } });
await addPizza(pizzaIndex, pizzaSizeIndex);
}
}
const handlePizzaDelete = async (pizzaOrder: PizzaVariant) => {
await removePizza({ body: { pizzaOrder } });
const handlePizzaDelete = async (pizzaOrder: PizzaOrder) => {
await removePizza(pizzaOrder);
}
const handlePizzaPoznamkaChange = async () => {
if (pizzaPoznamkaRef.current?.value && pizzaPoznamkaRef.current.value.length > 70) {
alert("Poznámka může mít maximálně 70 znaků");
const handlePoznamkaChange = async () => {
if (poznamkaRef.current?.value && poznamkaRef.current.value.length > 100) {
alert("Poznámka může mít maximálně 100 znaků");
return;
}
updatePizzaDayNote({ body: { note: pizzaPoznamkaRef.current?.value } });
updateNote(poznamkaRef.current?.value);
}
// const addToCart = async () => {
@@ -355,264 +173,98 @@ 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: string, food: 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 dayIndex={dayIndex} />
<>
<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>Zavedení JWT, přesun autentizace na server</li>
<li>Oprava pádu v případě, že ve Sladovnické daný den nic není</li>
<li>Podpora situace, kdy ve Sladovnické je méně než 3 hlavní jídla</li>
<li>Oprava parsování cen z TechTower v případě použití nedělitelných mezer</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[Restaurants.SLADOVNICKA])}
{renderFoodTable('U Motlíků', food[Restaurants.UMOTLIKU])}
{renderFoodTable('TechTower', food[Restaurants.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}>
<p>Jak to dnes vidíš s obědem?</p>
<Form.Select ref={choiceRef} onChange={changeChoice}>
<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>)}
<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.PIZZA}>Pizza day</option>
<option value={Locations.OBJEDNAVAM}>Budu objednávat (mimo pizzu)</option>
<option value={Locations.NEOBEDVAM}>Mám vlastní/neobědvám</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 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]}>
{Object.keys(data.choices).map((key: string, index: number) =>
<tr key={index}>
<td>{Object.values(Locations)[Number(key)]}</td>
<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>
}
{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>
)}
</tbody>
</Table>
</ul>
</td>
</tr>)
}
</tr>
)}
</tbody>
</Table>
: <div className='mt-5'><i>Zatím nikdo nehlasoval...</i></div>
}
</div>
{dayIndex === todayDayIndex &&
<div className='mt-5'>
{!data.pizzaDay &&
<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));
await createPizzaDay();
}}>Založit Pizza day</Button>
<Button onClick={doJdemeObed} style={{ marginLeft: "14px" }}>Jdeme na oběd !</Button>
</>
}
</div>
}
{data.pizzaDay &&
@@ -648,6 +300,9 @@ function App() {
<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' }} 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();
}}>Objednáno</Button>
@@ -665,7 +320,7 @@ function App() {
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 } });
await finishDelivery(bank?.bankAccount, bank?.holderName);
}}>Doručeno</Button>
</div>
}
@@ -674,7 +329,7 @@ function App() {
{
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>
<p>Pizzy byly doručeny. Objednávku můžete uhradit pomocí QR kódu níže.</p>
</div>
}
</div>
@@ -685,45 +340,37 @@ function App() {
options={pizzaSuggestions}
placeholder='Vyhledat pizzu...'
onChange={handlePizzaChange}
onBlur={_ => { }}
onFocus={_ => { }}
/>
Poznámka: <input ref={pizzaPoznamkaRef} className='mt-3' type="text" onKeyDown={event => {
Poznámka: <input ref={poznamkaRef} className='mt-3' type="text" onKeyDown={event => {
if (event.key === 'Enter') {
handlePizzaPoznamkaChange();
handlePoznamkaChange();
}
event.stopPropagation();
}} />
<Button
style={{ marginLeft: '20px' }}
disabled={!myOrder?.pizzaList?.length}
onClick={handlePizzaPoznamkaChange}>
onClick={handlePoznamkaChange}>
Uložit
</Button>
</div>
}
<PizzaOrderList state={data.pizzaDay.state!} orders={data.pizzaDay.orders!} onDelete={handlePizzaDelete} creator={data.pizzaDay.creator!} />
<PizzaOrderList state={data.pizzaDay.state} orders={data.pizzaDay.orders} onDelete={handlePizzaDelete} />
{
data.pizzaDay.state === PizzaDayState.DELIVERED && myOrder?.hasQr ?
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
}
<div>Částka: {myOrder.totalPrice} </div>
<img src={getQrUrl(auth.login)} alt='QR kód' />
<p>Generování QR kódů je v experimentální fázi - doporučujeme si překontrolovat údaje před odesláním platby.</p>
</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,7 @@
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 } from './Api';
import './Login.css';
/**
@@ -11,31 +11,18 @@ 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 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
// TODO odchytávat cokoliv mimo 200
const token = await login(loginRef.current.value);
if (token) {
auth?.setToken(token);
}
}
}, [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>

View File

@@ -1,4 +1,14 @@
import { DepartureTime } from "../../types";
/**
* Vrátí kořenovou URL serveru na základě aktuálního prostředí (vývojovou či produkční).
*
* @returns kořenová URL serveru
*/
export const getBaseUrl = (): string => {
if (process.env.PUBLIC_URL) {
return process.env.PUBLIC_URL;
}
return 'http://127.0.0.1:3001';
}
const TOKEN_KEY = "token";
@@ -16,8 +26,8 @@ export const storeToken = (token: string) => {
*
* @returns token nebo null
*/
export const getToken = (): string | undefined => {
return localStorage.getItem(TOKEN_KEY) ?? undefined;
export const getToken = (): string | null => {
return localStorage.getItem(TOKEN_KEY);
}
/**
@@ -26,87 +36,3 @@ export const getToken = (): string | undefined => {
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}`;
}
}
/**
* 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;
}
/**
* 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í 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}`;
}

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,57 +1,22 @@
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 GenerateQRModal from "./modals/GenerateQRModal";
import { useNavigate } from "react-router";
import { STATS_URL } from "../AppRoutes";
import { FeatureRequest, getVotes, updateVote } from "../../../types";
import { useBank } from "../context/bank";
type Props = {
dayIndex?: number;
}
export default function Header({ dayIndex }: Readonly<Props>) {
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 [generateQRModalOpen, setGenerateQRModalOpen] = 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 closePizzaModal = () => {
setPizzaModalOpen(false);
}
const closeRefreshMenuModal = () => {
setRefreshMenuModalOpen(false);
}
const closeGenerateQRModal = () => {
setGenerateQRModalOpen(false);
const closeModal = () => {
setModalOpen(false);
}
const isValidInteger = (str: string) => {
@@ -64,14 +29,14 @@ export default function Header({ dayIndex }: Readonly<Props>) {
return n !== Infinity && String(n) === str && n >= 0;
}
const saveSettings = (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean) => {
if (bankAccountNumber) {
const save = () => {
if (bankAccountRef.current?.value) {
try {
// Validace kódu banky
if (bankAccountNumber.indexOf('/') < 0) {
if (bankAccountRef.current?.value.indexOf('/') < 0) {
throw Error("Číslo účtu neobsahuje lomítko/kód banky")
}
const split = bankAccountNumber.split("/");
const split = bankAccountRef.current?.value.split("/");
if (split[1].length !== 4) {
throw Error("Kód banky musí být 4 číslice")
}
@@ -92,7 +57,7 @@ export default function Header({ dayIndex }: Readonly<Props>) {
cislo = cislo.padStart(16, '0');
}
let sum = 0;
for (let i = 0; i < cislo.length; i++) {
for (var i = 0; i < cislo.length; i++) {
const char = cislo.charAt(i);
const order = (cislo.length - 1) - i;
const weight = (2 ** order) % 11;
@@ -106,44 +71,39 @@ export default function Header({ dayIndex }: Readonly<Props>) {
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);
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={() => setGenerateQRModalOpen(true)}>Generování QR</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={openBankSettings}>Nastavit číslo účtu</NavDropdown.Item>
<NavDropdown.Item onClick={auth?.logout}>Odhlásit se</NavDropdown.Item>
</NavDropdown>
</Nav>
</Navbar.Collapse>
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
<GenerateQRModal isOpen={generateQRModalOpen} onClose={closeGenerateQRModal} dayIndex={dayIndex} bankAccount={settings?.bankAccount} bankAccountHolder={settings?.holderName} />
<FeaturesVotingModal isOpen={votingModalOpen} onClose={closeVotingModal} onChange={saveFeatureVote} initialValues={featureVotes} />
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
<Modal show={modalOpen} onHide={closeModal} size="lg">
<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 />Čí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={bank?.bankAccount} /> <br />
Název příjemce (jméno majitele účtu): <input ref={nameRef} type="text" placeholder="Jan Novák" defaultValue={bank?.holderName} />
</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,24 +1,18 @@
import React from "react";
import { Table } from "react-bootstrap";
import PizzaOrderRow from "./PizzaOrderRow";
import { PizzaDayState, PizzaOrder, PizzaVariant, updatePizzaFee } from "../../../types";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrashCan } from "@fortawesome/free-regular-svg-icons";
import { useAuth } from "../context/auth";
import { Order, PizzaDayState, PizzaOrder } from "@luncher/api/dist/Types";
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: PizzaDayState, 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>
}
const total = orders.reduce((total, order) => total + order.totalPrice, 0);
const total = orders.map(order => order.pizzaList.map(o => o.price).reduce((total, i) => total + i)).reduce((total, i) => total + i);
return <Table className="mt-3" striped bordered hover>
<thead>
@@ -26,16 +20,28 @@ export default function PizzaOrderList({ state, orders, onDelete, creator }: Rea
<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 === PizzaDayState.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.note || '-'}</td>
<td>{order.totalPrice} </td>
</tr>)}
<tr style={{ fontWeight: 'bold' }}>
<td colSpan={4}>Celkem</td>
<td colSpan={3}>Celkem</td>
<td>{`${total}`}</td>
</tr>
</tbody>

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,176 +0,0 @@
import { useEffect, useState } from "react";
import { Modal, Button, Table, Form, Alert } from "react-bootstrap";
import { ClientData, generateQr, getData } from "../../../../types";
type Props = {
isOpen: boolean,
onClose: () => void,
dayIndex?: number,
bankAccount?: string,
bankAccountHolder?: string,
}
type UserQRData = {
login: string;
selected: boolean;
note: string;
amount: string;
}
/** Modální dialog pro generování QR kódů. */
export default function GenerateQRModal({ isOpen, onClose, dayIndex, bankAccount, bankAccountHolder }: Readonly<Props>) {
const [users, setUsers] = useState<UserQRData[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const isBankDataValid = bankAccount && bankAccountHolder;
useEffect(() => {
if (isOpen) {
setLoading(true);
getData({ query: { dayIndex } }).then(response => {
const data: ClientData = response.data;
const userList: UserQRData[] = [];
// Projdeme všechny volby stravování a získáme uživatele
if (data.choices) {
Object.entries(data.choices).forEach(([locationKey, locationUsers]) => {
Object.keys(locationUsers).forEach(login => {
// Přidáme uživatele pouze pokud tam ještě není
if (!userList.find(u => u.login === login)) {
userList.push({
login,
selected: false,
note: '',
amount: ''
});
}
});
});
}
setUsers(userList);
setLoading(false);
}).catch(() => {
setLoading(false);
});
}
}, [isOpen, dayIndex]);
const handleCheckboxChange = (login: string) => {
setUsers(users.map(u =>
u.login === login ? { ...u, selected: !u.selected } : u
));
};
const handleNoteChange = (login: string, note: string) => {
setUsers(users.map(u =>
u.login === login ? { ...u, note } : u
));
};
const handleAmountChange = (login: string, amount: string) => {
setUsers(users.map(u =>
u.login === login ? { ...u, amount } : u
));
};
const handleGenerate = async () => {
const selectedUsers = users.filter(u => u.selected);
// TODO: Implementovat generování QR kódů
console.log('Generování QR pro:', selectedUsers);
alert('Funkce generování QR bude implementována');
await generateQr({
body: {
bankAccount: bankAccount!,
bankAccountHolder: bankAccountHolder!,
qrCodes: selectedUsers.map(u => ({
login: u.login,
des: u.note,
amount: Number.parseFloat(u.amount)
}))
},
})
};
const handleClose = () => {
setUsers([]);
onClose();
};
return (
<Modal show={isOpen} onHide={handleClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Generování QR kódů</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{!isBankDataValid && (
<Alert variant="warning">
<strong>Upozornění:</strong> Pro generování QR kódů je nutné mít v nastavení vyplněné číslo bankovního účtu a jméno majitele účtu.
</Alert>
)}
{loading ? (
<p>Načítání uživatelů...</p>
) : users.length === 0 ? (
<p>Pro aktuální den nemá žádný uživatel vybranou volbu stravování.</p>
) : (
<Table striped bordered hover>
<thead>
<tr>
<th style={{ width: '50px' }}></th>
<th>Uživatel</th>
<th>Poznámka</th>
<th style={{ width: '120px' }}>Částka ()</th>
</tr>
</thead>
<tbody>
{users.map(user => (
<tr key={user.login}>
<td className="text-center">
<Form.Check
type="checkbox"
checked={user.selected}
onChange={() => handleCheckboxChange(user.login)}
/>
</td>
<td>{user.login}</td>
<td>
<Form.Control
type="text"
value={user.note}
onChange={(e) => handleNoteChange(user.login, e.target.value)}
placeholder="Poznámka"
disabled={!user.selected}
/>
</td>
<td>
<Form.Control
type="number"
step="0.01"
min="0"
value={user.amount}
onChange={(e) => handleAmountChange(user.login, e.target.value)}
placeholder="0.00"
disabled={!user.selected}
/>
</td>
</tr>
))}
</tbody>
</Table>
)}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={handleClose}>
Zavřít
</Button>
<Button
variant="primary"
onClick={handleGenerate}
disabled={users.filter(u => u.selected).length === 0 || !isBankDataValid}
>
Generovat
</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,10 +1,10 @@
import React, { ReactNode, useContext, useEffect, useState } from "react"
import React, { ReactNode, useContext, useState } from "react"
import { useEffect } from "react"
import { useJwt } from "react-jwt";
import { deleteToken, getToken, storeToken } from "../Utils";
export type AuthContextProps = {
login?: string,
trusted?: boolean,
setToken: (name: string) => void,
logout: () => void,
}
@@ -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,9 +26,8 @@ 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 ?? '');
const [token, setToken] = useState<string | null>(getToken());
const { decodedToken } = useJwt(token || '');
useEffect(() => {
if (token && token.length > 0) {
@@ -41,27 +40,18 @@ function useProvideAuth(): AuthContextProps {
useEffect(() => {
if (decodedToken) {
setLoginName((decodedToken as any).login);
setTrusted((decodedToken as any).trusted);
} else {
setLoginName(undefined);
setTrusted(undefined);
}
}, [decodedToken]);
function logout() {
const trusted = (decodedToken as any).trusted;
const logoutUrl = (decodedToken as any).logoutUrl;
setToken(undefined);
setToken(null);
setLoginName(undefined);
setTrusted(undefined);
if (trusted && logoutUrl?.length) {
window.location.replace(logoutUrl);
}
}
return {
login: loginName,
trusted,
setToken,
logout,
}

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 />
<ProvideBank>
<SocketContext.Provider value={socket}>
<App />
<ToastContainer />
</SocketContext.Provider>
</ProvideBank>
</ProvideAuth>
</BrowserRouter>
</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 dayIndex={undefined} />
<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,12 +13,10 @@
"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"
}

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,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

18
docker-compose.yml Normal file
View File

@@ -0,0 +1,18 @@
version: '3.8'
services:
server:
build:
context: ./server
client:
build:
context: ./client
nginx:
depends_on:
- server
- client
restart: always
build:
context: ./nginx
ports:
- 3005:80

2
nginx/Dockerfile Normal file
View File

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

37
nginx/default.conf Normal file
View File

@@ -0,0 +1,37 @@
upstream client {
server client:3000;
}
upstream server {
server server:3001;
}
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 {
proxy_pass http://server;
}
}

8
package.json Normal file
View File

@@ -0,0 +1,8 @@
{
"private": true,
"workspaces": [
"api",
"server",
"client"
]
}

View File

@@ -1,18 +1,6 @@
#!/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
yarn install
cd api && yarn build &
cd server && yarn start &
cd client && yarn start &
wait

View File

@@ -1,19 +1,6 @@
# Secret pro podepisování JWT tokenů. Minimální délka 32 znaků.
# JWT_SECRET='CHANGE_ME'
# 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.
# 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.
@@ -24,17 +11,3 @@
# 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

18
server/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
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
EXPOSE 3001
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

@@ -6,33 +6,26 @@
"private": true,
"scripts": {
"start": "ts-node src/index.ts",
"startReload": "nodemon --watch src src/index.ts",
"build": "tsc -p .",
"test": "jest"
"startReload": "nodemon src/index.ts",
"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/jsonwebtoken": "^9.0.2",
"@types/node": "^20.2.5",
"@types/request-promise": "^4.1.48",
"babel-jest": "^30.2.0",
"jest": "^30.2.0",
"nodemon": "^3.1.10",
"nodemon": "^2.0.22",
"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",
"dotenv": "^16.1.3",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.0",
"redis": "^5.9.0",
"@luncher/api": "1.0.0",
"simple-json-db": "^2.0.0",
"socket.io": "^4.6.1"
}

View File

@@ -4,10 +4,9 @@ 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 {
export function generateToken(login?: string): string {
if (!process.env.JWT_SECRET) {
throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
}
@@ -17,8 +16,7 @@ export function generateToken(login?: string, trusted?: boolean): string {
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);
return jwt.sign({ login }, process.env.JWT_SECRET);
}
/**
@@ -53,19 +51,3 @@ export function getLogin(token?: string): string {
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,6 +1,9 @@
import os from 'os';
import path from 'path';
import fs from 'fs';
import axios from 'axios';
import { load } from 'cheerio';
import { getPizzaListMock } from './mock';
import { formatDate } from './utils';
// TODO přesunout do types
type PizzaSize = {
@@ -36,29 +39,23 @@ 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 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 +82,25 @@ 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 dateStr = formatDate(date_ob);
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,90 +1,65 @@
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, updateNote } from "./service";
import dotenv from 'dotenv';
import path from 'path';
import { getMenuSladovnicka, getMenuTechTower, getMenuUMotliku } from "./restaurants";
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 debugRoutes from "./routes/debugRoutes";
import qrRoutes from "./routes/qrRoutes";
import { generateToken, getLogin, verify } from "./auth";
import { Restaurants } from "@luncher/api/dist/Types";
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 parseToken = (req: any) => {
if (req?.headers?.authorization) {
return req.headers.authorization.split(' ')[1];
}
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));
res.send(req.header('remote-user'));
})
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 ??");
const remoteUser = req.header('remote-user');
if (remoteUser && remoteUser.length > 0) {
res.status(200).json(generateToken(remoteUser));
return;
}
} 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));
}
res.status(200).json(generateToken(req.body.login));
});
// TODO dočasné řešení - QR se zobrazuje přes <img>, nemáme sem jak dostat token
app.get("/api/qr", (req, res) => {
// const login = getLogin(parseToken(req));
if (!req.query?.login) {
throw Error("Nebyl předán login");
}
@@ -98,26 +73,10 @@ app.get("/api/qr", (req, res) => {
// ----------------------------------------------------
// Přeskočení auth pro refresh dat xd
app.use("/api/food/refresh", refreshMetoda);
app.use("/api/debug", debugRoutes);
/** 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);
}
}
app.use((req, res, next) => {
if (req.header('remote-user')) {
console.log("Tvuj username: %s.", req.header('remote-user'));
}
if (!req.headers.authorization) {
return res.status(401).json({ error: 'Nebyl předán autentizační token' });
@@ -130,47 +89,140 @@ app.use("/api/", (req, res, 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));
app.get("/api/data", (req, res) => {
res.status(200).json(getData());
});
// 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("/api/qr", qrRoutes);
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 })
/** Vrátí obědové menu pro dostupné podniky. */
app.get("/api/food", async (req, res) => {
const mock = !!process.env.MOCK_DATA;
const date = new Date();
const data = {
[Restaurants.SLADOVNICKA]: await getMenuSladovnicka(date, mock),
[Restaurants.UMOTLIKU]: await getMenuUMotliku(date, mock),
[Restaurants.TECHTOWER]: await getMenuTechTower(date, mock),
}
next();
res.status(200).json(data);
});
const PORT = process.env.PORT ?? 3001;
const HOST = process.env.HOST ?? '0.0.0.0';
/** 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) => {
const login = getLogin(parseToken(req));
const data = createPizzaDay(login);
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) => {
const login = getLogin(parseToken(req));
const data = deletePizzaDay(login);
io.emit("message", data);
});
app.post("/api/addPizza", (req, 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;
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(login, pizzy[pizzaIndex], pizzy[pizzaIndex].sizes[pizzaSizeIndex]);
io.emit("message", data);
res.status(200).json({});
})
});
app.post("/api/removePizza", (req, res) => {
const login = getLogin(parseToken(req));
if (!req.body?.pizzaOrder) {
throw Error("Nebyla předána objednávka");
}
const data = removePizzaOrder(login, req.body?.pizzaOrder);
io.emit("message", data);
res.status(200).json({});
});
app.post("/api/lockPizzaDay", (req, res) => {
const login = getLogin(parseToken(req));
const data = lockPizzaDay(login);
io.emit("message", data);
res.status(200).json({});
});
app.post("/api/unlockPizzaDay", (req, res) => {
const login = getLogin(parseToken(req));
const data = unlockPizzaDay(login);
io.emit("message", data);
res.status(200).json({});
});
app.post("/api/finishOrder", (req, res) => {
const login = getLogin(parseToken(req));
const data = finishPizzaOrder(login);
io.emit("message", data);
res.status(200).json({});
});
app.post("/api/finishDelivery", (req, res) => {
const login = getLogin(parseToken(req));
const data = finishPizzaDelivery(login, req.body.bankAccount, req.body.bankAccountHolder);
io.emit("message", data);
res.status(200).json({});
});
app.post("/api/updateChoice", (req, res) => {
const login = getLogin(parseToken(req));
const data = updateChoice(login, req.body.choice);
io.emit("message", data);
res.status(200).json(data);
});
app.post("/api/updateNote", (req, res) => {
const login = getLogin(parseToken(req));
if (req.body.note && req.body.note.length > 100) {
throw Error("Poznámka může mít maximálně 100 znaků");
}
const data = updateNote(login, req.body.note);
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}`);
});
});
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,71 @@
import axios from 'axios';
/** Notifikace pro gotify*/
import { GotifyServer, NotififaceInput, NotifikaceData } from '@luncher/api/dist/Types';
import axios, { AxiosError } 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';
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
const ENVIRONMENT = process.env.NODE_ENV || 'production'
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
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)
const gotifyDataRaw = process.env.GOTIFY_SERVERS_AND_KEYS || "{}";
const gotifyData: GotifyServer[] = JSON.parse(gotifyDataRaw);
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'
export const gotifyCall = async (data: NotififaceInput, gotifyServers?: GotifyServer[]): Promise<any[]> => {
if (!Array.isArray(gotifyServers)) {
return []
}
});
console.log(response.data);
} catch (error) {
console.error(error);
}
})
const urls = gotifyServers.flatMap(gotifyServer =>
gotifyServer.api_keys.map(apiKey => `${gotifyServer.server}/message?token=${apiKey}`));
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 dataPayload = {
title: "Luncher",
message: `${data.udalost} - spustil:${data.user}`,
priority: 7,
};
if (!url) {
console.log("TEAMS_WEBHOOK_URL není definován v env")
return
}
const headers = { "Content-Type": "application/json" };
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 promises = urls.map(url =>
axios.post(url, dataPayload, { headers }).then(response => {
response.data = {
success: true,
message: "Notifikace doručena",
};
return response;
}).catch(error => {
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError;
if (axiosError.response) {
axiosError.response.data = {
success: false,
message: "fail",
};
console.log(error)
return axiosError.response;
}
}
}
// Handle unknown error without a response
console.log(error, "unknown error");
})
);
return promises;
};
/** 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) {
const gotifyPromises = await gotifyCall(input, gotifyData);
notifications.push(...gotifyPromises);
}
/* 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,33 +1,19 @@
import axios from "axios";
import os from "os";
import path from "path";
import fs from "fs";
import { load } from 'cheerio';
import { getMenuSladovnickaMock, getMenuTechTowerMock, getMenuUMotlikuMock, getMenuZastavkaUmichalaMock, getMenuSenkSerikovaMock } from "./mock";
import { formatDate } from "./utils";
import { Food } from "../../types/gen/types.gen";
import { Food } from "@luncher/api/dist/Types";
// 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 SOUP_NAMES = ['polévka', 'česnečka', 'česnekový krém', 'cibulačka', 'vývar']
const DAYS_IN_WEEK = ['pondělí', 'úterý', 'středa', 'čtvrtek', 'pátek', 'sobota', 'neděle'];
// URL na týdenní menu jednotlivých restaurací
const SLADOVNICKA_URL = 'https://sladovnicka.unasplzenchutna.cz/cz/#denni-nabidka';
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.
@@ -50,213 +36,220 @@ const capitalize = (word: string): string => {
}
const sanitizeText = (text: string): string => {
return text.replace('\t', '').replace(' , ', ', ').trim();
return text.replace('\t', '').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.
* Vrátí index dne v týdnu, kde pondělí=0, neděle=6
*
* @param name původní název jídla
* @returns objekt obsahující vyčištěný název a pole alergenů
* @param date datum
* @returns index dne v týdnu
*/
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: [] };
const getDayOfWeekIndex = (date: Date) => {
// https://stackoverflow.com/a/4467559
return (((date.getDay() - 1) % 7) + 7) % 7;
}
/**
* Stáhne a vrátí aktuální HTML z dané URL.
* Stáhne (v případě potřeby) a vrátí HTML z dané URL pro předané datum.
* Pokud je pro dané datum již staženo, vrátí jeho obsah ze souboru.
*
* @param url URL pro stažení
* @returns stažené HTML
* @param prefix prefix pro uložení do souboru
* @param date datum ke kterému stáhnout HTML
* @returns stažené HTML, nebo HTML ze souborové cache
*/
const getHtml = async (url: string): Promise<any> => {
return await axios.get(url).then(res => res.data).then(content => content);
const getHtml = async (url: string, prefix: string, date: Date): Promise<Buffer> => {
const fileName = path.join(os.tmpdir(), `${prefix}_${formatDate(date)}.html`);
if (!fs.existsSync(fileName)) {
await axios.get(url).then(res => res.data).then(content => {
fs.writeFileSync(fileName, content);
});
}
return fs.readFileSync(fileName);
}
/**
* Získá obědovou nabídku Sladovnické pro jeden týden.
* Získá obědovou nabídku Sladovnické pro předané datum.
*
* @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 date datum, 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[][]> => {
export const getMenuSladovnicka = async (date: Date = new Date(), mock: boolean = false): Promise<Food[]> => {
if (mock) {
return getMenuUMotlikuMock();
return [
{
amount: "0,25l",
name: "Zelná polévka s klobásou",
price: "35 Kč",
isSoup: true,
},
{
amount: "150g",
name: "Hovězí na česneku s bramborovým knedlíkem",
price: "135 Kč",
isSoup: false,
},
{
amount: "250g",
name: "Přírodní holandský řízek s bramborovou kaší, rajčatový salát",
price: "135 Kč",
isSoup: false,
},
{
amount: "350g",
name: "Bagel s vinnou klobásou, cibulový konfit, kysané zelí, slanina a hořčicová mayo, hranolky, curry omáčka",
price: "135 Kč",
isSoup: false,
}
const html = await getHtml(U_MOTLIKU_URL);
]
}
const todayDayIndex = getDayOfWeekIndex(date);
if (todayDayIndex == 5 || todayDayIndex == 6) { // Víkend
return [];
}
const html = await getHtml(SLADOVNICKA_URL, 'sladovnicka', date);
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;
// Najdeme index pro vstupní datum (např. při svátcích bude posunutý)
// TODO validovat, že vstupní datum je v aktuálním týdnu
// TODO tenhle způsob je zbytečně komplikovaný - stačilo by hledat rovnou v div.tab-content, protože každý den tam má datum taky (akorát je print-only)
const list = $('ul.tab-links').children();
const searchedDayText = `${date.getDate()}.${date.getMonth() + 1}.${capitalize(DAYS_IN_WEEK[todayDayIndex])}`;
let index = undefined;
list.each((i, dayRow) => {
const rowText = $(dayRow).first().text().trim();
if (rowText === searchedDayText) {
index = i;
return;
}
}
if (usedTable != null) {
break;
}
usedDate.setDate(usedDate.getDate() + 1);
})
if (index === undefined) {
// Pravděpodobně svátek, nebo je zavřeno
return [{
amount: undefined,
name: "Pro daný den nebyla nalezena denní nabídka",
price: "",
isSoup: false,
}];
}
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}`);
// Dle dohledaného indexu najdeme správný tabpanel
const rows = $('div.tab-content').children();
if (index >= rows.length) {
throw Error("V HTML nebyl nalezen řádek menu pro index " + index);
}
const tabPanel = $(rows.get(index));
// Opětovná validace, že daný tabpanel je pro vstupní datum
const headers = tabPanel.find('h2');
if (headers.length !== 3) {
throw Error("Neočekávaný počet elementů h2 v menu pro datum " + searchedDayText + ", očekávány 3, ale nalezeno bylo " + headers.length);
}
const dayText = $(headers.get(0)).text().trim();
if (dayText !== searchedDayText) {
throw Error("Neočekávaný datum na řádce nalezeného dne: '" + dayText + "', ale očekáváno bylo '" + searchedDayText + "'");
}
const body = usedTable.children().first();
// V tabpanelu očekáváme dvě tabulky - pro polévku a pro hlavní jídlo
const tables = tabPanel.find('table');
if (tables.length !== 2) {
throw Error("Neočekávaný počet tabulek na řádce nalezeného dne: " + tables.length + ", ale očekávány byly 2");
}
const results: Food[] = [];
// Polévka - div -> table -> tbody -> tr -> 3x td
const soupCells = $(tables.get(0)).children().first().children().first().children();
if (soupCells.length !== 3) {
throw Error("Neočekávaný počet buněk v tabulce polévky: " + soupCells.length + ", ale očekávány byly 3");
}
results.push({
amount: sanitizeText($(soupCells.get(0)).text()),
name: sanitizeText($(soupCells.get(1)).text()),
price: sanitizeText($(soupCells.get(2)).text()),
isSoup: true,
});
// Hlavní jídla - div -> table -> tbody -> 3x tr
const mainCourseRows = $(tables.get(1)).children().first().children();
// Záměrně zakomentováno - občas je ve Sladovnické jídel méně
// if (mainCourseRows.length !== 3) {
// throw Error("Neočekávaný počet řádek jídel: " + mainCourseRows.length + ", ale očekávány byly 3");
// }
mainCourseRows.each((i, foodRow) => {
const foodCells = $(foodRow).children();
if (foodCells.length !== 3) {
throw Error("Neočekávaný počet buněk v řádku jídla: " + foodCells.length + ", ale očekávány byly 3");
}
results.push({
amount: sanitizeText($(foodCells.get(0)).text()),
name: sanitizeText($(foodCells.get(1)).text()),
price: sanitizeText($(foodCells.get(2)).text()),
isSoup: false,
});
})
return results;
}
/**
* Získá obědovou nabídku restaurace U Motlíků pro předané datum.
*
* @param date datum, pro které získat menu
* @param mock zda vrátit mock data
* @returns seznam jídel pro dané datum
*/
export const getMenuUMotliku = async (date: Date = new Date(), mock: boolean = false): Promise<Food[]> => {
if (mock) {
return [
{
amount: "0,33l",
name: "Hovězí vývar s nudlemi",
price: "35 Kč",
isSoup: true,
},
{
amount: "150g",
name: "Opečený párek, čočka, sázené vejce, okurka",
price: "135 Kč",
isSoup: false,
},
{
amount: "150g",
name: "Hovězí líčka na červeném víně, bramborová kaše",
price: "145 Kč",
isSoup: false,
},
{
amount: "150g",
name: "Tortilla s trhaným kuřecím masem, uzeným sýrem, dipem a kukuřicí, míchaný salát",
price: "135 Kč",
isSoup: false,
},
]
}
const todayDayIndex = getDayOfWeekIndex(date);
if (todayDayIndex == 5 || todayDayIndex == 6) { // Víkend
return [];
}
const html = await getHtml(U_MOTLIKU_URL, 'umotliku', date);
const $ = load(html);
const table = $('table.table.table-hover.Xtable-striped').first();
const body = table.children().first();
const rows = body.children();
const result: Food[][] = [];
for (let dayIndex = 0; dayIndex < 5; dayIndex++) {
if (!(dayIndex in result)) {
result[dayIndex] = [];
}
const results: Food[] = [];
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) {
if (capitalize(DAYS_IN_WEEK[todayDayIndex]) === childText) { // Našli jsme dnešek
parsing = true;
} else if (parsing) {
// Narazili jsme na další den - konec parsování
parsing = false;
return;
}
} else if (parsing) {
} else if (parsing) { // Jsme aktuálně na dnešním dni
const children = $(row).children();
if (children.length === 1) { // Nadpis "Polévka" nebo "Hlavní jídlo"
const foodType = children.first().text();
@@ -265,16 +258,16 @@ export const getMenuUMotliku = async (firstDayOfWeek: Date, mock: boolean = fals
} else if (foodType === 'Hlavní jídlo') {
isSoup = false;
} else {
throw new Error("Neočekáváný typ jídla: " + foodType);
throw 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");
throw 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({
const price = sanitizeText($(children.get(2)).text()).replace(',-', '');
results.push({
amount,
name,
price,
@@ -283,196 +276,90 @@ export const getMenuUMotliku = async (firstDayOfWeek: Date, mock: boolean = fals
}
}
})
}
return result;
return results;
}
/**
* Získá obědovou nabídku TechTower pro jeden týden.
* Získá obědovou nabídku TechTower pro předané datum.
*
* @param firstDayOfWeek první den v týdnu, pro který získat menu
* @param date datum, 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[][]> => {
export const getMenuTechTower = async (date: Date = new Date(), mock: boolean = false) => {
if (mock) {
return getMenuTechTowerMock();
return [
{
amount: "-",
name: "Bavorská gulášová polévka s kroupami",
price: "40 Kč",
isSoup: true,
},
{
amount: "-",
name: "Vepřové výpečky, kedlubnové zelí, bramborový knedlík",
price: "120 Kč",
isSoup: false,
},
{
amount: "-",
name: "Hambuger Black Angus s čedarem a slaninou, cibulové kroužky",
price: "220 Kč",
isSoup: false,
}
const html = await getHtml(TECHTOWER_URL);
]
}
const todayDayIndex = getDayOfWeekIndex(date);
if (todayDayIndex == 5 || todayDayIndex == 6) { // Víkend
return [];
}
const html = await getHtml(TECHTOWER_URL, 'techtower', date);
const $ = load(html);
let secondTry = false;
// První pokus - varianta "Obědy"
let fonts = $('font.wsw-41');
const 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;
throw Error('Chyba: nenalezen <font> pro obědy v HTML Techtower.');
}
})
}
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();
const siblings = $(font).parent().parent().siblings();
let parsing = false;
let currentDayIndex = 0;
const results: Food[] = [];
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
if (DAYS_IN_WEEK.includes(text)) {
if (text === DAYS_IN_WEEK[todayDayIndex]) {
// Našli jsme dnešní den, odtud začínáme parsovat jídla
parsing = true;
continue
} else if (parsing) {
// Už parsujeme jídla, ale narazili jsme na následující den - končíme
break;
}
} else if (parsing) {
if (text.length == 0) {
// Prázdná řádka - bývá na zcela náhodných místech ¯\_(ツ)_/¯
continue;
// Prázdná řádka - končíme (je za pátečním menu TechTower)
break;
}
let price = 'na\xA0váhu';
let nameRaw = text.replace('•', '');
if (text.toLowerCase().endsWith('kč')) {
let price = '? Kč';
let name = text;
if (text.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('•', '');
price = split.slice(1).join(" ")
name = split[0]
}
if (nameRaw.endsWith('')) {
nameRaw = nameRaw.slice(0, -1).trim();
}
const parsed = parseAllergens(nameRaw);
result[currentDayIndex] ??= [];
result[currentDayIndex].push({
results.push({
amount: '-',
name: parsed.cleanName,
name,
price,
isSoup: isTextSoupName(parsed.cleanName),
allergens: parsed.allergens.length > 0 ? parsed.allergens : undefined,
isSoup: isTextSoupName(name),
})
}
}
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;
return results;
}

View File

@@ -1,45 +0,0 @@
import express, { Request } from "express";
import { addChoice, getData, removeChoices } from "../service";
import { ClientData, LunchChoice } from "../../../types";
const NAMES = ["alice", "bob", "carol", "dave", "eve", "frank", "grace", "heidi", "ivan", "judy"];
const DATES = ["2025-01-06", "2025-01-07", "2025-01-08", "2025-01-09", "2025-01-10"];
const router = express.Router();
router.get("/createUsers", async (req: Request<{}, any, any>, res) => {
for (const element of NAMES) {
for (const dateStr of DATES) {
// Se šancí 50 % přidat pro tohoto uživatele tento den náhodnou volbu
if (Math.random() > 0.5) {
const foodIndex = Math.floor(Math.random() * 3); // Předpokládáme, že jsou 3 možnosti jídla
const date = new Date(dateStr);
// Náhodná volba z LunchChoice
const lunchChoices = [
"SLADOVNICKA",
"TECHTOWER",
"ZASTAVKAUMICHALA",
"SENKSERIKOVA",
];
const randomLunchChoice = lunchChoices[Math.floor(Math.random() * lunchChoices.length)];
await addChoice(element, true, randomLunchChoice as LunchChoice, foodIndex, date);
}
}
}
res.status(200).json({});
});
router.get("/clearUsers", async (req: Request<{}, any, any>, res) => {
for (const dateStr of DATES) {
const date = new Date(dateStr);
const data: ClientData = await getData(date);
for (const user of NAMES) {
for (const locationKey in data.choices) {
await removeChoices(user, true, locationKey as keyof ClientData["choices"], date);
}
}
}
res.status(200).json({});
});
export default router;

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,15 +0,0 @@
import express, { Request, Response } from "express";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import { GenerateQrData } from "../../../types";
const router = express.Router();
router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, res: Response<any>) => {
getLogin(parseToken(req));
console.log("Bank account for QR codes:", req.body.bankAccount);
console.log("Bank account holder for QR codes:", req.body.bankAccountHolder);
console.log("Requested QR codes for users:", req.body.qrCodes);
});
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,286 @@
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 { db } from "./database";
import { formatDate, getHumanDate, getIsWeekend } from "./utils";
import { callNotifikace } from "./notifikace";
import { generateQr } from "./qr";
import { ClientData, PizzaDayState, UdalostEnum, Pizza, PizzaSize, Order, PizzaOrder, Locations } from "@luncher/api/dist/Types";
/** 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]
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");
}
await storage.setData(selectedDay, data);
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
}
let order: Order | undefined = clientData.pizzaDay.orders.find(o => o.customer === login);
if (!order) {
order = {
customer: login,
pizzaList: [],
totalPrice: 0,
}
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);
clientData.pizzaDay.orders.push(order);
}
const pizzaOrder: PizzaOrder = {
varId: size.varId,
name: pizza.name,
size: size.size,
price: size.price,
}
}
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);
}
}
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);
}
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.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 function finishPizzaDelivery(login: string, bankAccount?: string, bankAccountHolder?: 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;
// Vygenerujeme QR kód, pokud k tomu máme data
// TODO berka je potřeba počkat na resolve promises z generateQr a až poté volat save do DB
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(', ');
const price = order.pizzaList.map(pizza => pizza.price).reduce((partial, a) => partial + a, 0);
generateQr(order.customer, bankAccount, bankAccountHolder, price, message).then(() => order.hasQr = true);
}
}
}
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;
}
export function updateNote(login: string, note?: string) {
const today = formatDate(getToday());
let clientData: ClientData = db.get(today);
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 || !myOrder.pizzaList.length) {
throw Error("Pizza day neobsahuje žádné objednávky uživatele " + login);
}
myOrder.note = note;
db.set(today, clientData);
return clientData;
}

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);
});

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