185 Commits

Author SHA1 Message Date
0179afca75 feat: část refaktoru databáze 2025-11-25 13:42:06 +01:00
8285dd2780 Povýšení závislostí
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-11-10 22:03:48 +01:00
039d8457f3 Povýšení závislostí
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-11-06 21:22:39 +01:00
091e25f446 Kopírování poznámky jen pokud je vyplněna
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-11-03 14:17:22 +01:00
00b4a0cce2 Oprava/narovnání ikony kopírování poznámky
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-11-03 14:05:01 +01:00
979c79e090 poznamka, pak icon
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-11-03 13:43:20 +01:00
d29c7863dd chat ikona misto barvicek
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-11-03 13:36:54 +01:00
0781b84f11 Kopírování komentaře na kliknutí.
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-11-03 12:57:22 +01:00
e3d217822a Oprava výběru jídla při přepínání dnů šipkami
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-11-03 12:48:56 +01:00
ccfe9a9ae1 Oprava parsování Sladovnické v případě chybějících dnů
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-10-31 08:59:30 +01:00
7407a3d881 Oprava hover popisků u Font Awesome ikon
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-10-16 08:06:22 +02:00
8b139eba4e Oprava zobrazení tooltipu při hoveru
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-10-16 07:39:39 +02:00
74eeef1de9 Aktualizace posledních změn
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-10-11 13:24:11 +02:00
fd67c0e646 Oprava buildu
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-10-11 13:15:47 +02:00
60150889b0 Povýšení závislostí
Some checks failed
ci/woodpecker/push/workflow Pipeline failed
2025-10-11 13:02:56 +02:00
9e0e842c2d Podzimní atmosféra
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-10-11 11:03:20 +02:00
86c50b315a Přidání alergenů do mock dat
Some checks failed
ci/woodpecker/push/workflow Pipeline failed
2025-10-11 11:01:42 +02:00
fe7d609b5f Oprava zobrazení patičky
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-10-11 10:36:21 +02:00
331a890cc5 Oddělení přenačtení menu do vlastní komponenty
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-10-10 14:00:01 +02:00
523bbbfb0f Proklik na seznam alergenů
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-10-10 13:15:18 +02:00
1acf9bf092 Úprava cesty pro čtení easter eggs
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-10-10 08:49:17 +02:00
81f67c8424 Podpora parsování a zobrazení alergenů
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-10-06 16:28:38 +02:00
c2a001b7e5 Oprava parsování TechTower dle aktuální podoby HTML
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-10-06 13:12:58 +02:00
670e45b805 Nevyvolávat přenačtení u zavřených podniků
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-08-11 10:30:42 +02:00
52769fc981 Opravy dle SonarQube
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-08-07 13:12:55 +02:00
0d90453c38 Oprava chybného čtení .env souborů 2025-08-07 13:02:41 +02:00
a9709a944f Úprava pro novou podobu stránek Sladovnická
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-08-04 17:30:04 +02:00
593ffcf02b Vylepšení run_dev.sh pro vývoj 2025-08-04 17:27:03 +02:00
b4b62870e3 Úprava .gitignore 2025-08-04 17:26:38 +02:00
480fe725f1 Oprava načítání jídel pro Šenk Šeříková na přelomu měsíce
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-08-01 14:00:27 +02:00
d2845f7d0f Merge pull request 'feat/odflaknutyRefreshDat' (#17) from feat/odflaknutyRefreshDat into master
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
Reviewed-on: #17
2025-08-01 09:05:50 +02:00
269f1994bc Update novinky 2025-08-01 09:04:28 +02:00
3dcda2028e Error pri fetch do klienta 2025-07-31 23:47:43 +02:00
cfffd2b31d pro refresh endpoint nevyzadovat authtoken 2025-07-31 23:45:47 +02:00
58bb5f4e7d pro refresh endpoint nevyzadovat authtoken 2025-07-31 23:41:51 +02:00
a3dfdb17e8 fix async.... 2025-07-31 23:37:21 +02:00
124fdce69d tak jsem to mozna robil, ale mozna taky ne lol 2025-07-31 23:35:38 +02:00
ff20394b97 feat: Přidání funkce pro manuální refresh jidel. 2025-07-31 23:29:19 +02:00
a77a04bcdf umichal patek fix
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-07-30 12:00:03 +02:00
42852805e0 Oprava plnění data a času poslední aktualizace menu
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-07-29 15:41:05 +02:00
d767730b19 sonar
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-07-29 15:15:16 +02:00
fa4f9903cb parametr forceupdate jidla
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-07-29 11:33:31 +02:00
cf8be8c64f fix: feat jsem to dal na spatnej radaek
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-07-29 10:55:04 +02:00
4c2b08adf8 feat: refresh jidla endpoint
Some checks failed
ci/woodpecker/push/workflow Pipeline failed
2025-07-29 10:43:49 +02:00
62cc82da9a Úprava parsování TechTower pro aktuální týden
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-07-22 17:10:39 +02:00
40c113a4c8 Oprava pádů při načítání z menicka.cz
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-07-07 08:47:39 +02:00
7681584d11 Oprava parsování TechTower pro aktuální stav
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-07-07 08:24:26 +02:00
c670b4212a Revert "TechTower hack pro tento specifický týden"
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
This reverts commit 5fd90de3f8.
2025-05-26 10:13:35 +02:00
5fd90de3f8 TechTower hack pro tento specifický týden
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-05-20 08:02:34 +02:00
49b8ab5c13 Update server/src/index.ts
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
delete req.headers["cookie"]
2025-04-11 12:06:52 +02:00
9a05ef1fe6 Update server/src/index.ts
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
vic logov
2025-04-11 12:01:58 +02:00
0bfea3765f properta pro logovani headeru
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-04-11 10:42:27 +02:00
962fbe2947 fix hardcoded header name xd
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-04-11 10:06:35 +02:00
d6d6ebb682 Aktualizace posledních změn
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-03-21 00:24:26 +01:00
5bb7de58e7 Odebrání zimní atmosféry 2025-03-21 00:24:17 +01:00
739c7707e1 Migrace serveru na OpenAPI
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-03-20 23:50:47 +01:00
d366882f6b Migrace klienta na OpenAPI
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-03-19 23:08:46 +01:00
f09bc44d63 Oprava nefunkčního odebrání prvního vybraného jídla
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-03-11 20:57:37 +01:00
f0d56f11aa Oprava popisu varianty "neobědvám"
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-03-11 19:41:37 +01:00
f74ec379c8 Oprava výběru možnosti stravování
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-03-06 08:03:49 +01:00
c9fa710070 Oprava buildu
Some checks failed
ci/woodpecker/push/workflow Pipeline failed
2025-03-06 07:59:33 +01:00
e55ee7c11e Refaktor: Nálezy SonarQube
Some checks are pending
ci/woodpecker/push/workflow Pipeline is running
2025-03-05 21:48:02 +01:00
55fd368663 Oprava Woodpecker pipeline
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-03-05 21:19:33 +01:00
61f13d2132 Validace TypeScript typů při sestavení klienta
Some checks failed
ci/woodpecker/push/workflow Pipeline failed
2025-03-05 21:05:40 +01:00
d69e09afee Migrace na OpenAPI - TypeScript typy 2025-03-05 21:05:21 +01:00
d144c55bf7 feat: #11 je tohle feat?, pridani poctu lidi k restauraci
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-03-05 18:56:21 +01:00
999a517404 Oprava lokalizace datumu
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-03-03 10:20:41 +01:00
68bafa808c Oprava #8
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-27 21:46:50 +01:00
a34614c8db Oprava #6
Some checks failed
ci/woodpecker/push/workflow Pipeline failed
2025-02-27 21:36:21 +01:00
f4e31cea36 Oprava #4, #5
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-27 21:29:43 +01:00
8dda6b1014 Oprava #7
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-27 21:19:38 +01:00
f9c7d647f7 Migrace Node v18 -> v22
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-27 00:28:14 +01:00
ca400638d1 Přidání základních statistik
Some checks failed
ci/woodpecker/push/workflow Pipeline failed
2025-02-27 00:22:34 +01:00
0af78e72d9 Nastavení časové zóny
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-24 12:06:29 +01:00
Michal Hájek
8137ca6fc0 Teamsová notifikace "Jdeme na oběd"
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-22 20:43:34 +01:00
Michal Hájek
3817126ac0 Výběr restaurace kliknutím na její název
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-19 20:39:27 +01:00
Michal Hájek
c1856b2eee Pokud bylo v osobním nastavení vypnuto zobraování polévky, předával se do funkce doAddClickFoodChoice spatně foodIndex
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-19 20:21:17 +01:00
Michal Hájek
eaf0bc353d Výběr obědu kliknutím
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-18 10:07:35 +01:00
ff650ec3b8 rm db.json
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-17 09:32:23 +01:00
f8aa293413 fix
Some checks are pending
ci/woodpecker/push/workflow Pipeline is running
2025-02-17 09:26:03 +01:00
cafcd0a467 Log username a email pri kazdem dotazu pouze pro neproduction env
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-17 09:19:28 +01:00
9e247eb2a1 Podpora sestavování přes Woodpecker CI
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
2025-02-09 00:34:59 +01:00
469a6b9031 Oprava .gitignore 2025-02-08 23:28:20 +01:00
Michal Hájek
89dec1c194 Založení složky server/data, pokud neexistuje, do které je vytvořen soubor db.json 2025-02-02 19:46:20 +01:00
Michal Hájek
f3af64923c Přesun json databaze (souboru db.json) do složky data, související úpravy v Dockerfile 2025-02-02 16:09:07 +01:00
Michal Hájek
44b09a9d1a Začištění souborů .gitignore 2025-02-02 16:06:52 +01:00
Michal Hájek
c311cc2fd7 Oprava importů klienta do složky types, aby nebylo potřeba složku kokírovat 2025-02-02 16:01:21 +01:00
Michal Hájek
a9fe369abc Oprava možnosti vybrat V kolik hodin preferuješ odchod pro následující dny 2025-01-29 08:48:43 +01:00
Michal Hájek
ea9fe980f0 U restaurace Pivovarský šenk Šeříková nahrazena mezera mezi cenou a Kč pevnou mezerou, aby nedocházelo k zalomení 2025-01-29 01:29:51 +01:00
Michal Hájek
d367826ce0 Přidání restaurace Pivovarský šenk Šeříková 2025-01-29 01:14:03 +01:00
Michal Hájek
fdf1ae938f Načtení menu celého týdne restaurace Zastávka u Michala 2025-01-28 22:20:44 +01:00
57c22958be Oprava chybné detekce některých jídel TechTower jako polévka 2025-01-20 14:41:18 +01:00
Michal Hájek
fe9cee3a80 Odfiltrovat ze selectboxu pro výběr preferovaného odchodu časy z minulosti 2025-01-15 00:35:17 +01:00
Michal Hájek
1d995faf8e Odfiltrovat ze selectboxu pro výběr preferovaného odchodu časy z minulosti 2025-01-15 00:34:47 +01:00
Michal Hájek
62fff22a12 Přidání restaurace Zastávka u Michala do výběru "Jak to dnes vidíš s obědem" 2025-01-15 00:03:15 +01:00
Michal Hájek
0fd1482810 Přidání restaurace Zastávka u Michala 2025-01-14 23:45:06 +01:00
02de6691a8 Migrace z pořadových indexů na unikátní klíče 2025-01-09 22:05:20 +01:00
774cb4f9d2 Oprava syntaxe - zapomenutá migrace interface 2025-01-09 21:04:12 +01:00
fd9aa547e2 Migrace "interface" na "type" 2025-01-08 20:53:48 +01:00
e611d36995 Otypování requestů na API 2025-01-08 17:58:49 +01:00
414664b2d7 Úprava API pro podporu TypeScript 2025-01-08 17:43:47 +01:00
a2167038da Redukce velikosti obrázku 2025-01-07 15:52:10 +01:00
219f7ffbc8 Zimní atmosféra 2025-01-07 15:49:22 +01:00
4d2ec529bb Skrytí podniku U Motlíků 2025-01-07 15:44:39 +01:00
86af490e94 Oprava parsování TechTower 2025-01-07 15:21:10 +01:00
e21da059c6 Aktualizace posledních změn 2024-12-11 23:31:55 +01:00
e990108140 Migrace na React 19 2024-12-11 23:04:03 +01:00
18f2b72133 Migrace sestavování klienta na Vite 2024-12-11 22:54:57 +01:00
b0d8a1a830 Povýšení závislostí 2024-12-11 20:24:06 +01:00
7e4fa236b1 Podpora easter eggů 2024-12-11 20:09:45 +01:00
98f2b2a1e0 Přidání vánočních prvků 2024-12-06 16:53:24 +01:00
9b7abb0703 throw error 2024-11-19 12:10:02 +01:00
5678e4a606 Snad fix timeout 2024-11-19 12:01:59 +01:00
582216015c Přidání easter-eggs.json do .gitignore 2024-11-13 23:30:54 +01:00
31daf4fb36 Začištění Dockerfile a compose.yml 2024-10-30 13:05:05 +01:00
4f858a19d8 Oprava parsování TechTower 2024-10-30 13:01:32 +01:00
91ea07a539 Oprava parsování TechTower 2024-07-08 20:45:09 +02:00
101bd60ddb Oprava case-sensitive parsování TechTower 2024-06-10 12:56:20 +02:00
7e061aa890 Nové možnosti hlasování 2024-04-11 22:00:34 +02:00
ff2d9e4fdb Vylepšení mobilního zobrazení 2024-04-09 17:40:13 +02:00
e261d32170 Úprava zápatí 2024-03-24 18:53:51 +01:00
731fd2eeb9 Oprava validace délky poznámky 2024-03-24 18:51:38 +01:00
93ba8def03 Oprava posunů mezi dny v inputech 2024-03-05 23:10:38 +01:00
1e280e9d05 Možnost zadání obecné poznámky k volbě 2024-03-04 23:35:58 +01:00
44187bc316 Oprava vyhodnocení nastavení trusted headers 2024-03-04 23:33:22 +01:00
4bd825fbcf Aktualizace TODO 2024-02-26 20:34:14 +01:00
b087c790ad Povýšení závislostí 2024-02-26 20:23:14 +01:00
e4a146995f Oprava nodemon hotreload 2024-02-26 20:16:11 +01:00
2883e80658 urcite neco rozbije a pavel to najde jako prvni 2024-02-05 20:02:21 +01:00
5830cde9ac Aktualizace frází pro detekci polévek v TechTower 2024-02-02 22:05:54 +01:00
52c4a53b9e Aktualizace changelogu 2024-01-28 21:15:38 +01:00
e9ea42c636 Oprava varování linteru 2024-01-28 20:46:38 +01:00
e735af4fc1 Neuskakování šipek 2024-01-25 19:18:05 +01:00
56125eea2e Přidání možnosti "Rozhoduji se" 2024-01-24 19:29:09 +01:00
61b6ec04f4 Rozšíření výčtu polévek pro TechTower 2024-01-24 19:11:21 +01:00
b954374425 Aktualizace mock dat 2024-01-24 19:07:49 +01:00
72c7bfe80c Možnost skrytí polévek 2024-01-24 18:54:07 +01:00
2633d445cc Doplnění chybějící nedělitelné mezery 2024-01-23 08:00:21 +01:00
3fd6b7dfcb Text "na váhu" v případě neznámé ceny u TechTower 2024-01-22 21:32:04 +01:00
8e075dd904 Základní Pizza kalkulačka 2024-01-08 23:39:12 +01:00
74f6e1ab69 Oprava parsování Sladovnická
Opravena chyba, kdy docházelo k posunu nabídky o den, pokud nabídka nezačínala pondělím.
2024-01-03 14:03:38 +01:00
fcad338921 Oprava parsování U Motlíků
Nyní je počítáno s případy, kdy neexistuje nabídka pro první den v daném týdnu.
2024-01-03 14:01:45 +01:00
aca4055d57 Přidání trusted headers do .env.template 2023-12-02 21:07:15 +01:00
4991b813bf Umožnit zadání trusted IPs s bílými znaky 2023-12-02 20:54:39 +01:00
515d4bb47e Opravy překlepů 2023-12-02 20:50:19 +01:00
b9b2492cb4 Odstranění zbytečné proměnné 2023-12-02 20:47:38 +01:00
4ff5d70331 tohle prepsalo muj list ip adres 2023-12-02 19:09:12 +01:00
44de01f6eb doufam ze jsem to hodne rozjebal lol 2023-12-02 17:45:56 +01:00
f85d19bbd6 Vylepšení parsování U Motlíků
Nově je počítáno s případy, kdy na stránkách existuje menu pro více týdnů současně.
2023-11-20 19:33:59 +01:00
7024c75f37 Přidání favicony 2023-11-10 21:04:33 +01:00
b9b9487375 Neumožnit změnu volby do minulosti 2023-11-10 20:48:34 +01:00
bbcb4c34b1 Lepší zobrazení poslední aktualizace menu 2023-11-10 20:40:59 +01:00
2b9d817af5 Zobrazování data u poslední aktualizace menu 2023-11-10 20:22:47 +01:00
3021e6159d Oprava parsování pro TechTower 2023-10-23 12:24:32 +02:00
c5103f902d Oprava sestavování ntfy URL 2023-10-17 08:44:14 +02:00
60563eaf0d Oprava sestavování URL pro ntfy 2023-10-16 09:56:22 +02:00
dc959543f4 Úprava uživatelských jmen pro ntfy URL 2023-10-16 09:18:08 +02:00
9736646b03 Kódování username pro ntfy v base64 2023-10-16 09:10:51 +02:00
9c2808d4ec Oprava funkčnosti při selhání načtení dat 2023-10-16 08:54:17 +02:00
eb82c23386 Odstranění přebytečných dat, aktualizace TODO 2023-10-15 21:02:58 +02:00
f2983b4397 Oprava pádu o víkendech 2023-10-15 20:47:59 +02:00
6d89858e3e feat: podpora notifikací z notify 2023-10-15 20:28:58 +02:00
3460d69899 Oprava mizejícího Pizza day 2023-10-15 19:42:03 +02:00
74c8ab9e39 Testy datumových funkcí 2023-10-15 19:05:27 +02:00
ca9a7c5c23 Parsování jídel na celý týden 2023-10-15 19:05:19 +02:00
74893c38eb Refaktor, rozdělení api, zpřehlednění kódu 2023-10-03 22:52:09 +02:00
829197e17a Oprava výchozích dat pro hlasování 2023-10-02 19:13:47 +02:00
c15f33323d Oprava počátečního hlasování 2023-10-01 19:27:34 +02:00
8e285e9197 Možnost hlasování o nových funkcích 2023-09-27 18:35:18 +02:00
401833f763 Oprava zbytečného posílání loginu na server 2023-09-27 18:34:14 +02:00
bef6178a6f Generalizace úložiště pro libovolná data 2023-09-27 15:09:36 +02:00
87beb5b66e Oprava zvýraznění aktuálního dne 2023-09-26 18:44:09 +02:00
8d80678a9a Vylepšená detekce uzavření podniků 2023-09-24 21:16:47 +02:00
eb27591727 Možnost příplatků u Pizza day objednávek 2023-09-24 20:15:04 +02:00
c3d35ccc9c Odstranění neexistujícího types workspace 2023-09-24 09:28:50 +02:00
47cd9f90c8 Oddělení TODO do samostatného souboru 2023-09-24 09:25:24 +02:00
f5ecedb3b9 Loader při zakládání Pizza day, mock data 2023-09-24 09:21:43 +02:00
3f16485368 Aktualizace TODO v README.md 2023-09-24 08:53:29 +02:00
dc9d1d0e9a Vylepšení začištění názvů jídel 2023-09-24 08:45:47 +02:00
e4451e299a Serverová validace času odchodu 2023-09-24 08:38:40 +02:00
c286bd1778 Začištění závislostí klienta 2023-09-24 08:15:49 +02:00
00cf8655e5 Opravy eslint 2023-09-24 08:10:48 +02:00
862614ae9d Refaktor, oddělení Pizza Day do vlastní servisky 2023-09-24 08:08:41 +02:00
1b132a7ca7 Oprava generování QR kódů pro Pizza day 2023-09-22 20:30:27 +02:00
3a357f077f Neorientovat se dle datumu klienta 2023-09-22 19:41:33 +02:00
116 changed files with 13682 additions and 11876 deletions

23
.gitignore vendored
View File

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

64
.woodpecker/workflow.yaml Normal file
View File

@@ -0,0 +1,64 @@
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,42 +1,92 @@
ARG NODE_VERSION="node:22-alpine"
# Builder # Builder
FROM node:18-alpine3.18 as builder FROM ${NODE_VERSION} AS builder
WORKDIR /build WORKDIR /build
COPY package.json . # Zkopírování závislostí - OpenAPI generátor
COPY yarn.lock . 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/package.json ./server/
COPY client/package.json ./client/ 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 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/tsconfig.json ./server/
COPY server/src ./server/src/ COPY server/src ./server/src/
# Zkopírování build závislostí - klient
COPY client/tsconfig.json ./client/ 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/src ./client/src
COPY client/public ./client/public COPY client/public ./client/public
COPY types ./types/ # 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 WORKDIR /build/server
RUN yarn build RUN yarn build
# Sestavení klienta
WORKDIR /build/client WORKDIR /build/client
RUN yarn build RUN yarn build
# Runner # Runner
FROM node:18-alpine3.18 FROM ${NODE_VERSION}
ENV LANG cs_CZ.UTF-8
ENV NODE_ENV production RUN apk add --no-cache tzdata
ENV TZ=Europe/Prague \
LC_ALL=cs_CZ.UTF-8 \
NODE_ENV=production
WORKDIR /app WORKDIR /app
COPY --from=builder /build/node_modules ./node_modules # Vykopírování sestaveného serveru
COPY --from=builder /build/server/node_modules ./server/node_modules
COPY --from=builder /build/server/dist ./ COPY --from=builder /build/server/dist ./
COPY --from=builder /build/client/build ./public
COPY /server/.env.production ./server/src # 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 EXPOSE 3000

26
Dockerfile-Woodpecker Normal file
View File

@@ -0,0 +1,26 @@
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

@@ -3,7 +3,7 @@ Aplikace pro profesionální management obědů.
Aplikace sestává ze tří modulů. Aplikace sestává ze tří modulů.
- types - types
- společné TypeScript definice, pro objekty posílané mezi serverem a klientem - OpenAPI definice společných typů, generované přes [openapi-ts](https://github.com/hey-api/openapi-ts)
- server - server
- backend psaný v [node.js](https://nodejs.dev) - backend psaný v [node.js](https://nodejs.dev)
- client - client
@@ -12,87 +12,29 @@ Aplikace sestává ze tří modulů.
## Spuštění pro vývoj ## Spuštění pro vývoj
### Závislosti ### Závislosti
#### Klient/server #### Klient/server
- [Node.js 18.x](https://nodejs.dev) - [Node.js 22.x (>= 22.11)](https://nodejs.dev)
- [Yarn 1.22.x (Classic)](https://classic.yarnpkg.com) - [Yarn 1.22.x (Classic)](https://classic.yarnpkg.com)
### Spuštění na *nix platformách ### Spuštění na *nix platformách
- Nainstalovat závislosti viz předchozí bod - Nainstalovat závislosti viz předchozí bod
- Zkopírovat `server/.env.template` do `server/.env.development` a upravit dle potřeby - Zkopírovat `server/.env.template` do `server/.env.development` a upravit dle potřeby
- Spustit `./run_dev.sh`. Na jiných platformách se lze inspirovat jeho obsahem, postup by měl být víceméně stejný. - 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`
## Sestavení a spuštění produkční verze v Docker ## Sestavení a spuštění produkční verze v Docker
### Závislosti ### Závislosti
- [Docker](https://www.docker.com) - [Docker](https://www.docker.com)
- [Docker Compose](https://docs.docker.com/compose) - [Docker Compose](https://docs.docker.com/compose)
### Spuštění
- `docker compose up --build -d`
### Spuštení s traefik ### Spuštení s traefik
- `docker compose -f compose-traefik.yml up --build -d` - `docker compose -f compose-traefik.yml up --build -d`
## TODO ## TODO
- [ ] Umožnit vybrat libovolný čas odchodu Dostupné [zde](TODO.md).
- [ ] Validace zadání smysluplného času (ideálně i klientská)
- [ ] 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
- [ ] Možnost náhledu na ostatní dny v týdnu (např. pomocí šipek)
- [ ] Možnost výběru oběda na následující dny v týdnu
- [ ] Vylepšit parsery restaurací
- [ ] Někdy jsou v názvech jídel přebytečné mezery kolem čárek ( , )
- [ ] 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
- [ ] 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í
- [ ] QR kód zobrazovat už po objednání, není důvod čekat na doručení
- [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] 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ů

73
TODO.md Normal file
View File

@@ -0,0 +1,73 @@
# 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ů

2
client/.gitignore vendored
View File

@@ -1,2 +1,2 @@
build build
src/types dist

21
client/index.html Normal file
View File

@@ -0,0 +1,21 @@
<!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

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 17 KiB

BIN
client/public/hat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -1,43 +0,0 @@
<!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

@@ -0,0 +1,22 @@
<?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>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -0,0 +1,22 @@
<?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>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -0,0 +1,22 @@
<?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>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -0,0 +1,22 @@
<?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>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -0,0 +1,22 @@
<?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>

After

Width:  |  Height:  |  Size: 2.3 KiB

22
client/public/leaf.svg Normal file
View File

@@ -0,0 +1,22 @@
<?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>

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 60 KiB

BIN
client/public/snowman.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -1,109 +0,0 @@
import { toast } from "react-toastify";
import { PizzaOrder } from "./types";
import { getBaseUrl, getToken } from "./Utils";
/**
* Wrapper pro volání API, u kterých chceme automaticky zobrazit toaster s chybou ze serveru.
*
* @param apiFunction volaná API funkce
*/
export function errorHandler<T>(apiFunction: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
apiFunction().then((result) => {
resolve(result);
}).catch(e => {
toast.error(e.message, { theme: "colored" });
});
});
}
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()}`);
try {
const response = await fetch(getBaseUrl() + url, config);
if (!response.ok) {
const json = await response.json();
// Vyhodíme samotnou hlášku z odpovědi, odchytí si jí errorHandler
throw new Error(json.error);
}
return response.json() as TResponse;
} catch (e) {
return Promise.reject(e);
}
}
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 (dayIndex?: number) => {
let url = '/api/data';
if (dayIndex != null) {
url += '?dayIndex=' + dayIndex;
}
return await api.get<any>(url);
}
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 addChoice = async (locationIndex: number, foodIndex?: number, dayIndex?: number) => {
return await api.post<any, any>('/api/addChoice', JSON.stringify({ locationIndex, foodIndex, dayIndex }));
}
export const removeChoices = async (locationIndex: number, dayIndex?: number) => {
return await api.post<any, any>('/api/removeChoices', JSON.stringify({ locationIndex, dayIndex }));
}
export const removeChoice = async (locationIndex: number, foodIndex: number, dayIndex?: number) => {
return await api.post<any, any>('/api/removeChoice', JSON.stringify({ locationIndex, foodIndex, dayIndex }));
}
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 }));
}
export const changeDepartureTime = async (login: string, time: string, dayIndex?: number) => {
return await api.post<any, any>('/api/changeDepartureTime', JSON.stringify({ login, time, dayIndex }));
}

View File

@@ -51,12 +51,28 @@
font-size: 64px; font-size: 64px;
} }
/* Sticky footer layout */
html,
body,
#root {
height: 100%;
margin: 0;
}
.app-container {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.wrapper { .wrapper {
padding: 20px; padding: 20px;
flex: 1;
/* Zabere zbytek místa */
} }
.title { .title {
margin: 50px 30px; margin: 50px 20px;
} }
.food-tables { .food-tables {
@@ -84,7 +100,7 @@
margin-bottom: 0; margin-bottom: 0;
} }
.table> :not(caption) .trash-icon { .table> :not(caption) .action-icon {
color: rgb(0, 89, 255); color: rgb(0, 89, 255);
cursor: pointer; cursor: pointer;
margin-left: 10px; margin-left: 10px;
@@ -107,6 +123,7 @@
.qr-code { .qr-code {
text-align: center; text-align: center;
margin-top: 30px;
} }
.select-search-container { .select-search-container {
@@ -123,3 +140,45 @@
align-items: center; align-items: center;
font-size: xx-large; 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,73 +1,102 @@
import React, { useContext, useEffect, useMemo, useRef, useState, useCallback } from 'react'; import React, { useContext, useEffect, useMemo, useRef, useState, useCallback } from 'react';
import 'bootstrap/dist/css/bootstrap.min.css'; import 'bootstrap/dist/css/bootstrap.min.css';
import { EVENT_DISCONNECT, EVENT_MESSAGE, SocketContext } from './context/socket'; import { EVENT_DISCONNECT, EVENT_MESSAGE, SocketContext } from './context/socket';
import { addChoice, addPizza, changeDepartureTime, createPizzaDay, deletePizzaDay, errorHandler, finishDelivery, finishOrder, getData, getQrUrl, lockPizzaDay, removeChoice, removeChoices, removePizza, unlockPizzaDay, updateNote } from './Api';
import { useAuth } from './context/auth'; import { useAuth } from './context/auth';
import Login from './Login'; import Login from './Login';
import { Alert, Button, Col, Form, Row, Table } from 'react-bootstrap'; import { Alert, Button, Col, Form, Row, Table } from 'react-bootstrap';
import Header from './components/Header'; import Header from './components/Header';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import PizzaOrderList from './components/PizzaOrderList'; import PizzaOrderList from './components/PizzaOrderList';
import SelectSearch, { SelectedOptionValue } from 'react-select-search'; import SelectSearch, { SelectedOptionValue, SelectSearchOption } from 'react-select-search';
import 'react-select-search/style.css'; import 'react-select-search/style.css';
import './App.css'; import './App.scss';
import { SelectSearchOption } from 'react-select-search'; import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons';
import { faCircleCheck, faTrashCan } from '@fortawesome/free-regular-svg-icons'; import { useSettings } from './context/settings';
import { useBank } from './context/bank';
import { ClientData, Restaurants, Food, Order, Locations, PizzaOrder, PizzaDayState, FoodChoices, Menu } from './types';
import Footer from './components/Footer'; import Footer from './components/Footer';
import { faChainBroken, faChevronLeft, faChevronRight, faSatelliteDish, faSearch } from '@fortawesome/free-solid-svg-icons'; import { faChainBroken, faChevronLeft, faChevronRight, faGear, faSatelliteDish, faSearch } from '@fortawesome/free-solid-svg-icons';
import Loader from './components/Loader'; 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';
const EVENT_CONNECT = "connect" const EVENT_CONNECT = "connect"
// TODO tohle má být kvůli bezpečnosti na serveru // Fixní styl pro všechny easter egg obrázky
const DEPARTURE_TIMES = [ const EASTER_EGG_STYLE = {
"10:00", zIndex: 1,
"10:15", animationName: "bounce-in",
"10:30", animationTimingFunction: "ease"
"10:45", }
"11:00",
"11:15", // Mapování čísel alergenů na jejich názvy
"11:30", const ALLERGENS: { [key: number]: string } = {
"11:45", 1: "Obiloviny obsahující lepek",
"12:00", 2: "Korýši a výrobky z nich",
"12:15", 3: "Vejce a výrobky z nich",
"12:30", 4: "Ryby a výrobky z nich",
"12:45", 5: "Arašidy a výrobky z nich",
"13:00", 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() { function App() {
const auth = useAuth(); const auth = useAuth();
const bank = useBank(); const settings = useSettings();
const [easterEgg, _] = useEasterEgg(auth);
const [isConnected, setIsConnected] = useState<boolean>(false); const [isConnected, setIsConnected] = useState<boolean>(false);
const [data, setData] = useState<ClientData>(); const [data, setData] = useState<ClientData>();
const [food, setFood] = useState<{ [key in Restaurants]?: Menu }>(); const [food, setFood] = useState<RestaurantDayMenuMap>();
const [myOrder, setMyOrder] = useState<Order>(); const [myOrder, setMyOrder] = useState<PizzaOrder>();
const [foodChoiceList, setFoodChoiceList] = useState<Food[]>(); const [foodChoiceList, setFoodChoiceList] = useState<Food[]>();
const [closed, setClosed] = useState<boolean>(false); const [closed, setClosed] = useState<boolean>(false);
const socket = useContext(SocketContext); const socket = useContext(SocketContext);
const choiceRef = useRef<HTMLSelectElement>(null); const choiceRef = useRef<HTMLSelectElement>(null);
const foodChoiceRef = useRef<HTMLSelectElement>(null); const foodChoiceRef = useRef<HTMLSelectElement>(null);
const departureChoiceRef = useRef<HTMLSelectElement>(null); const departureChoiceRef = useRef<HTMLSelectElement>(null);
const poznamkaRef = useRef<HTMLInputElement>(null); const pizzaPoznamkaRef = useRef<HTMLInputElement>(null);
const [failure, setFailure] = useState<boolean>(false); const [failure, setFailure] = useState<boolean>(false);
const [dayIndex, setDayIndex] = useState<number>(); 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 // 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 // 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 dayIndexRef = useRef<number | undefined>(dayIndex);
// Načtení dat po přihlášení // Načtení dat po přihlášení
useEffect(() => { useEffect(() => {
if (!auth || !auth.login) { if (!auth?.login) {
return return
} }
getData().then((data: ClientData) => { getData().then(response => {
const data = response.data
if (data) {
setData(data); setData(data);
setDayIndex(data.weekIndex); const dayIndex = getDayOfWeekIndex(new Date(data.date));
dayIndexRef.current = data.weekIndex; setDayIndex(dayIndex);
dayIndexRef.current = dayIndex;
setFood(data.menus); setFood(data.menus);
}
}).catch(e => { }).catch(e => {
setFailure(true); setFailure(true);
}) })
@@ -75,31 +104,34 @@ function App() {
// Přenačtení pro zvolený den // Přenačtení pro zvolený den
useEffect(() => { useEffect(() => {
if (!auth || !auth.login) { if (!auth?.login) {
return return
} }
getData(dayIndex).then((data: ClientData) => { setTodayDayIndex(getDayOfWeekIndex(new Date()));
setIsTodayWeekend(getIsWeekend(new Date()));
getData({ query: { dayIndex: dayIndex } }).then(response => {
const data = response.data;
setData(data); setData(data);
if (data) {
setFood(data.menus); setFood(data.menus);
}
}).catch(e => { }).catch(e => {
setFailure(true); setFailure(true);
}) })
}, [dayIndex]); }, [dayIndex, auth]);
// Registrace socket eventů // Registrace socket eventů
useEffect(() => { useEffect(() => {
socket.on(EVENT_CONNECT, () => { socket.on(EVENT_CONNECT, () => {
// console.log("Connected!");
setIsConnected(true); setIsConnected(true);
}); });
socket.on(EVENT_DISCONNECT, () => { socket.on(EVENT_DISCONNECT, () => {
// console.log("Disconnected!");
setIsConnected(false); setIsConnected(false);
}); });
socket.on(EVENT_MESSAGE, (newData: ClientData) => { socket.on(EVENT_MESSAGE, (newData: ClientData) => {
// console.log("Přijata nová data ze socketu", newData); // console.log("Přijata nová data ze socketu", newData);
// Aktualizujeme pouze, pokud jsme dostali data pro den, který máme aktuálně zobrazený // Aktualizujeme pouze, pokud jsme dostali data pro den, který máme aktuálně zobrazený
if (dayIndexRef.current == null || newData.weekIndex === dayIndexRef.current) { if (dayIndexRef.current == null || getDayOfWeekIndex(new Date(newData.date)) === dayIndexRef.current) {
setData(newData); setData(newData);
} }
}); });
@@ -112,7 +144,7 @@ function App() {
}, [socket]); }, [socket]);
useEffect(() => { useEffect(() => {
if (!auth || !auth.login) { if (!auth?.login) {
return return
} }
// TODO tohle občas náhodně nezafunguje, nutno přepsat, viz https://medium.com/@teh_builder/ref-objects-inside-useeffect-hooks-eb7c15198780 // TODO tohle občas náhodně nezafunguje, nutno přepsat, viz https://medium.com/@teh_builder/ref-objects-inside-useeffect-hooks-eb7c15198780
@@ -137,13 +169,10 @@ function App() {
useEffect(() => { useEffect(() => {
if (choiceRef?.current?.value && choiceRef.current.value !== "") { if (choiceRef?.current?.value && choiceRef.current.value !== "") {
// TODO: wtf, cos pil, když jsi tohle psal? const locationKey = choiceRef.current.value as LunchChoice;
const key = choiceRef?.current?.value; const restaurantKey = Object.keys(Restaurant).indexOf(locationKey);
const locationIndex = Object.keys(Locations).indexOf(key as unknown as Locations);
const locationsKey = Object.keys(Locations)[locationIndex];
const restaurantKey = Object.keys(Restaurants).indexOf(locationsKey);
if (restaurantKey > -1 && food) { if (restaurantKey > -1 && food) {
const restaurant = Object.values(Restaurants)[restaurantKey]; const restaurant = Object.keys(Restaurant)[restaurantKey] as Restaurant;
setFoodChoiceList(food[restaurant]?.food); setFoodChoiceList(food[restaurant]?.food);
setClosed(food[restaurant]?.closed ?? false); setClosed(food[restaurant]?.closed ?? false);
} else { } else {
@@ -158,9 +187,9 @@ function App() {
// Navigace mezi dny pomocí klávesových šípek // Navigace mezi dny pomocí klávesových šípek
const handleKeyDown = useCallback((e: any) => { const handleKeyDown = useCallback((e: any) => {
if (e.keyCode == 37 && dayIndex != null && dayIndex > 0) { if (e.keyCode === 37 && dayIndex != null && dayIndex > 0) {
handleDayChange(dayIndex - 1); handleDayChange(dayIndex - 1);
} else if (e.keyCode == 39 && dayIndex != null && dayIndex < 4) { } else if (e.keyCode === 39 && dayIndex != null && dayIndex < 4) {
handleDayChange(dayIndex + 1); handleDayChange(dayIndex + 1);
} }
}, [dayIndex]); }, [dayIndex]);
@@ -172,35 +201,60 @@ function App() {
} }
}, [handleKeyDown]); }, [handleKeyDown]);
// Index v týdnu dnešního dne (0-6) // Stažení a nastavení easter egg obrázku
// TODO tohle má posílat server, klient je nespolehlivý useEffect(() => {
const currentDayIndex = useMemo(() => { if (auth?.login && easterEgg?.url && !eggImage) {
return (((new Date().getDay() - 1) % 7) + 7) % 7; getEasterEggImage({ path: { url: easterEgg.url } }).then(response => {
}, []) if (response.data) {
setEggImage(response.data);
// Smazání obrázku z DOMu po animaci
setTimeout(() => {
if (eggRef?.current) {
eggRef.current.remove();
}
}, (easterEgg.duration || EASTER_EGG_DEFAULT_DURATION) * 1000);
}
});
}
}, [auth?.login, easterEgg?.duration, easterEgg?.url, eggImage]);
const doAddClickFoodChoice = async (location: LunchChoice, foodIndex?: number) => {
if (document.getSelection()?.type !== 'Range') { // pouze pokud se nejedná o výběr textu
if (auth?.login) {
await addChoice({ body: { locationKey: location, foodIndex, dayIndex } });
}
}
}
const doAddChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => { const doAddChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => {
const index = Object.keys(Locations).indexOf(event.target.value as unknown as Locations); const locationKey = event.target.value as LunchChoice;
if (auth?.login) { if (auth?.login) {
await errorHandler(() => addChoice(index, undefined, dayIndex)); await addChoice({ body: { locationKey, dayIndex } });
if (foodChoiceRef.current?.value) { if (foodChoiceRef.current?.value) {
foodChoiceRef.current.value = ""; foodChoiceRef.current.value = "";
} }
choiceRef.current?.blur();
}
}
const doJdemeObed = async () => {
if (auth?.login) {
await jdemeObed();
} }
} }
const doAddFoodChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => { const doAddFoodChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => {
if (event.target.value && foodChoiceList?.length && choiceRef.current?.value) { if (event.target.value && foodChoiceList?.length && choiceRef.current?.value) {
const restaurantKey = choiceRef.current.value; const locationKey = choiceRef.current.value as LunchChoice;
if (auth?.login) { if (auth?.login) {
const locationIndex = Object.keys(Locations).indexOf(restaurantKey as unknown as Locations); await addChoice({ body: { locationKey, foodIndex: Number(event.target.value), dayIndex } });
await errorHandler(() => addChoice(locationIndex, Number(event.target.value), dayIndex));
} }
} }
} }
const doRemoveChoices = async (locationKey: string) => { const doRemoveChoices = async (locationKey: LunchChoice) => {
if (auth?.login) { if (auth?.login) {
await errorHandler(() => removeChoices(Number(locationKey), dayIndex)); await removeChoices({ body: { locationKey, dayIndex } });
// Vyresetujeme výběr, aby bylo jasné pro který případně vybíráme jídlo // Vyresetujeme výběr, aby bylo jasné pro který případně vybíráme jídlo
if (choiceRef?.current?.value) { if (choiceRef?.current?.value) {
choiceRef.current.value = ""; choiceRef.current.value = "";
@@ -211,9 +265,9 @@ function App() {
} }
} }
const doRemoveFoodChoice = async (locationKey: string, foodIndex: number) => { const doRemoveFoodChoice = async (locationKey: LunchChoice, foodIndex: number) => {
if (auth?.login) { if (auth?.login) {
await errorHandler(() => removeChoice(Number(locationKey), foodIndex, dayIndex)); await removeChoice({ body: { locationKey, foodIndex, dayIndex } });
if (choiceRef?.current?.value) { if (choiceRef?.current?.value) {
choiceRef.current.value = ""; choiceRef.current.value = "";
} }
@@ -223,6 +277,19 @@ function App() {
} }
} }
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(() => { const pizzaSuggestions = useMemo(() => {
if (!data?.pizzaList) { if (!data?.pizzaList) {
return []; return [];
@@ -242,26 +309,26 @@ function App() {
const handlePizzaChange = async (value: SelectedOptionValue | SelectedOptionValue[]) => { const handlePizzaChange = async (value: SelectedOptionValue | SelectedOptionValue[]) => {
if (auth?.login && data?.pizzaList) { if (auth?.login && data?.pizzaList) {
if (!(typeof value === 'string')) { if (typeof value !== 'string') {
throw Error('Nepodporovaný typ hodnoty'); throw Error('Nepodporovaný typ hodnoty');
} }
const s = value.split('|'); const s = value.split('|');
const pizzaIndex = Number.parseInt(s[0]); const pizzaIndex = Number.parseInt(s[0]);
const pizzaSizeIndex = Number.parseInt(s[1]); const pizzaSizeIndex = Number.parseInt(s[1]);
await addPizza(pizzaIndex, pizzaSizeIndex); await addPizza({ body: { pizzaIndex, pizzaSizeIndex } });
} }
} }
const handlePizzaDelete = async (pizzaOrder: PizzaOrder) => { const handlePizzaDelete = async (pizzaOrder: PizzaVariant) => {
await removePizza(pizzaOrder); await removePizza({ body: { pizzaOrder } });
} }
const handlePoznamkaChange = async () => { const handlePizzaPoznamkaChange = async () => {
if (poznamkaRef.current?.value && poznamkaRef.current.value.length > 100) { if (pizzaPoznamkaRef.current?.value && pizzaPoznamkaRef.current.value.length > 70) {
alert("Poznámka může mít maximálně 100 znaků"); alert("Poznámka může mít maximálně 70 znaků");
return; return;
} }
updateNote(poznamkaRef.current?.value); updatePizzaDayNote({ body: { note: pizzaPoznamkaRef.current?.value } });
} }
// const addToCart = async () => { // const addToCart = async () => {
@@ -290,9 +357,7 @@ function App() {
const handleChangeDepartureTime = async (event: React.ChangeEvent<HTMLSelectElement>) => { const handleChangeDepartureTime = async (event: React.ChangeEvent<HTMLSelectElement>) => {
if (foodChoiceList?.length && choiceRef.current?.value) { if (foodChoiceList?.length && choiceRef.current?.value) {
if (auth?.login) { await changeDepartureTime({ body: { time: event.target.value as DepartureTime, dayIndex } });
await changeDepartureTime(auth.login, event.target.value, dayIndex);
}
} }
} }
@@ -310,17 +375,32 @@ function App() {
} }
} }
const renderFoodTable = (name: string, menu: Menu) => { const renderFoodTable = (location: Restaurant, menu: RestaurantDayMenu) => {
let content; let content;
if (menu?.closed) { if (menu?.closed) {
content = <h3>Zavřeno</h3> content = <h3>Zavřeno</h3>
} else if (menu?.food?.length > 0) { } else if (menu?.food?.length && menu.food.length > 0) {
const hideSoups = settings?.hideSoups;
content = <Table striped bordered hover> content = <Table striped bordered hover>
<tbody> <tbody style={{ cursor: 'pointer' }}>
{menu.food.map((f: any, index: number) => {menu.food.map((f: Food, index: number) =>
<tr key={index}> (!hideSoups || !f.isSoup) &&
<tr key={f.name} onClick={() => doAddClickFoodChoice(location, index)}>
<td>{f.amount}</td> <td>{f.amount}</td>
<td>{f.name}</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.price}</td> <td>{f.price}</td>
</tr> </tr>
)} )}
@@ -329,14 +409,14 @@ function App() {
} else { } else {
content = <h3>Chyba načtení dat</h3> content = <h3>Chyba načtení dat</h3>
} }
return <Col md={12} lg={4}> return <Col md={12} lg={3} className='mt-3'>
<h3>{name}</h3> <h3 style={{ cursor: 'pointer' }} onClick={() => doAddClickFoodChoice(location)}>{getLunchChoiceName(location)}</h3>
{menu?.lastUpdate && <small>Poslední aktualizace: {menu.lastUpdate}</small>} {menu?.lastUpdate && <small>Poslední aktualizace: {getHumanDateTime(new Date(menu.lastUpdate))}</small>}
{content} {content}
</Col> </Col>
} }
if (!auth || !auth.login) { if (!auth?.login) {
return <Login />; return <Login />;
} }
@@ -365,105 +445,138 @@ function App() {
} }
const noOrders = data?.pizzaDay?.orders?.length === 0; const noOrders = data?.pizzaDay?.orders?.length === 0;
const canChangeChoice = dayIndex == null || dayIndex >= todayDayIndex;
const { path, url, startOffset, endOffset, duration, ...style } = easterEgg || {};
return ( 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 /> <Header />
<div className='wrapper'> <div className='wrapper'>
{data.isWeekend ? <h4>Užívejte víkend :)</h4> : <> {isTodayWeekend ? <h4>Užívejte víkend :)</h4> : <>
<Alert variant={'primary'}> <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: Poslední změny:
<ul> <ul>
<li>Ochrana proti některým Stánkovinám</li> <li>Zobrazení alergenu při najetí myší a proklik na seznam alergenů</li>
<li>Navigace mezi dny klávesovými šipkami</li> <li>Přesun přenačtení menu do samostatného dialogu</li>
<li>Podzimní atmosféra</li>
</ul> </ul>
</Alert> </Alert>
{dayIndex != null && {dayIndex != null &&
<div className='day-navigator'> <div className='day-navigator'>
{dayIndex > 0 && <FontAwesomeIcon title="Předchozí den" icon={faChevronLeft} style={{ cursor: "pointer" }} onClick={() => handleDayChange(dayIndex - 1)} />} <span title='Předchozí den'>
<h1 className='title'>{`${dayIndex === currentDayIndex ? "(Dnes) " : ""}${data.date}`}</h1> <FontAwesomeIcon icon={faChevronLeft} style={{ cursor: "pointer", visibility: dayIndex > 0 ? "initial" : "hidden" }} onClick={() => handleDayChange(dayIndex - 1)} />
{dayIndex < 4 && <FontAwesomeIcon title="Následující den" icon={faChevronRight} style={{ cursor: "pointer" }} 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> </div>
} }
<Row className='food-tables'> <Row className='food-tables'>
{food[Restaurants.SLADOVNICKA] && renderFoodTable('Sladovnická', food[Restaurants.SLADOVNICKA])} {/* TODO zjednodušit, stačí iterovat klíče typu Restaurant */}
{food[Restaurants.UMOTLIKU] && renderFoodTable('U Motlíků', food[Restaurants.UMOTLIKU])} {food['SLADOVNICKA'] && renderFoodTable('SLADOVNICKA', food['SLADOVNICKA'])}
{food[Restaurants.TECHTOWER] && renderFoodTable('TechTower', food[Restaurants.TECHTOWER])} {food['TECHTOWER'] && renderFoodTable('TECHTOWER', food['TECHTOWER'])}
{food['ZASTAVKAUMICHALA'] && renderFoodTable('ZASTAVKAUMICHALA', food['ZASTAVKAUMICHALA'])}
{food['SENKSERIKOVA'] && renderFoodTable('SENKSERIKOVA', food['SENKSERIKOVA'])}
</Row> </Row>
<div className='content-wrapper'> <div className='content-wrapper'>
<div className='content'> <div className='content'>
{dayIndex == null || dayIndex >= currentDayIndex && <> {canChangeChoice && <>
<p>{`Jak to ${dayIndex == null || dayIndex === currentDayIndex ? 'dnes' : 'tento den'} vidíš s obědem?`}</p> <p>{`Jak to ${dayIndex == null || dayIndex === todayDayIndex ? 'dnes' : 'tento den'} vidíš s obědem?`}</p>
<Form.Select ref={choiceRef} onChange={doAddChoice}> <Form.Select ref={choiceRef} onChange={doAddChoice}>
<option></option> <option></option>
{Object.entries(Locations) {Object.entries(LunchChoice)
.filter(entry => { .filter(entry => {
// TODO: wtf, cos pil, když jsi tohle psal? v2 const locationKey = entry[0] as Restaurant;
const key = entry[0]; return !food[locationKey]?.closed;
const locationIndex = Object.keys(Locations).indexOf(key as unknown as Locations);
const locationsKey = Object.keys(Locations)[locationIndex];
const restaurantKey = Object.keys(Restaurants).indexOf(locationsKey);
const v = Object.values(Restaurants)[restaurantKey];
return v == null || !food[v]?.closed;
}) })
.map(entry => <option key={entry[0]} value={entry[0]}>{entry[1]}</option>)} .map(entry => <option key={entry[0]} value={entry[0]}>{getLunchChoiceName(entry[1])}</option>)}
</Form.Select> </Form.Select>
<small>Je možné vybrat jen jednu možnost. Výběr jiné odstraní předchozí.</small> <small>Je možné vybrat jen jednu možnost. Výběr jiné odstraní předchozí.</small>
{foodChoiceList && !closed && <> {foodChoiceList && !closed && <>
<p style={{ marginTop: "10px" }}>Na co dobrého? <small>(nepovinné)</small></p> <p style={{ marginTop: "10px" }}>Na co dobrého? <small>(nepovinné)</small></p>
<Form.Select ref={foodChoiceRef} onChange={doAddFoodChoice}> <Form.Select ref={foodChoiceRef} onChange={doAddFoodChoice}>
<option></option> <option></option>
{foodChoiceList.map((food, index) => <option key={index} value={index}>{food.name}</option>)} {foodChoiceList.map((food, index) => <option key={food.name} value={index}>{food.name}</option>)}
</Form.Select> </Form.Select>
</>} </>}
{foodChoiceList && !closed && <> {foodChoiceList && !closed && <>
<p style={{ marginTop: "10px" }}>V kolik hodin preferuješ odchod?</p> <p style={{ marginTop: "10px" }}>V kolik hodin preferuješ odchod?</p>
<Form.Select ref={departureChoiceRef} onChange={handleChangeDepartureTime}> <Form.Select ref={departureChoiceRef} onChange={handleChangeDepartureTime}>
<option></option> <option></option>
{DEPARTURE_TIMES.map(time => <option key={time} value={time}>{time}</option>)} {Object.values(DepartureTime)
.filter(time => isInTheFuture(time))
.map(time => <option key={time} value={time}>{time}</option>)}
</Form.Select> </Form.Select>
</>} </>}
</>} </>}
{Object.keys(data.choices).length > 0 ? {Object.keys(data.choices).length > 0 ?
<Table bordered className='mt-5'> <Table bordered className='mt-5'>
<tbody> <tbody>
{Object.keys(data.choices).map((locationKey: string) => { {Object.keys(data.choices).map(key => {
const locationName = Object.values(Locations)[Number(locationKey)]; const locationKey = key as LunchChoice;
const locationLoginList = Object.entries(data.choices[Number(locationKey)]); const locationName = getLunchChoiceName(locationKey);
const loginObject = data.choices[locationKey];
if (!loginObject) {
return;
}
const locationLoginList = Object.entries(loginObject);
const locationPickCount = locationLoginList.length
return ( return (
<tr key={locationKey}> <tr key={key}>
<td>{locationName}</td> {(locationPickCount ?? 0) > 1 ? (
<td>{locationName} ({locationPickCount})</td>
) : (
<td>{locationName}</td>)}
<td className='p-0'> <td className='p-0'>
<Table> <Table>
<tbody> <tbody>
{locationLoginList.map((entry: [string, FoodChoices], index) => { {locationLoginList.map((entry: [string, UserLunchChoice], index) => {
const login = entry[0]; const login = entry[0];
const userPayload = entry[1]; const userPayload = entry[1];
const userChoices = userPayload?.options; const userChoices = userPayload?.selectedFoods;
const trusted = userPayload?.trusted || false; const trusted = userPayload?.trusted || false;
return <tr key={index}> return <tr key={entry[0]}>
<td className='text-nowrap'> <td>
{trusted && <span className='trusted-icon'> {trusted && <span className='trusted-icon' title='Uživatel ověřený doménovým přihlášením'>
<FontAwesomeIcon title='Uživatel ověřený doménovým přihlášením' icon={faCircleCheck} style={{ cursor: "help" }} /> <FontAwesomeIcon icon={faCircleCheck} style={{ cursor: "help" }} />
</span>} </span>}
{login} {login}
{userPayload.departureTime && <small> ({userPayload.departureTime})</small>} {userPayload.departureTime && <small> ({userPayload.departureTime})</small>}
{login === auth.login && <FontAwesomeIcon onClick={() => { {userPayload.note && <span style={{ fontSize: 'small' }}> ({userPayload.note})</span>}
doRemoveChoices(locationKey); {login !== auth.login && canChangeChoice && userPayload?.note?.length && <span title='Převzít poznámku'>
}} title={`Odstranit volbu ${locationName}, včetně případných zvolených jídel`} className='trash-icon' icon={faTrashCan} />} <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> </td>
{userChoices?.length && food ? <td className='w-100'> {userChoices?.length && food ? <td>
<ul> <ul>
{userChoices?.map(foodIndex => { {userChoices?.map(foodIndex => {
const locationsKey = Object.keys(Locations)[Number(locationKey)] const restaurantKey = key as Restaurant;
const restaurantKey = Object.keys(Restaurants).indexOf(locationsKey); const foodName = food[restaurantKey]?.food?.[foodIndex].name;
const restaurant = Object.values(Restaurants)[restaurantKey];
const foodName = food[restaurant]?.food[foodIndex].name;
return <li key={foodIndex}> return <li key={foodIndex}>
{foodName} {foodName}
{login === auth.login && <FontAwesomeIcon onClick={() => { {login === auth.login && canChangeChoice &&
doRemoveFoodChoice(locationKey, foodIndex); <span title={`Odstranit ${foodName}`}>
}} title={`Odstranit ${foodName}`} className='trash-icon' icon={faTrashCan} />} <FontAwesomeIcon onClick={() => {
doRemoveFoodChoice(restaurantKey, foodIndex);
}} className='action-icon' icon={faTrashCan} />
</span>}
</li> </li>
})} })}
</ul> </ul>
@@ -482,14 +595,24 @@ function App() {
: <div className='mt-5'><i>Zatím nikdo nehlasoval...</i></div> : <div className='mt-5'><i>Zatím nikdo nehlasoval...</i></div>
} }
</div> </div>
{dayIndex === currentDayIndex && {dayIndex === todayDayIndex &&
<div className='mt-5'> <div className='mt-5'>
{!data.pizzaDay && {!data.pizzaDay &&
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<p>Pro dnešní den není aktuálně založen Pizza day.</p> <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 () => { <Button onClick={async () => {
await createPizzaDay(); setLoadingPizzaDay(true);
await createPizzaDay().then(() => setLoadingPizzaDay(false));
}}>Založit Pizza day</Button> }}>Založit Pizza day</Button>
<Button onClick={doJdemeObed} style={{ marginLeft: "14px" }}>Jdeme na oběd !</Button>
</>
}
</div> </div>
} }
{data.pizzaDay && {data.pizzaDay &&
@@ -525,9 +648,6 @@ function App() {
<Button className='danger mb-3' title="Umožní znovu editovat objednávky." onClick={async () => { <Button className='danger mb-3' title="Umožní znovu editovat objednávky." onClick={async () => {
await unlockPizzaDay(); await unlockPizzaDay();
}}>Odemknout</Button> }}>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 () => { <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(); await finishOrder();
}}>Objednáno</Button> }}>Objednáno</Button>
@@ -545,7 +665,7 @@ function App() {
await lockPizzaDay(); await lockPizzaDay();
}}>Vrátit do "uzamčeno"</Button> }}>Vrátit do "uzamčeno"</Button>
<Button className='danger mb-3' style={{ marginLeft: '20px' }} title="Nastaví stav na 'Doručeno' - koncový stav." onClick={async () => { <Button className='danger mb-3' style={{ marginLeft: '20px' }} title="Nastaví stav na 'Doručeno' - koncový stav." onClick={async () => {
await finishDelivery(bank?.bankAccount, bank?.holderName); await finishDelivery({ body: { bankAccount: settings?.bankAccount, bankAccountHolder: settings?.holderName } });
}}>Doručeno</Button> }}>Doručeno</Button>
</div> </div>
} }
@@ -554,7 +674,7 @@ function App() {
{ {
data.pizzaDay.state === PizzaDayState.DELIVERED && data.pizzaDay.state === PizzaDayState.DELIVERED &&
<div> <div>
<p>Pizzy byly doručeny. Objednávku můžete uhradit pomocí QR kódu níže.</p> <p>{`Pizzy byly doručeny.${myOrder?.hasQr ? ` Objednávku můžete uživateli ${data.pizzaDay.creator} uhradit pomocí QR kódu níže.` : ''}`}</p>
</div> </div>
} }
</div> </div>
@@ -565,39 +685,45 @@ function App() {
options={pizzaSuggestions} options={pizzaSuggestions}
placeholder='Vyhledat pizzu...' placeholder='Vyhledat pizzu...'
onChange={handlePizzaChange} onChange={handlePizzaChange}
onBlur={_ => { }}
onFocus={_ => { }}
/> />
Poznámka: <input ref={poznamkaRef} className='mt-3' type="text" onKeyDown={event => { Poznámka: <input ref={pizzaPoznamkaRef} className='mt-3' type="text" onKeyDown={event => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
handlePoznamkaChange(); handlePizzaPoznamkaChange();
} }
event.stopPropagation();
}} /> }} />
<Button <Button
style={{ marginLeft: '20px' }} style={{ marginLeft: '20px' }}
disabled={!myOrder?.pizzaList?.length} disabled={!myOrder?.pizzaList?.length}
onClick={handlePoznamkaChange}> onClick={handlePizzaPoznamkaChange}>
Uložit Uložit
</Button> </Button>
</div> </div>
} }
<PizzaOrderList state={data.pizzaDay.state} orders={data.pizzaDay.orders} onDelete={handlePizzaDelete} /> <PizzaOrderList state={data.pizzaDay.state!} orders={data.pizzaDay.orders!} onDelete={handlePizzaDelete} creator={data.pizzaDay.creator!} />
{ {
data.pizzaDay.state === PizzaDayState.DELIVERED && myOrder?.hasQr && data.pizzaDay.state === PizzaDayState.DELIVERED && myOrder?.hasQr ?
<div className='qr-code'> <div className='qr-code'>
<h3>QR platba</h3> <h3>QR platba</h3>
<div>Částka: {myOrder.totalPrice} </div> <img src={`/api/qr?login=${auth.login}`} alt='QR kód' />
<img src={getQrUrl(auth.login)} alt='QR kód' /> </div> : null
<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>
} }
</div> </div>
} }
</div> </div>
</>} </> || "Jejda, něco se nám nepovedlo :("}
</div> </div>
<FallingLeaves
numLeaves={LEAF_PRESETS.NORMAL}
leafVariants={LEAF_COLOR_THEMES.AUTUMN}
/>
<Footer /> <Footer />
</> <NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} />
</div>
); );
} }

33
client/src/AppRoutes.tsx Normal file
View File

@@ -0,0 +1,33 @@
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

@@ -0,0 +1,31 @@
.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

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

View File

@@ -1,14 +1,4 @@
/** 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"; const TOKEN_KEY = "token";
@@ -26,8 +16,8 @@ export const storeToken = (token: string) => {
* *
* @returns token nebo null * @returns token nebo null
*/ */
export const getToken = (): string | null => { export const getToken = (): string | undefined => {
return localStorage.getItem(TOKEN_KEY); return localStorage.getItem(TOKEN_KEY) ?? undefined;
} }
/** /**
@@ -36,3 +26,87 @@ export const getToken = (): string | null => {
export const deleteToken = () => { export const deleteToken = () => {
localStorage.removeItem(TOKEN_KEY); 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,7 +1,12 @@
import { Navbar } from "react-bootstrap"; import { Navbar } from "react-bootstrap";
export default function Footer() { export default function Footer() {
return <Navbar className="text-light" variant='dark' expand="lg" style={{ display: "flex", justifyContent: "center" }}> return <Navbar className="text-light" variant='dark' expand="lg" style={{
<span>🄯 Kancelář 51, Marbes s.r.o. Žádná práva nevyhrazena. TODO a zdrojové kódy dostupné <a href="https://gitea.melancholik.eu/mates/Luncher">zde</a>.</span> 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 > </Navbar >
} }

View File

@@ -1,22 +1,47 @@
import React, { useRef, useState } from "react"; import { useEffect, useState } from "react";
import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap"; import { Navbar, Nav, NavDropdown } from "react-bootstrap";
import { useAuth } from "../context/auth"; import { useAuth } from "../context/auth";
import { useBank } from "../context/bank"; import SettingsModal from "./modals/SettingsModal";
import { useSettings } from "../context/settings";
import FeaturesVotingModal from "./modals/FeaturesVotingModal";
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
import RefreshMenuModal from "./modals/RefreshMenuModal";
import { useNavigate } from "react-router";
import { STATS_URL } from "../AppRoutes";
import { FeatureRequest, getVotes, updateVote } from "../../../types";
export default function Header() { export default function Header() {
const auth = useAuth(); const auth = useAuth();
const bank = useBank(); const settings = useSettings();
const [modalOpen, setModalOpen] = useState<boolean>(false); const navigate = useNavigate();
const bankAccountRef = useRef<HTMLInputElement>(null); const [settingsModalOpen, setSettingsModalOpen] = useState<boolean>(false);
const nameRef = useRef<HTMLInputElement>(null); const [votingModalOpen, setVotingModalOpen] = useState<boolean>(false);
const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false);
const [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState<boolean>(false);
const [featureVotes, setFeatureVotes] = useState<FeatureRequest[] | undefined>([]);
const openBankSettings = () => { useEffect(() => {
setModalOpen(true); if (auth?.login) {
getVotes().then(response => {
setFeatureVotes(response.data);
})
}
}, [auth?.login]);
const closeSettingsModal = () => {
setSettingsModalOpen(false);
} }
const closeModal = () => { const closeVotingModal = () => {
setModalOpen(false); setVotingModalOpen(false);
}
const closePizzaModal = () => {
setPizzaModalOpen(false);
}
const closeRefreshMenuModal = () => {
setRefreshMenuModalOpen(false);
} }
const isValidInteger = (str: string) => { const isValidInteger = (str: string) => {
@@ -29,14 +54,14 @@ export default function Header() {
return n !== Infinity && String(n) === str && n >= 0; return n !== Infinity && String(n) === str && n >= 0;
} }
const save = () => { const saveSettings = (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean) => {
if (bankAccountRef.current?.value) { if (bankAccountNumber) {
try { try {
// Validace kódu banky // Validace kódu banky
if (bankAccountRef.current?.value.indexOf('/') < 0) { if (bankAccountNumber.indexOf('/') < 0) {
throw Error("Číslo účtu neobsahuje lomítko/kód banky") throw Error("Číslo účtu neobsahuje lomítko/kód banky")
} }
const split = bankAccountRef.current?.value.split("/"); const split = bankAccountNumber.split("/");
if (split[1].length !== 4) { if (split[1].length !== 4) {
throw Error("Kód banky musí být 4 číslice") throw Error("Kód banky musí být 4 číslice")
} }
@@ -57,7 +82,7 @@ export default function Header() {
cislo = cislo.padStart(16, '0'); cislo = cislo.padStart(16, '0');
} }
let sum = 0; let sum = 0;
for (var i = 0; i < cislo.length; i++) { for (let i = 0; i < cislo.length; i++) {
const char = cislo.charAt(i); const char = cislo.charAt(i);
const order = (cislo.length - 1) - i; const order = (cislo.length - 1) - i;
const weight = (2 ** order) % 11; const weight = (2 ** order) % 11;
@@ -71,39 +96,42 @@ export default function Header() {
return return
} }
} }
bank?.setBankAccountNumber(bankAccountRef.current?.value); settings?.setBankAccountNumber(bankAccountNumber);
bank?.setBankAccountHolderName(nameRef.current?.value); settings?.setBankAccountHolderName(bankAccountHolderName);
closeModal(); 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);
} }
return <Navbar variant='dark' expand="lg"> return <Navbar variant='dark' expand="lg">
<Navbar.Brand>Luncher</Navbar.Brand> <Navbar.Brand href="/">Luncher</Navbar.Brand>
<Navbar.Toggle aria-controls="basic-navbar-nav" /> <Navbar.Toggle aria-controls="basic-navbar-nav" />
<Navbar.Collapse id="basic-navbar-nav"> <Navbar.Collapse id="basic-navbar-nav">
<Nav className="nav"> <Nav className="nav">
<NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown"> <NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown">
<NavDropdown.Item onClick={openBankSettings}>Nastavit číslo účtu</NavDropdown.Item> <NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item>
<NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item>
<NavDropdown.Item onClick={() => setVotingModalOpen(true)}>Hlasovat o nových funkcích</NavDropdown.Item>
<NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item>
<NavDropdown.Divider />
<NavDropdown.Item onClick={auth?.logout}>Odhlásit se</NavDropdown.Item> <NavDropdown.Item onClick={auth?.logout}>Odhlásit se</NavDropdown.Item>
</NavDropdown> </NavDropdown>
</Nav> </Nav>
</Navbar.Collapse> </Navbar.Collapse>
<Modal show={modalOpen} onHide={closeModal} size="lg"> <SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
<Modal.Header closeButton> <RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
<Modal.Title>Bankovní účet</Modal.Title> <FeaturesVotingModal isOpen={votingModalOpen} onClose={closeVotingModal} onChange={saveFeatureVote} initialValues={featureVotes} />
</Modal.Header> <PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
<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> </Navbar>
} }

View File

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

View File

@@ -1,18 +1,24 @@
import React from "react";
import { Table } from "react-bootstrap"; import { Table } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import PizzaOrderRow from "./PizzaOrderRow";
import { faTrashCan } from "@fortawesome/free-regular-svg-icons"; import { PizzaDayState, PizzaOrder, PizzaVariant, updatePizzaFee } from "../../../types";
import { useAuth } from "../context/auth";
import { Order, PizzaDayState, PizzaOrder } from "../types";
export default function PizzaOrderList({ state, orders, onDelete }: { state: PizzaDayState, orders: Order[], onDelete: (pizzaOrder: PizzaOrder) => void }) { type Props = {
const auth = useAuth(); 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 } });
}
if (!orders?.length) { if (!orders?.length) {
return <p className="mt-3"><i>Zatím žádné objednávky...</i></p> return <p className="mt-3"><i>Zatím žádné objednávky...</i></p>
} }
const total = orders.map(order => order.pizzaList.map(o => o.price).reduce((total, i) => total + i)).reduce((total, i) => total + i); const total = orders.reduce((total, order) => total + order.totalPrice, 0);
return <Table className="mt-3" striped bordered hover> return <Table className="mt-3" striped bordered hover>
<thead> <thead>
@@ -20,28 +26,16 @@ export default function PizzaOrderList({ state, orders, onDelete }: { state: Piz
<th>Jméno</th> <th>Jméno</th>
<th>Objednávka</th> <th>Objednávka</th>
<th>Poznámka</th> <th>Poznámka</th>
<th>Příplatek</th>
<th>Cena</th> <th>Cena</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{orders.map(order => <tr key={order.customer}> {orders.map(order => <tr key={order.customer}>
<td>{order.customer}</td> <PizzaOrderRow creator={creator} state={state} order={order} onDelete={onDelete} onFeeModalSave={saveFees} />
<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>)}
<tr style={{ fontWeight: 'bold' }}> <tr style={{ fontWeight: 'bold' }}>
<td colSpan={3}>Celkem</td> <td colSpan={4}>Celkem</td>
<td>{`${total}`}</td> <td>{`${total}`}</td>
</tr> </tr>
</tbody> </tbody>

View File

@@ -0,0 +1,47 @@
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

@@ -0,0 +1,45 @@
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

@@ -0,0 +1,36 @@
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

@@ -0,0 +1,45 @@
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

@@ -0,0 +1,137 @@
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

@@ -0,0 +1,105 @@
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

@@ -0,0 +1,42 @@
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, useState } from "react" import React, { ReactNode, useContext, useEffect, useState } from "react"
import { useEffect } from "react"
import { useJwt } from "react-jwt"; import { useJwt } from "react-jwt";
import { deleteToken, getToken, storeToken } from "../Utils"; import { deleteToken, getToken, storeToken } from "../Utils";
export type AuthContextProps = { export type AuthContextProps = {
login?: string, login?: string,
trusted?: boolean,
setToken: (name: string) => void, setToken: (name: string) => void,
logout: () => void, logout: () => void,
} }
@@ -15,7 +15,7 @@ type ContextProps = {
const authContext = React.createContext<AuthContextProps | null>(null); const authContext = React.createContext<AuthContextProps | null>(null);
export function ProvideAuth(props: ContextProps) { export function ProvideAuth(props: Readonly<ContextProps>) {
const auth = useProvideAuth(); const auth = useProvideAuth();
return <authContext.Provider value={auth}>{props.children}</authContext.Provider> return <authContext.Provider value={auth}>{props.children}</authContext.Provider>
} }
@@ -26,8 +26,9 @@ export const useAuth = () => {
function useProvideAuth(): AuthContextProps { function useProvideAuth(): AuthContextProps {
const [loginName, setLoginName] = useState<string | undefined>(); const [loginName, setLoginName] = useState<string | undefined>();
const [token, setToken] = useState<string | null>(getToken()); const [trusted, setTrusted] = useState<boolean | undefined>();
const { decodedToken } = useJwt(token || ''); const [token, setToken] = useState<string | undefined>(getToken());
const { decodedToken } = useJwt(token ?? '');
useEffect(() => { useEffect(() => {
if (token && token.length > 0) { if (token && token.length > 0) {
@@ -40,16 +41,19 @@ function useProvideAuth(): AuthContextProps {
useEffect(() => { useEffect(() => {
if (decodedToken) { if (decodedToken) {
setLoginName((decodedToken as any).login); setLoginName((decodedToken as any).login);
setTrusted((decodedToken as any).trusted);
} else { } else {
setLoginName(undefined); setLoginName(undefined);
setTrusted(undefined);
} }
}, [decodedToken]); }, [decodedToken]);
function logout() { function logout() {
const trusted = (decodedToken as any).trusted; const trusted = (decodedToken as any).trusted;
const logoutUrl = (decodedToken as any).logoutUrl; const logoutUrl = (decodedToken as any).logoutUrl;
setToken(null); setToken(undefined);
setLoginName(undefined); setLoginName(undefined);
setTrusted(undefined);
if (trusted && logoutUrl?.length) { if (trusted && logoutUrl?.length) {
window.location.replace(logoutUrl); window.location.replace(logoutUrl);
} }
@@ -57,6 +61,7 @@ function useProvideAuth(): AuthContextProps {
return { return {
login: loginName, login: loginName,
trusted,
setToken, setToken,
logout, logout,
} }

View File

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

View File

@@ -18,8 +18,3 @@ export const SocketContext = React.createContext();
export const EVENT_CONNECT = 'connect'; export const EVENT_CONNECT = 'connect';
export const EVENT_DISCONNECT = 'disconnect'; export const EVENT_DISCONNECT = 'disconnect';
export const EVENT_MESSAGE = 'message'; 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';

41
client/src/enums.ts Normal file
View File

@@ -0,0 +1,41 @@
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,25 +1,38 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from './App';
import { SocketContext, socket } from './context/socket';
import { ProvideAuth } from './context/auth'; import { ProvideAuth } from './context/auth';
import { ToastContainer } from 'react-toastify';
import { ProvideBank } from './context/bank';
import 'react-toastify/dist/ReactToastify.css'; import 'react-toastify/dist/ReactToastify.css';
import './index.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( const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement document.getElementById('root') as HTMLElement
); );
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter>
<ProvideAuth> <ProvideAuth>
<ProvideBank> <AppRoutes />
<SocketContext.Provider value={socket}>
<App />
<ToastContainer />
</SocketContext.Provider>
</ProvideBank>
</ProvideAuth> </ProvideAuth>
</BrowserRouter>
</React.StrictMode> </React.StrictMode>
); );

View File

@@ -0,0 +1,16 @@
.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

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

View File

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

View File

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

1
client/vite-env.d.ts vendored Normal file
View File

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

16
client/vite.config.ts Normal file
View File

@@ -0,0 +1,16 @@
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',
}
},
})

2015
client/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,3 @@
version: '3.8'
services: services:
redis: redis:
image: redis/redis-stack-server:7.2.0-RC3 image: redis/redis-stack-server:7.2.0-RC3

View File

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

View File

@@ -1,5 +1,18 @@
export NODE_ENV=development #!/bin/bash
yarn install # Spustí server a klienta v samostatných panelech uvnitř stejného tmux okna.
cd server && yarn start & # Pokud už daná tmux session existuje, pouze se k ní připojí.
cd client && yarn start &
wait 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

View File

@@ -24,3 +24,17 @@
# To je užitečné pro odesílání upozornění na různé servery Gotify s různými klíči API. # To je užitečné pro odesílání upozornění na různé servery Gotify s různými klíči API.
# Struktura dat je ve formátu JSON a je uložena jako řetězec. # 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"]}]' # 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,5 +1,7 @@
/node_modules /data
/dist /dist
data.json /resources/easterEggs
/src/gen
.env.production .env.production
.env.development .env.development
.easter-eggs.json

6
server/babel.config.js Normal file
View File

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

View File

@@ -6,26 +6,33 @@
"private": true, "private": true,
"scripts": { "scripts": {
"start": "ts-node src/index.ts", "start": "ts-node src/index.ts",
"startReload": "nodemon src/index.ts", "startReload": "nodemon --watch src src/index.ts",
"build": "tsc -p ." "build": "tsc -p .",
"test": "jest"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.17", "@babel/core": "^7.28.5",
"@types/jsonwebtoken": "^9.0.2", "@babel/preset-env": "^7.28.5",
"@types/node": "^20.2.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/request-promise": "^4.1.48", "@types/request-promise": "^4.1.48",
"nodemon": "^2.0.22", "babel-jest": "^30.2.0",
"jest": "^30.2.0",
"nodemon": "^3.1.10",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "^5.0.2" "typescript": "^5.9.3"
}, },
"dependencies": { "dependencies": {
"axios": "^1.4.0", "axios": "^1.13.2",
"cheerio": "^1.0.0-rc.12", "cheerio": "^1.1.2",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.1.3", "dotenv": "^17.2.3",
"express": "^4.18.2", "express": "^5.1.0",
"jsonwebtoken": "^9.0.0", "jsonwebtoken": "^9.0.0",
"redis": "^4.6.7", "redis": "^5.9.0",
"simple-json-db": "^2.0.0", "simple-json-db": "^2.0.0",
"socket.io": "^4.6.1" "socket.io": "^4.6.1"
} }

View File

@@ -1,5 +1,6 @@
import axios from 'axios'; import axios from 'axios';
import { load } from 'cheerio'; import { load } from 'cheerio';
import { getPizzaListMock } from './mock';
// TODO přesunout do types // TODO přesunout do types
type PizzaSize = { type PizzaSize = {
@@ -35,23 +36,29 @@ const boxPrices: { [key: string]: number } = {
/** /**
* Stáhne a scrapne aktuální pizzy ze stránek Pizza Chefie. * Stáhne a scrapne aktuální pizzy ze stránek Pizza Chefie.
*
* @param mock zda vrátit pouze mock data
*/ */
export async function downloadPizzy(): Promise<Pizza[]> { 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));
}
// Získáme seznam pizz // Získáme seznam pizz
const html = await axios.get(pizzyUrl).then(res => res.data); const html = await axios.get(pizzyUrl).then(res => res.data);
const $ = load(html); const $ = load(html);
const links = $('.vypisproduktu > div > h4 > a'); const links = $('.vypisproduktu > div > h4 > a');
const urls = []; const urls = [];
for (let i = 0; i < links.length; i++) { for (const element of links) {
if (links[i].name === 'a' && links[i].attribs?.href) { if (element.name === 'a' && element.attribs?.href) {
const pizzaUrl = links[i].attribs?.href; const pizzaUrl = element.attribs?.href;
urls.push(buildPizzaUrl(pizzaUrl)); urls.push(buildPizzaUrl(pizzaUrl));
} }
} }
// Scrapneme jednotlivé pizzy // Scrapneme jednotlivé pizzy
const result: Pizza[] = []; const result: Pizza[] = [];
for (let i = 0; i < urls.length; i++) { for (const element of urls) {
const pizzaUrl = urls[i]; const pizzaUrl = element;
const pizzaHtml = await axios.get(pizzaUrl).then(res => res.data); const pizzaHtml = await axios.get(pizzaUrl).then(res => res.data);
// Název // Název
const name = $('.produkt > h2', pizzaHtml).first().text() const name = $('.produkt > h2', pizzaHtml).first().text()

View File

@@ -1,16 +1,21 @@
import express from "express"; import express from "express";
import { Server } from "socket.io";
import bodyParser from "body-parser"; import bodyParser from "body-parser";
import cors from 'cors'; import cors from 'cors';
import { addChoice, addPizzaOrder, createPizzaDay, deletePizzaDay, finishPizzaDelivery, finishPizzaOrder, getData, getDateForWeekIndex, getPizzaList, getToday, lockPizzaDay, removeChoice, removeChoices, removePizzaOrder, unlockPizzaDay, updateDepartureTime, updateNote } from "./service"; import { getData, getDateForWeekIndex } from "./service";
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import path from 'path'; import path from 'path';
import { getQr } from "./qr"; import { getQr } from "./qr";
import { generateToken, getLogin, getTrusted, verify } from "./auth"; import { generateToken, verify } from "./auth";
import { InsufficientPermissions, getDayOfWeekIndex } from "./utils"; 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";
const ENVIRONMENT = process.env.NODE_ENV || 'production'; const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
dotenv.config({ path: path.resolve(__dirname, `./.env.${ENVIRONMENT}`) }); dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
// Validace nastavení JWT tokenu - nemá bez něj smysl vůbec povolit server spustit // Validace nastavení JWT tokenu - nemá bez něj smysl vůbec povolit server spustit
if (!process.env.JWT_SECRET) { if (!process.env.JWT_SECRET) {
@@ -19,84 +24,65 @@ if (!process.env.JWT_SECRET) {
const app = express(); const app = express();
const server = require("http").createServer(app); const server = require("http").createServer(app);
const io = new Server(server, { initWebsocket(server);
cors: {
origin: "*",
},
});
// Body-parser middleware for parsing JSON // Body-parser middleware for parsing JSON
app.use(bodyParser.json()); app.use(bodyParser.json());
// app.use(express.json());
app.use(cors({ app.use(cors({
origin: '*' origin: '*'
})); }));
// app.use((req, res, next) => { // Zapínatelný login přes hlavičky - pokud je zapnutý nepovolí "basicauth"
// console.log("--- Request ---") const HTTP_REMOTE_USER_ENABLED = process.env.HTTP_REMOTE_USER_ENABLED === 'true' || false;
// console.log(req.url); const HTTP_REMOTE_USER_HEADER_NAME = process.env.HTTP_REMOTE_USER_HEADER_NAME ?? 'remote-user';
// console.log(req.baseUrl); if (HTTP_REMOTE_USER_ENABLED) {
// console.log(req.originalUrl); if (!process.env.HTTP_REMOTE_TRUSTED_IPS) {
// console.log(req.path); throw new Error('Je zapnutý login z hlaviček, ale není nastaven rozsah adres ze kterých hlavička může přijít.');
// next();
// });
app.use(express.static('public'))
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.');
} }
/**
* 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: any) => {
if (req.body.dayIndex == null) {
throw Error(`Nebyl předán index dne v týdnu.`);
}
const todayDayIndex = getDayOfWeekIndex(getToday());
const dayIndex = parseInt(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;
}
// ----------- Metody nevyžadující token -------------- // ----------- Metody nevyžadující token --------------
app.get("/api/whoami", (req, res) => { app.get("/api/whoami", (req, res) => {
res.send(req.header('remote-user')); if (!HTTP_REMOTE_USER_ENABLED) {
res.status(403).json({ error: 'Není zapnuté přihlášení z hlaviček' });
}
if(process.env.ENABLE_HEADERS_LOGGING === 'yes'){
delete req.headers["cookie"]
console.log(req.headers)
}
res.send(req.header(HTTP_REMOTE_USER_HEADER_NAME));
}) })
app.post("/api/login", (req, res) => { app.post("/api/login", (req, res) => {
if (HTTP_REMOTE_USER_ENABLED) { // je rovno app.enabled('trust proxy')
// Autentizace pomocí trusted headers // Autentizace pomocí trusted headers
const remoteUser = req.header('remote-user'); const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
const remoteName = req.header('remote-name'); //const remoteName = req.header('remote-name');
if (remoteUser && remoteUser.length > 0 && remoteName && remoteName.length > 0) { if (remoteUser && remoteUser.length > 0 ) {
res.status(200).json(generateToken(Buffer.from(remoteName, 'latin1').toString(), true)); res.status(200).json(generateToken(Buffer.from(remoteUser, 'latin1').toString(), true));
return; } else {
throw Error("Je zapnuto přihlášení přes hlavičky, ale nepřišla hlavička nebo ??");
} }
} else {
// Klasická autentizace loginem // Klasická autentizace loginem
if (!req.body?.login || req.body.login.trim().length === 0) { if (!req.body?.login || req.body.login.trim().length === 0) {
throw Error("Nebyl předán login"); throw Error("Nebyl předán login");
} }
// TODO zavést podmínky pro délku loginu (min i max) // 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, false));
}
}); });
// TODO dočasné řešení - QR se zobrazuje přes <img>, nemáme sem jak dostat token // TODO dočasné řešení - QR se zobrazuje přes <img>, nemáme sem jak dostat token
app.get("/api/qr", (req, res) => { app.get("/api/qr", (req, res) => {
// const login = getLogin(parseToken(req));
if (!req.query?.login) { if (!req.query?.login) {
throw Error("Nebyl předán login"); throw Error("Nebyl předán login");
} }
@@ -110,14 +96,24 @@ app.get("/api/qr", (req, res) => {
// ---------------------------------------------------- // ----------------------------------------------------
// Přeskočení auth pro refresh dat xd
app.use("/api/food/refresh", refreshMetoda);
/** Middleware ověřující JWT token */ /** Middleware ověřující JWT token */
app.use((req, res, next) => { app.use("/api/", (req, res, next) => {
const userHeader = req.header('remote-user'); if (HTTP_REMOTE_USER_ENABLED) {
const nameHeader = req.header('remote-name'); // Autentizace pomocí trusted headers
const emailHeader = req.header('remote-email'); const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
if (userHeader !== undefined && nameHeader !== undefined) { if(process.env.ENABLE_HEADERS_LOGGING === 'yes'){
const remoteName = Buffer.from(nameHeader, 'latin1').toString(); delete req.headers["cookie"]
console.log("Tvuj username, name a email: %s, %s, %s.", userHeader, remoteName, emailHeader); console.log(req.headers)
}
if (remoteUser && remoteUser.length > 0) {
const remoteName = Buffer.from(remoteUser, 'latin1').toString();
if (ENVIRONMENT !== "production") {
console.log("Tvuj username: %s.", remoteName);
}
}
} }
if (!req.headers.authorization) { if (!req.headers.authorization) {
return res.status(401).json({ error: 'Nebyl předán autentizační token' }); return res.status(401).json({ error: 'Nebyl předán autentizační token' });
@@ -141,174 +137,15 @@ app.get("/api/data", async (req, res) => {
res.status(200).json(await getData(date)); res.status(200).json(await getData(date));
}); });
/** Založí pizza day pro aktuální den, za předpokladu že dosud neexistuje. */ // Ostatní routes
app.post("/api/createPizzaDay", async (req, res) => { app.use("/api/pizzaDay", pizzaDayRoutes);
const login = getLogin(parseToken(req)); app.use("/api/food", foodRoutes);
const data = await createPizzaDay(login); app.use("/api/voting", votingRoutes);
res.status(200).json(data); app.use("/api/easterEggs", easterEggRoutes);
io.emit("message", data); app.use("/api/stats", statsRoutes);
});
/** Smaže pizza day pro aktuální den, za předpokladu že existuje. */ app.use('/stats', express.static('public'));
app.post("/api/deletePizzaDay", async (req, res) => { app.use(express.static('public'));
const login = getLogin(parseToken(req));
const data = await deletePizzaDay(login);
io.emit("message", data);
});
app.post("/api/addPizza", async (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;
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]);
io.emit("message", data);
res.status(200).json({});
});
app.post("/api/removePizza", async (req, 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);
io.emit("message", data);
res.status(200).json({});
});
app.post("/api/lockPizzaDay", async (req, res) => {
const login = getLogin(parseToken(req));
const data = await lockPizzaDay(login);
io.emit("message", data);
res.status(200).json({});
});
app.post("/api/unlockPizzaDay", async (req, res) => {
const login = getLogin(parseToken(req));
const data = await unlockPizzaDay(login);
io.emit("message", data);
res.status(200).json({});
});
app.post("/api/finishOrder", async (req, res) => {
const login = getLogin(parseToken(req));
const data = await finishPizzaOrder(login);
io.emit("message", data);
res.status(200).json({});
});
app.post("/api/finishDelivery", async (req, res) => {
const login = getLogin(parseToken(req));
const data = await finishPizzaDelivery(login, req.body.bankAccount, req.body.bankAccountHolder);
io.emit("message", data);
res.status(200).json({});
});
app.post("/api/addChoice", async (req, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
if (req.body.locationIndex > -1) {
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.locationIndex, req.body.foodIndex, date);
io.emit("message", data);
return res.status(200).json(data);
} catch (e: any) { next(e) }
}
return res.status(400); // TODO přidat popis chyby
});
app.post("/api/removeChoices", async (req, 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.locationIndex, date);
io.emit("message", data);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
app.post("/api/removeChoice", async (req, 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.locationIndex, req.body.foodIndex, date);
io.emit("message", data);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
// TODO přejmenovat, ať je jasné, že to patří k Pizza day
app.post("/api/updateNote", async (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 = await updateNote(login, req.body.note);
io.emit("message", data);
res.status(200).json(data);
});
app.post("/api/changeDepartureTime", async (req, res) => {
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);
}
const data = await updateDepartureTime(login, req.body?.time, date);
io.emit("message", data);
res.status(200).json(data);
});
// Middleware pro zpracování chyb // Middleware pro zpracování chyb
app.use((err: any, req: any, res: any, next: any) => { app.use((err: any, req: any, res: any, next: any) => {
@@ -320,20 +157,8 @@ app.use((err: any, req: any, res: any, next: any) => {
next(); next();
}); });
io.on("connection", (socket) => { const PORT = process.env.PORT ?? 3001;
console.log(`New client connected: ${socket.id}`); const HOST = process.env.HOST ?? '0.0.0.0';
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, () => { server.listen(PORT, () => {
console.log(`Server listening on ${HOST}, port ${PORT}`); console.log(`Server listening on ${HOST}, port ${PORT}`);

File diff suppressed because it is too large Load Diff

View File

@@ -1,71 +1,113 @@
/** Notifikace pro gotify*/ import axios from 'axios';
import { GotifyServer, NotififaceInput, NotifikaceData } from '../../types';
import axios, { AxiosError } from 'axios';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import path from 'path'; 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}`) }); dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
const gotifyDataRaw = process.env.GOTIFY_SERVERS_AND_KEYS || "{}"; export const ntfyCall = async (data: NotifikaceInput) => {
const gotifyData: GotifyServer[] = JSON.parse(gotifyDataRaw); const url = process.env.NTFY_HOST
const username = process.env.NTFY_USERNAME;
export const gotifyCall = async (data: NotififaceInput, gotifyServers?: GotifyServer[]): Promise<any[]> => { const password = process.env.NTFY_PASSWD;
if (!Array.isArray(gotifyServers)) { if (!url) {
return [] console.log("NTFY_HOST není definován v env")
return
} }
const urls = gotifyServers.flatMap(gotifyServer => if (!username) {
gotifyServer.api_keys.map(apiKey => `${gotifyServer.server}/message?token=${apiKey}`)); console.log("NTFY_USERNAME není definován v env")
return
const dataPayload = {
title: "Luncher",
message: `${data.udalost} - spustil:${data.user}`,
priority: 7,
};
const headers = { "Content-Type": "application/json" };
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;
} }
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 token = Buffer.from(`${username}:${password}`, 'utf8').toString('base64');
const promises = userByCLocation.map(async user => {
try {
// Odstraníme mezery a diakritiku a převedeme na lowercase
const topic = user.normalize('NFD').replace(' ', '').replace(/[\u0300-\u036f]/g, '').toLowerCase();
const response = await axios({
url: `${url}/${topic}`,
method: 'POST',
data: `${data.udalost} - spustil:${data.user}`,
headers: {
'Authorization': `Basic ${token}`,
'Tag': 'meat_on_bone'
}
});
console.log(response.data);
} catch (error) {
console.error(error);
} }
// Handle unknown error without a response
console.log(error, "unknown error");
}) })
);
return promises; 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,
},
],
}; };
if (!url) {
console.log("TEAMS_WEBHOOK_URL není definován v env")
return
}
try {
const response = await axios.post(url, card, {
headers: {
'content-type': 'application/vnd.microsoft.teams.card.o365connector'
},
});
return `${response.status} - ${response.statusText}`;
} catch (err) {
return err;
}
}
/** Zavolá notifikace na všechny konfigurované způsoby notifikace, přetížení proměných na false pro jednotlivé způsoby je vypne*/ /** 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 = true }: NotifikaceData) => { export const callNotifikace = async ({ input, teams = true, gotify = false, ntfy = true }: NotifikaceData) => {
const notifications = []; const notifications = [];
if (gotify) { if (ntfy) {
const gotifyPromises = await gotifyCall(input, gotifyData); const ntfyPromises = await ntfyCall(input);
notifications.push(...gotifyPromises); if (ntfyPromises) {
notifications.push(...ntfyPromises);
}
} }
/* Zatím není
if (teams) { if (teams) {
notifications.push(teamsCall(input)); const teamsPromises = await teamsCall(input);
}*/ if (teamsPromises) {
notifications.push(teamsPromises);
// Add more notifications as necessary }
}
// gotify bych řekl, že už je deprecated
// if (gotify) {
// const gotifyPromises = await gotifyCall(input, gotifyData);
// notifications.push(...gotifyPromises);
// }
try { try {
const results = await Promise.all(notifications); const results = await Promise.all(notifications);

310
server/src/pizza.ts Normal file
View File

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

View File

@@ -0,0 +1,146 @@
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

@@ -0,0 +1,266 @@
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

@@ -0,0 +1,112 @@
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

@@ -0,0 +1,22 @@
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

@@ -0,0 +1,26 @@
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,18 +1,16 @@
import { InsufficientPermissions, formatDate, getDayOfWeekIndex, getHumanDate, getHumanTime, getIsWeekend } from "./utils"; import { InsufficientPermissions, formatDate, getDayOfWeekIndex, getFirstWorkDayOfWeek, getIsWeekend, getWeekNumber } from "./utils";
import { callNotifikace } from "./notifikace";
import { generateQr } from "./qr";
import { ClientData, PizzaDayState, UdalostEnum, Pizza, PizzaSize, Order, PizzaOrder, Locations, Restaurants, Menu } from "../../types";
import getStorage from "./storage"; import getStorage from "./storage";
import { getMenuSladovnicka, getMenuTechTower, getMenuUMotliku } from "./restaurants"; import { getMenuSladovnicka, getMenuTechTower, getMenuZastavkaUmichala, getMenuSenkSerikova } from "./restaurants";
import { downloadPizzy } from "./chefie";
import { getTodayMock } from "./mock"; import { getTodayMock } from "./mock";
import { ClientData, DepartureTime, LunchChoice, Restaurant, RestaurantDayMenu, WeekMenu } from "../../types/gen/types.gen";
const storage = getStorage(); const storage = getStorage();
const MENU_PREFIX = 'menu';
/** Vrátí dnešní datum, případně fiktivní datum pro účely vývoje a testování. */ /** Vrátí dnešní datum, případně fiktivní datum pro účely vývoje a testování. */
export function getToday(): Date { export function getToday(): Date {
if (process.env.MOCK_DATA === 'true') { if (process.env.MOCK_DATA === 'true') {
return new Date(getTodayMock()); return getTodayMock();
} }
return new Date(); return new Date();
} }
@@ -32,307 +30,221 @@ export const getDateForWeekIndex = (index: number) => {
/** Vrátí "prázdná" (implicitní) data pro předaný den. */ /** Vrátí "prázdná" (implicitní) data pro předaný den. */
function getEmptyData(date?: Date): ClientData { function getEmptyData(date?: Date): ClientData {
const usedDate = date || getToday(); const usedDate = date || getToday();
return { date: getHumanDate(usedDate), isWeekend: getIsWeekend(usedDate), weekIndex: getDayOfWeekIndex(usedDate), choices: {} }; return {
date: usedDate.toISOString().split('T')[0],
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 předaný den, nebo aktuální den, pokud není předán.
*/ */
export async function getData(date?: Date): Promise<ClientData> { export async function getData(date?: Date): Promise<ClientData> {
const dateString = formatDate(date ?? getToday()); const clientData = await getClientData(date);
const data = await storage.getData(dateString) || getEmptyData(date); clientData.menus = {
// Dotažení jídel, pokud je ještě nemáme SLADOVNICKA: await getRestaurantMenu('SLADOVNICKA', date),
if (!data.menus) { // UMOTLIKU: await getRestaurantMenu('UMOTLIKU', date),
data.menus = { TECHTOWER: await getRestaurantMenu('TECHTOWER', date),
[Restaurants.SLADOVNICKA]: await getRestaurantMenu(Restaurants.SLADOVNICKA, date ?? getToday()), ZASTAVKAUMICHALA: await getRestaurantMenu('ZASTAVKAUMICHALA', date),
[Restaurants.UMOTLIKU]: await getRestaurantMenu(Restaurants.UMOTLIKU, date ?? getToday()), SENKSERIKOVA: await getRestaurantMenu('SENKSERIKOVA', date),
[Restaurants.TECHTOWER]: await getRestaurantMenu(Restaurants.TECHTOWER, date ?? getToday()),
} }
await storage.setData(dateString, data);
}
return data;
}
/**
* 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();
const today = formatDate(getToday());
let clientData: ClientData = await storage.getData(today);
if (!clientData.pizzaList) {
clientData = await savePizzaList(await downloadPizzy());
}
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: ClientData = await storage.getData(today);
clientData.pizzaList = pizzaList;
clientData.pizzaListLastUpdate = new Date();
await storage.setData(today, clientData);
return clientData; return clientData;
} }
/** /**
* Vrátí menu dané restaurace pro předaný den. Pokud neexistuje, provede jeho stažení a uložení do DB. * 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 restaurant restaurace
* @param date datum * @param firstDay první pracovní den týdne
* @param mock příznak, zda chceme pouze mock data * @returns pole menu pro jednotlivé dny týdne
*/ */
export async function getRestaurantMenu(restaurant: Restaurants, date?: Date): Promise<Menu> { export async function fetchRestaurantWeekMenuData(restaurant: Restaurant, firstDay: Date): Promise<any[]> {
await initIfNeeded(date); return await fetchRestaurantWeekMenu(restaurant, firstDay);
const selectedDay = formatDate(date ?? getToday());
const clientData: ClientData = await storage.getData(selectedDay);
if (!clientData.menus) {
clientData.menus = {};
storage.setData(selectedDay, clientData);
} }
if (!clientData.menus[restaurant]) {
clientData.menus[restaurant] = { /**
lastUpdate: getHumanTime(new Date()), * 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, closed: false,
food: [], food: [],
}; };
const mock = process.env.MOCK_DATA === 'true'; }
// 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) { switch (restaurant) {
case Restaurants.SLADOVNICKA: case 'SLADOVNICKA':
clientData.menus[restaurant]!.food = await getMenuSladovnicka(date, mock); if (weekData[i].length === 1 && weekData[i][0].name.toLowerCase() === 'pro daný den nebyla nalezena denní nabídka') {
break; weekMenu[i][restaurant]!.closed = true;
case Restaurants.UMOTLIKU:
const uMotlikuFood = await getMenuUMotliku(date, mock);
clientData.menus[restaurant]!.food = uMotlikuFood;
if (uMotlikuFood.length === 1 && uMotlikuFood[0].name.toLowerCase() === 'zavřeno') {
clientData.menus[restaurant]!.closed = true;
} }
break; break;
case Restaurants.TECHTOWER: case 'TECHTOWER':
clientData.menus[restaurant]!.food = await getMenuTechTower(date, mock); 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; break;
} }
storage.setData(selectedDay, clientData);
} }
return clientData.menus[restaurant]!;
// Uložení do storage
await storage.setData(getMenuKey(date), weekMenu);
} }
/** /**
* Vytvoří pizza day pro aktuální den a vrátí data pro klienta. * Načte menu dané restaurace pro celý týden bez ukládání do storage.
*/
export async function createPizzaDay(creator: string): Promise<ClientData> {
await initIfNeeded();
const today = formatDate(getToday());
const clientData: ClientData = await storage.getData(today);
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 };
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 today = formatDate(getToday());
const clientData: ClientData = await storage.getData(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;
await storage.setData(today, clientData);
return clientData;
}
/**
* Přidá objednávku pizzy uživateli.
* *
* @param login login uživatele * @param restaurant restaurace
* @param pizza zvolená pizza * @param firstDay první pracovní den týdne
* @param size zvolená velikost pizzy * @returns pole menu pro jednotlivé dny týdne
*/ */
export async function addPizzaOrder(login: string, pizza: Pizza, size: PizzaSize) { async function fetchRestaurantWeekMenu(restaurant: Restaurant, firstDay: Date): Promise<any[]> {
const today = formatDate(getToday()); const mock = process.env.MOCK_DATA === 'true';
const clientData: ClientData = await storage.getData(today);
if (!clientData.pizzaDay) { switch (restaurant) {
throw Error("Pizza day pro dnešní den neexistuje"); 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}`);
} }
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,
}
clientData.pizzaDay.orders.push(order);
}
const pizzaOrder: PizzaOrder = {
varId: size.varId,
name: pizza.name,
size: size.size,
price: size.price,
}
order.pizzaList.push(pizzaOrder);
order.totalPrice += pizzaOrder.price;
await storage.setData(today, clientData);
return clientData;
} }
/** /**
* Odstraní danou objednávku pizzy. * 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 login login uživatele * @param restaurant restaurace
* @param pizzaOrder objednávka pizzy * @param date datum, ke kterému získat menu
* @param forceRefresh příznak vynuceného obnovení
*/ */
export async function removePizzaOrder(login: string, pizzaOrder: PizzaOrder) { export async function getRestaurantMenu(restaurant: Restaurant, date?: Date, forceRefresh = false): Promise<RestaurantDayMenu> {
const today = formatDate(getToday()); const usedDate = date ?? getToday();
const clientData: ClientData = await storage.getData(today); const dayOfWeekIndex = getDayOfWeekIndex(usedDate);
if (!clientData.pizzaDay) { const now = new Date().getTime();
throw Error("Pizza day pro dnešní den neexistuje"); if (getIsWeekend(usedDate)) {
return {
lastUpdate: now,
closed: true,
food: [],
};
} }
const orderIndex = clientData.pizzaDay.orders.findIndex(o => o.customer === login);
if (orderIndex < 0) { let weekMenu = await getMenu(usedDate);
throw Error("Nebyly nalezeny žádné objednávky pro uživatele " + login); weekMenu ??= [{}, {}, {}, {}, {}];
for (let i = 0; i < 5; i++) {
weekMenu[i] ??= {};
weekMenu[i][restaurant] ??= {
lastUpdate: now,
closed: false,
food: [],
};
} }
const order = clientData.pizzaDay.orders[orderIndex]; if (forceRefresh || (!weekMenu[dayOfWeekIndex][restaurant]?.food?.length && !weekMenu[dayOfWeekIndex][restaurant]?.closed)) {
const index = order.pizzaList.findIndex(o => o.name === pizzaOrder.name && o.size === pizzaOrder.size); const firstDay = getFirstWorkDayOfWeek(usedDate);
if (index < 0) {
throw Error("Objednávka s danými parametry nebyla nalezena"); 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;
} }
const price = order.pizzaList[index].price; break;
order.pizzaList.splice(index, 1); case 'TECHTOWER':
order.totalPrice -= price; if (restaurantWeekFood[i]?.length === 1 && restaurantWeekFood[i][0].name.toLowerCase() === 'svátek') {
if (order.pizzaList.length == 0) { weekMenu[i][restaurant]!.closed = true;
clientData.pizzaDay.orders.splice(orderIndex, 1);
} }
await storage.setData(today, clientData); break;
return clientData; 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]!;
} }
/** /**
* Uzamkne možnost editovat objednávky pizzy. * Inicializuje výchozí data pro předané datum, nebo dnešek, pokud není datum předáno.
* *
* @param login login uživatele * @param date datum
* @returns aktuální data pro uživatele
*/ */
export async function lockPizzaDay(login: string) {
const today = formatDate(getToday());
const clientData: ClientData = await storage.getData(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;
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: ClientData = await storage.getData(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;
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: ClientData = await storage.getData(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;
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: ClientData = await storage.getData(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);
}
}
}
await storage.setData(today, clientData);
return clientData;
}
export async function initIfNeeded(date?: Date) { export async function initIfNeeded(date?: Date) {
const usedDate = formatDate(date ?? getToday()); const usedDate = formatDate(date ?? getToday());
const hasData = await storage.hasData(usedDate); const hasData = await storage.hasData(usedDate);
@@ -346,19 +258,19 @@ export async function initIfNeeded(date?: Date) {
* *
* @param login login uživatele * @param login login uživatele
* @param trusted příznak, zda se jedná o ověřeného uživatele * @param trusted příznak, zda se jedná o ověřeného uživatele
* @param location vybrané "umístění" * @param locationKey vybrané "umístění"
* @param date datum, ke kterému se volba vztahuje * @param date datum, ke kterému se volba vztahuje
* @returns * @returns
*/ */
export async function removeChoices(login: string, trusted: boolean, location: Locations, date?: Date) { export async function removeChoices(login: string, trusted: boolean, locationKey: LunchChoice, date?: Date) {
const selectedDay = formatDate(date ?? getToday()); const selectedDay = formatDate(date ?? getToday());
let data: ClientData = await storage.getData(selectedDay); let data = await getClientData(date);
validateTrusted(data, login, trusted); validateTrusted(data, login, trusted);
if (location in data.choices) { if (locationKey in data.choices) {
if (login in data.choices[location]) { if (data.choices[locationKey] && login in data.choices[locationKey]) {
delete data.choices[location][login] delete data.choices[locationKey][login]
if (Object.keys(data.choices[location]).length === 0) { if (Object.keys(data.choices[locationKey]).length === 0) {
delete data.choices[location] delete data.choices[locationKey]
} }
await storage.setData(selectedDay, data); await storage.setData(selectedDay, data);
} }
@@ -372,20 +284,20 @@ export async function removeChoices(login: string, trusted: boolean, location: L
* *
* @param login login uživatele * @param login login uživatele
* @param trusted příznak, zda se jedná o ověřeného uživatele * @param trusted příznak, zda se jedná o ověřeného uživatele
* @param location vybrané "umístění" * @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 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 * @param date datum, ke kterému se volba vztahuje
* @returns * @returns
*/ */
export async function removeChoice(login: string, trusted: boolean, location: Locations, foodIndex: number, date?: Date) { export async function removeChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex: number, date?: Date) {
const selectedDay = formatDate(date ?? getToday()); const selectedDay = formatDate(date ?? getToday());
let data: ClientData = await storage.getData(selectedDay); let data = await getClientData(date);
validateTrusted(data, login, trusted); validateTrusted(data, login, trusted);
if (location in data.choices) { if (locationKey in data.choices) {
if (login in data.choices[location]) { if (data.choices[locationKey] && login in data.choices[locationKey]) {
const index = data.choices[location][login].options.indexOf(foodIndex); const index = data.choices[locationKey][login].selectedFoods?.indexOf(foodIndex);
if (index > -1) { if (index != null && index > -1) {
data.choices[location][login].options.splice(index, 1) data.choices[locationKey][login].selectedFoods?.splice(index, 1);
await storage.setData(selectedDay, data); await storage.setData(selectedDay, data);
} }
} }
@@ -394,19 +306,26 @@ export async function removeChoice(login: string, trusted: boolean, location: Lo
} }
/** /**
* Odstraní kompletně volbu uživatele. * Odstraní kompletně volbu uživatele, vyjma ignoredLocationKey (pokud byla předána a existuje).
* *
* @param login login uživatele * @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: string) { async function removeChoiceIfPresent(login: string, date?: Date, ignoredLocationKey?: LunchChoice) {
let data: ClientData = await storage.getData(date); const usedDate = date ?? getToday();
let data = await getClientData(usedDate);
for (const key of Object.keys(data.choices)) { for (const key of Object.keys(data.choices)) {
if (login in data.choices[key]) { const locationKey = key as LunchChoice;
delete data.choices[key][login]; if (ignoredLocationKey != null && ignoredLocationKey == locationKey) {
if (Object.keys(data.choices[key]).length === 0) { continue;
delete data.choices[key];
} }
await storage.setData(date, data); 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; return data;
@@ -439,54 +358,94 @@ function validateTrusted(data: ClientData, login: string, trusted: boolean) {
* *
* @param login login uživatele * @param login login uživatele
* @param trusted příznak, zda se jedná o ověřeného uživatele * @param trusted příznak, zda se jedná o ověřeného uživatele
* @param location vybrané "umístění" * @param locationKey vybrané "umístění"
* @param foodIndex volitelný index jídla v daném 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 trusted příznak, zda se jedná o ověřeného uživatele
* @param date datum, ke kterému se volba vztahuje * @param date datum, ke kterému se volba vztahuje
* @returns aktuální data * @returns aktuální data
*/ */
export async function addChoice(login: string, trusted: boolean, location: Locations, foodIndex?: number, date?: Date) { export async function addChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex?: number, date?: Date) {
await initIfNeeded(); const usedDate = date ?? getToday();
const selectedDate = formatDate(date ?? getToday()); await initIfNeeded(usedDate);
let data: ClientData = await storage.getData(selectedDate); let data = await getClientData(usedDate);
validateTrusted(data, login, trusted); validateTrusted(data, login, trusted);
await validateFoodIndex(locationKey, foodIndex, date);
// Pokud měníme pouze lokaci, mažeme případné předchozí // Pokud měníme pouze lokaci, mažeme případné předchozí
if (foodIndex == null) { if (foodIndex == null) {
data = await removeChoiceIfPresent(login, selectedDate); data = await removeChoiceIfPresent(login, usedDate);
} else {
// Mažeme případné ostatní volby (měla by být maximálně jedna)
removeChoiceIfPresent(login, usedDate, locationKey);
} }
if (!(location in data.choices)) { // TODO vytáhnout inicializaci "prázdné struktury" do vlastní funkce
data.choices[location] = {}; data.choices[locationKey] ??= {};
if (!(login in data.choices[locationKey])) {
if (!data.choices[locationKey]) {
data.choices[locationKey] = {}
} }
if (!(login in data.choices[location])) { data.choices[locationKey][login] = {
data.choices[location][login] = {
trusted, trusted,
options: [] selectedFoods: []
}; };
} }
if (foodIndex != null && !data.choices[location][login].options.includes(foodIndex)) { if (foodIndex != null && !data.choices[locationKey][login].selectedFoods?.includes(foodIndex)) {
data.choices[location][login].options.push(foodIndex); data.choices[locationKey][login].selectedFoods?.push(foodIndex);
} }
const selectedDate = formatDate(usedDate);
await storage.setData(selectedDate, data); await storage.setData(selectedDate, data);
return data; return data;
} }
// TODO přejmenovat, ať je jasné že to patří k pizza day /**
export async function updateNote(login: string, note?: string) { * Zvaliduje platnost indexu jídla pro vybranou lokalitu a datum.
const today = formatDate(getToday()); *
let clientData: ClientData = await storage.getData(today); * @param locationKey vybraná lokalita
if (!clientData.pizzaDay) { * @param foodIndex index jídla pro danou lokalitu
throw Error("Pizza day pro dnešní den neexistuje"); * @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 (clientData.pizzaDay.state !== PizzaDayState.CREATED) { if (foodIndex < 0) {
throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED); throw Error(`Neplatný index ${foodIndex}`);
} }
const myOrder = clientData.pizzaDay.orders.find(o => o.customer === login); if (!Object.keys(Restaurant).includes(locationKey)) {
if (!myOrder || !myOrder.pizzaList.length) { throw Error(`Neplatný index ${foodIndex} pro lokalitu ${locationKey} nepodporující indexy`);
throw Error("Pizza day neobsahuje žádné objednávky uživatele " + login);
} }
myOrder.note = note; const usedDate = date ?? getToday();
await storage.setData(today, clientData); const menu = await getRestaurantMenu(locationKey as Restaurant, usedDate);
return clientData; 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;
} }
/** /**
@@ -497,17 +456,32 @@ export async function updateNote(login: string, note?: string) {
* @param date datum, ke kterému se čas vztahuje * @param date datum, ke kterému se čas vztahuje
*/ */
export async function updateDepartureTime(login: string, time?: string, date?: Date) { export async function updateDepartureTime(login: string, time?: string, date?: Date) {
const selectedDate = formatDate(date ?? getToday()); const usedDate = date ?? getToday();
let clientData: ClientData = await storage.getData(selectedDate); let clientData = await getClientData(usedDate);
const found = Object.values(clientData.choices).find(location => login in location); const found = Object.values(clientData.choices).find(location => login in location);
// TODO validace, že se jedná o restauraci // TODO validace, že se jedná o restauraci
if (found) { if (found) {
if (!time?.length) { if (!time?.length) {
delete found[login].departureTime; delete found[login].departureTime;
} else { } else {
if (!Object.values<string>(DepartureTime).includes(time)) {
throw Error(`Neplatný čas odchodu ${time}`);
}
found[login].departureTime = time; found[login].departureTime = time;
} }
await storage.setData(selectedDate, clientData); await storage.setData(formatDate(usedDate), clientData);
} }
return clientData; return clientData;
} }
/**
* Vrátí data pro klienta pro předaný nebo aktuální den.
*
* @param date datum pro který vrátit data, pokud není vyplněno, je použit dnešní den
* @returns data pro klienta
*/
export async function getClientData(date?: Date): Promise<ClientData> {
const targetDate = date ?? getToday();
const dateString = formatDate(targetDate);
return await storage.getData<ClientData>(dateString) || getEmptyData(date);
}

48
server/src/stats.ts Normal file
View File

@@ -0,0 +1,48 @@
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,5 +1,3 @@
import { ClientData } from "../../../types";
/** /**
* Interface pro úložiště dat. * Interface pro úložiště dat.
* *
@@ -7,22 +5,28 @@ import { ClientData } from "../../../types";
* Postupem času lze předělat pro efektivnější využití Redis. * Postupem času lze předělat pro efektivnější využití Redis.
*/ */
export interface StorageInterface { export interface StorageInterface {
/**
* Vrátí příznak, zda existují data pro předané datum.
* @param date datum, pro které zjišťujeme data
*/
hasData(date: string): Promise<boolean>;
/** /**
* Vrátí veškerá data pro předané datum. * Inicializuje úložiště, pokud je potřeba (např. připojení k databázi).
* @param date datum, pro které vrátit data
*/ */
getData(date: string): Promise<ClientData>; initialize?(): Promise<void>;
/** /**
* Uloží data pro předané datum. * Vrátí příznak, zda existují data pro předaný klíč.
* @param date datum, kterému patří ukládaná data * @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í * @param data data pro uložení
*/ */
setData(date: string, data: ClientData): Promise<void>; setData<Type>(key: string, data: Type): Promise<void>;
} }

View File

@@ -4,8 +4,8 @@ import { StorageInterface } from "./StorageInterface";
import JsonStorage from "./json"; import JsonStorage from "./json";
import RedisStorage from "./redis"; import RedisStorage from "./redis";
const ENVIRONMENT = process.env.NODE_ENV || 'production'; const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) }); dotenv.config({ path: path.resolve(__dirname, `../../.env.${ENVIRONMENT}`) });
const JSON_KEY = 'json'; const JSON_KEY = 'json';
const REDIS_KEY = 'redis'; const REDIS_KEY = 'redis';
@@ -19,6 +19,13 @@ if (!process.env.STORAGE || process.env.STORAGE?.toLowerCase() === JSON_KEY) {
throw Error("Nepodporovaná hodnota proměnné STORAGE: " + process.env.STORAGE + ", podporované jsou 'json' nebo 'redis'"); 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 { export default function getStorage(): StorageInterface {
return storage; return storage;
} }

View File

@@ -1,24 +1,32 @@
import JSONdb from 'simple-json-db'; import JSONdb from 'simple-json-db';
import { ClientData } from "../../../types";
import { StorageInterface } from "./StorageInterface"; import { StorageInterface } from "./StorageInterface";
import * as fs from 'fs';
import * as path from 'path';
const db = new JSONdb('./data.json'); 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. * Implementace úložiště používající JSON soubor.
*/ */
export default class JsonStorage implements StorageInterface { export default class JsonStorage implements StorageInterface {
hasData(date: string): Promise<boolean> { hasData(key: string): Promise<boolean> {
return Promise.resolve(db.has(date)); return Promise.resolve(db.has(key));
} }
getData(date: string): Promise<ClientData> { getData<Type>(key: string): Promise<Type> {
return db.get(date); return db.get(key);
} }
setData(date: string, data: ClientData): Promise<void> { setData<Type>(key: string, data: Type): Promise<void> {
db.set(date, data); db.set(key, data);
return Promise.resolve(); return Promise.resolve();
} }
} }

View File

@@ -1,6 +1,5 @@
import { RedisClientType, createClient } from 'redis'; import { RedisClientType, createClient } from 'redis';
import { StorageInterface } from "./StorageInterface"; import { StorageInterface } from "./StorageInterface";
import { ClientData } from '../../../types';
let client: RedisClientType; let client: RedisClientType;
@@ -9,24 +8,27 @@ let client: RedisClientType;
*/ */
export default class RedisStorage implements StorageInterface { export default class RedisStorage implements StorageInterface {
constructor() { constructor() {
const HOST = process.env.REDIS_HOST || 'localhost'; const HOST = process.env.REDIS_HOST ?? 'localhost';
const PORT = process.env.REDIS_PORT || 6379; const PORT = process.env.REDIS_PORT ?? 6379;
client = createClient({ url: `redis://${HOST}:${PORT}` }); client = createClient({ url: `redis://${HOST}:${PORT}` });
}
async initialize() {
client.connect(); client.connect();
} }
async hasData(date: string) { async hasData(key: string) {
const data = await client.json.get(date); const data = await client.json.get(key);
return (data ? true : false); return (!!data);
} }
async getData(date: string) { async getData<Type>(key: string) {
const data = await client.json.get(date, { path: '.' }); const data = await client.json.get(key, { path: '.' });
return data as unknown as ClientData; return data as Type;
} }
async setData(date: string, data: ClientData) { async setData<Type>(key: string, data: Type) {
await client.json.set(date, '.', data as any); await client.json.set(key, '.', data as any);
const check = await client.json.get(date); await client.json.get(key);
} }
} }

View File

@@ -0,0 +1,159 @@
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,9 +1,15 @@
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. */ /** Vrátí datum v ISO formátu. */
export function formatDate(date: Date) { export function formatDate(date: Date, format?: string) {
let currentDay = String(date.getDate()).padStart(2, '0'); let day = String(date.getDate()).padStart(2, '0');
let currentMonth = String(date.getMonth() + 1).padStart(2, "0"); let month = String(date.getMonth() + 1).padStart(2, "0");
let currentYear = date.getFullYear(); let year = String(date.getFullYear());
return `${currentYear}-${currentMonth}-${currentDay}`;
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í. */ /** Vrátí human-readable reprezentaci předaného data pro zobrazení. */
@@ -11,7 +17,7 @@ export function getHumanDate(date: Date) {
let currentDay = String(date.getDate()).padStart(2, '0'); let currentDay = String(date.getDate()).padStart(2, '0');
let currentMonth = String(date.getMonth() + 1).padStart(2, "0"); let currentMonth = String(date.getMonth() + 1).padStart(2, "0");
let currentYear = date.getFullYear(); let currentYear = date.getFullYear();
let currentDayOfWeek = date.toLocaleDateString("CZ-cs", { weekday: 'long' }); let currentDayOfWeek = DAY_OF_WEEK_FORMAT.format(date);
return `${currentDay}.${currentMonth}.${currentYear} (${currentDayOfWeek})`; return `${currentDay}.${currentMonth}.${currentYear} (${currentDayOfWeek})`;
} }
@@ -39,5 +45,90 @@ export function getIsWeekend(date: Date) {
return index == 5 || index == 6; 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 // TODO umístit do samostatného souboru
export class InsufficientPermissions extends Error { } 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;
}

54
server/src/voting.ts Normal file
View File

@@ -0,0 +1,54 @@
import { FeatureRequest } from "../../types/gen/types.gen";
import getStorage from "./storage";
interface VotingData {
[login: string]: FeatureRequest[],
}
const storage = getStorage();
const STORAGE_KEY = 'voting';
/**
* Vrátí pole voleb, pro které uživatel aktuálně hlasuje.
*
* @param login login uživatele
* @returns pole voleb
*/
export async function getUserVotes(login: string) {
const data = await storage.getData<VotingData>(STORAGE_KEY);
return data?.[login] || [];
}
/**
* Aktualizuje hlas uživatele pro konkrétní volbu.
*
* @param login login uživatele
* @param option volba
* @param active příznak, zda volbu přidat nebo odebrat
* @returns aktuální data
*/
export async function updateFeatureVote(login: string, option: FeatureRequest, active: boolean): Promise<VotingData> {
let data = await storage.getData<VotingData>(STORAGE_KEY);
data ??= {};
if (!(login in data)) {
data[login] = [];
}
const index = data[login].indexOf(option);
if (index > -1) {
if (active) {
throw Error('Pro tuto možnost jste již hlasovali');
} else {
data[login].splice(index, 1);
if (data[login].length === 0) {
delete data[login];
}
}
} else if (active) {
if (data[login].length == 4) {
throw Error('Je možné hlasovat pro maximálně 4 možnosti');
}
data[login].push(option);
}
await storage.setData(STORAGE_KEY, data);
return data;
}

27
server/src/websocket.ts Normal file
View File

@@ -0,0 +1,27 @@
import { DefaultEventsMap, Server } from "socket.io";
let io: Server<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, any>;
export const initWebsocket = (server: any) => {
io = new Server(server, {
cors: {
origin: "*",
},
});
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}`);
});
});
return io;
}
export const getWebsocket = () => {
return io;
}

View File

@@ -4,10 +4,11 @@
"../types/**/*" "../types/**/*"
], ],
"compilerOptions": { "compilerOptions": {
"target": "ES2016", "target": "ES2022",
"module": "CommonJS", "module": "Node16",
"jsx": "react", "moduleResolution": "node16",
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"outDir": "./dist", "outDir": "./dist",
"rootDir": "../", "rootDir": "../",

4864
server/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,126 +0,0 @@
/** 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 FoodChoices {
trusted: boolean,
options: number[],
departureTime?: string,
}
export interface Choices {
[location: string]: {
[login: string]: FoodChoices
},
}
/** 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
weekIndex: number, // index aktuálního dne v týdnu (0-6)
choices: Choices, // seznam voleb
menus?: { [restaurant in Restaurants]?: Menu }, // menu jednotlivých restaurací
pizzaDay?: PizzaDay, // pizza day pro dnešní den, pokud existuje
pizzaList?: Pizza[], // seznam dostupných pizz pro dnešní den
pizzaListLastUpdate?: Date, // datum a čas poslední aktualizace pizz
}
/** Nabídka jídel jednoho podniku. */
export interface Menu {
lastUpdate: string, // human-readable čas poslední aktualizace menu
closed: boolean, // příznak, zda je daný podnik aktuálně zavřený
food: Food[], // seznam jídel v menu
}
/** 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
}
// TODO tohle je dost špatné pojmenování, vzhledem k tomu co to obsahuje
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[];
}

84
types/api.yml Normal file
View File

@@ -0,0 +1,84 @@
openapi: 3.0.4
info:
title: Luncher API
version: 1.0.0
servers:
- url: /api
paths:
# Obecné (/api)
/login:
$ref: "./paths/login.yml"
/qr:
$ref: "./paths/getPizzaQr.yml"
/data:
$ref: "./paths/getData.yml"
# Restaurace a jídla (/api/food)
/food/addChoice:
$ref: "./paths/food/addChoice.yml"
/food/removeChoice:
$ref: "./paths/food/removeChoice.yml"
/food/updateNote:
$ref: "./paths/food/updateNote.yml"
/food/removeChoices:
$ref: "./paths/food/removeChoices.yml"
/food/changeDepartureTime:
$ref: "./paths/food/changeDepartureTime.yml"
/food/jdemeObed:
$ref: "./paths/food/jdemeObed.yml"
# Pizza day (/api/pizzaDay)
/pizzaDay/create:
$ref: "./paths/pizzaDay/create.yml"
/pizzaDay/delete:
$ref: "./paths/pizzaDay/delete.yml"
/pizzaDay/lock:
$ref: "./paths/pizzaDay/lock.yml"
/pizzaDay/unlock:
$ref: "./paths/pizzaDay/unlock.yml"
/pizzaDay/finishOrder:
$ref: "./paths/pizzaDay/finishOrder.yml"
/pizzaDay/finishDelivery:
$ref: "./paths/pizzaDay/finishDelivery.yml"
/pizzaDay/add:
$ref: "./paths/pizzaDay/addPizza.yml"
/pizzaDay/remove:
$ref: "./paths/pizzaDay/removePizza.yml"
/pizzaDay/updatePizzaDayNote:
$ref: "./paths/pizzaDay/updatePizzaDayNote.yml"
/pizzaDay/updatePizzaFee:
$ref: "./paths/pizzaDay/updatePizzaFee.yml"
# Easter eggy (/api/easterEggs)
/easterEggs:
$ref: "./paths/easterEggs/easterEggs.yml"
/easterEggs/{url}:
$ref: "./paths/easterEggs/easterEgg.yml"
# Statistiky (/api/stats)
/stats:
$ref: "./paths/stats/stats.yml"
# Hlasování (/api/voting)
/voting/getVotes:
$ref: "./paths/voting/getVotes.yml"
/voting/updateVote:
$ref: "./paths/voting/updateVote.yml"
components:
schemas:
$ref: "./schemas/_index.yml"
responses:
ClientDataResponse:
description: Aktuální data pro klienta
content:
application/json:
schema:
$ref: "./schemas/_index.yml#/ClientData"
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
security:
- bearerAuth: []

View File

@@ -1 +1 @@
export * from './Types'; export * from './gen';

View File

@@ -0,0 +1,14 @@
import { defaultPlugins } from '@hey-api/openapi-ts';
export default {
input: 'api.yml',
output: 'gen',
plugins: [
...defaultPlugins,
'@hey-api/client-fetch',
{
enums: 'javascript',
name: '@hey-api/typescript',
},
],
};

11
types/package.json Normal file
View File

@@ -0,0 +1,11 @@
{
"name": "@luncher/types",
"version": "1.0.0",
"license": "MIT",
"private": true,
"devDependencies": {
"@hey-api/client-fetch": "^0.8.2",
"@hey-api/openapi-ts": "^0.64.7",
"typescript": "^5.9.3"
}
}

View File

@@ -0,0 +1,18 @@
get:
operationId: getEasterEggImage
summary: Vrátí obrázek konkrétního easter eggu
parameters:
- in: path
name: url
required: true
schema:
type: string
description: URL easter eggu
responses:
"200":
content:
image/png:
description: Obrázek easter eggu
schema:
type: string
format: binary

View File

@@ -0,0 +1,9 @@
get:
operationId: getEasterEgg
summary: Vrátí náhodně metadata jednoho z definovaných easter egg obrázků pro přihlášeného uživatele, nebo nic, pokud žádné definované nemá.
responses:
"200":
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/EasterEgg"

View File

@@ -0,0 +1,20 @@
post:
operationId: addChoice
summary: Přidání či nahrazení volby uživatele pro zvolený den/podnik
requestBody:
required: true
content:
application/json:
schema:
required:
- locationKey
properties:
locationKey:
$ref: "../../schemas/_index.yml#/LunchChoice"
dayIndex:
$ref: "../../schemas/_index.yml#/DayIndex"
foodIndex:
$ref: "../../schemas/_index.yml#/FoodIndex"
responses:
"200":
$ref: "../../api.yml#/components/responses/ClientDataResponse"

View File

@@ -0,0 +1,16 @@
post:
operationId: changeDepartureTime
summary: Úprava preferovaného času odchodu do aktuálně zvoleného podniku.
requestBody:
required: true
content:
application/json:
schema:
properties:
dayIndex:
$ref: "../../schemas/_index.yml#/DayIndex"
time:
$ref: "../../schemas/_index.yml#/DepartureTime"
responses:
"200":
$ref: "../../api.yml#/components/responses/ClientDataResponse"

View File

@@ -0,0 +1,6 @@
post:
operationId: jdemeObed
summary: Odeslání notifikací "jdeme na oběd" dle konfigurace.
responses:
"200":
description: Notifikace byly odeslány.

View File

@@ -0,0 +1,21 @@
post:
operationId: removeChoice
summary: Odstranění jednoho zvoleného jídla uživatele pro zvolený den/podnik
requestBody:
required: true
content:
application/json:
schema:
required:
- foodIndex
- locationKey
properties:
foodIndex:
$ref: "../../schemas/_index.yml#/FoodIndex"
locationKey:
$ref: "../../schemas/_index.yml#/LunchChoice"
dayIndex:
$ref: "../../schemas/_index.yml#/DayIndex"
responses:
"200":
$ref: "../../api.yml#/components/responses/ClientDataResponse"

View File

@@ -0,0 +1,18 @@
post:
operationId: removeChoices
summary: Odstranění volby uživatele pro zvolený den/podnik, včetně případných jídel
requestBody:
required: true
content:
application/json:
schema:
required:
- locationKey
properties:
locationKey:
$ref: "../../schemas/_index.yml#/LunchChoice"
dayIndex:
$ref: "../../schemas/_index.yml#/DayIndex"
responses:
"200":
$ref: "../../api.yml#/components/responses/ClientDataResponse"

View File

@@ -0,0 +1,16 @@
post:
operationId: updateNote
summary: Nastavení poznámky k volbě uživatele
requestBody:
required: true
content:
application/json:
schema:
properties:
dayIndex:
$ref: "../../schemas/_index.yml#/DayIndex"
note:
type: string
responses:
"200":
$ref: "../../api.yml#/components/responses/ClientDataResponse"

14
types/paths/getData.yml Normal file
View File

@@ -0,0 +1,14 @@
get:
operationId: getData
summary: Načtení klientských dat pro aktuální nebo předaný den
parameters:
- in: query
name: dayIndex
description: Index dne v týdnu. Pokud není předán, je použit aktuální den.
schema:
type: integer
minimum: 0
maximum: 4
responses:
"200":
$ref: "../api.yml#/components/responses/ClientDataResponse"

View File

@@ -0,0 +1,19 @@
get:
operationId: getPizzaQr
summary: Získání QR kódu pro platbu za Pizza day
security: [] # Nevyžaduje autentizaci
parameters:
- in: query
name: login
schema:
type: string
required: true
description: Přihlašovací jméno uživatele, pro kterého bude vrácen QR kód
responses:
"200":
description: Vygenerovaný QR kód pro platbu
content:
image/png:
schema:
type: string
format: binary

20
types/paths/login.yml Normal file
View File

@@ -0,0 +1,20 @@
post:
operationId: login
summary: Přihlášení uživatele
security: [] # Nevyžaduje autentizaci
requestBody:
content:
application/json:
schema:
type: object
properties:
login:
type: string
description: Přihlašovací jméno uživatele. Vyžadováno pouze pokud není předáno pomocí hlaviček.
responses:
"200":
description: Přihlášení bylo úspěšné
content:
application/json:
schema:
$ref: "../schemas/_index.yml#/JWTToken"

View File

@@ -0,0 +1,21 @@
post:
operationId: addPizza
summary: Přidání pizzy do objednávky.
requestBody:
required: true
content:
application/json:
schema:
required:
- pizzaIndex
- pizzaSizeIndex
properties:
pizzaIndex:
description: Index pizzy v nabídce
type: integer
pizzaSizeIndex:
description: Index velikosti pizzy v nabídce variant
type: integer
responses:
"200":
description: Přidání pizzy do objednávky proběhlo úspěšně.

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