50 Commits

Author SHA1 Message Date
2e8774900f feat: Základ generování QR kódů 2025-12-02 11:46:52 +01:00
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
47 changed files with 4878 additions and 2563 deletions

View File

@@ -80,7 +80,7 @@ COPY --from=builder /build/server/dist ./
COPY --from=builder /build/client/dist ./public COPY --from=builder /build/client/dist ./public
# Zkopírování produkčních .env serveru # Zkopírování produkčních .env serveru
COPY /server/.env.production ./server/src COPY /server/.env.production ./server
# Zkopírování konfigurace easter eggů # Zkopírování konfigurace easter eggů
RUN if [ -f /server/.easter-eggs.json ]; then cp /server/.easter-eggs.json ./server/; fi RUN if [ -f /server/.easter-eggs.json ]; then cp /server/.easter-eggs.json ./server/; fi

View File

@@ -6,31 +6,31 @@
"type": "module", "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",
"@types/jest": "^29.5.12", "@types/jest": "^30.0.0",
"@types/node": "^20.11.20", "@types/node": "^24.10.0",
"@types/react": "^19.0.0", "@types/react": "^19.2.2",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^5.1.0",
"bootstrap": "^5.2.3", "bootstrap": "^5.3.8",
"react": "^19.0.0", "react": "^19.2.0",
"react-bootstrap": "^2.7.2", "react-bootstrap": "^2.10.10",
"react-dom": "^19.0.0", "react-dom": "^19.2.0",
"react-jwt": "^1.2.0", "react-jwt": "^1.3.0",
"react-modal": "^3.16.1", "react-modal": "^3.16.3",
"react-router": "^7.2.0", "react-router": "^7.9.5",
"react-router-dom": "^7.2.0", "react-router-dom": "^7.9.5",
"react-select-search": "^4.1.6", "react-select-search": "^4.1.6",
"react-snowfall": "^2.2.0", "react-snowfall": "^2.3.0",
"react-toastify": "^10.0.4", "react-toastify": "^11.0.5",
"recharts": "^2.15.1", "recharts": "^3.4.1",
"sass": "^1.80.6", "sass": "^1.93.3",
"socket.io-client": "^4.6.1", "socket.io-client": "^4.6.1",
"typescript": "^5.3.3", "typescript": "^5.9.3",
"vite": "^6.0.3", "vite": "^7.2.2",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
}, },
"scripts": { "scripts": {
@@ -56,6 +56,6 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"prettier": "^3.2.5" "prettier": "^3.6.2"
} }
} }

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

View File

@@ -51,8 +51,24 @@
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 {

View File

@@ -10,18 +10,18 @@ import PizzaOrderList from './components/PizzaOrderList';
import SelectSearch, { SelectedOptionValue, SelectSearchOption } 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.scss'; import './App.scss';
import { faCircleCheck, faNoteSticky, faTrashCan } from '@fortawesome/free-regular-svg-icons'; import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons';
import { useSettings } from './context/settings'; import { useSettings } from './context/settings';
import Footer from './components/Footer'; import Footer from './components/Footer';
import { faChainBroken, faChevronLeft, faChevronRight, faGear, 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 { getHumanDateTime, isInTheFuture } from './Utils'; import { getDayOfWeekIndex, getHumanDate, getHumanDateTime, getIsWeekend, isInTheFuture } from './Utils';
import NoteModal from './components/modals/NoteModal'; import NoteModal from './components/modals/NoteModal';
import { useEasterEgg } from './context/eggs'; import { useEasterEgg } from './context/eggs';
import { Link } from 'react-router';
import { STATS_URL } from './AppRoutes';
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 { 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 { getLunchChoiceName } from './enums';
import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves';
import './FallingLeaves.scss';
const EVENT_CONNECT = "connect" const EVENT_CONNECT = "connect"
@@ -32,6 +32,26 @@ const EASTER_EGG_STYLE = {
animationTimingFunction: "ease" animationTimingFunction: "ease"
} }
// Mapování čísel alergenů na jejich názvy
const ALLERGENS: { [key: number]: string } = {
1: "Obiloviny obsahující lepek",
2: "Korýši a výrobky z nich",
3: "Vejce a výrobky z nich",
4: "Ryby a výrobky z nich",
5: "Arašidy a výrobky z nich",
6: "Sója a výrobky z nich",
7: "Mléko a výrobky z nich (včetně laktózy)",
8: "Skořápkové plody",
9: "Celer a výrobky z něj",
10: "Hořčice a výrobky z ní",
11: "Sezamová semena a výrobky z nich",
12: "Oxid siřičitý a siřičitany",
13: "Vlčí bob (Lupina) a výrobky z něj",
14: "Měkkýši a výrobky z nich"
}
const LINK_ALLERGENS = 'https://www.strava.cz/Strava/Napoveda/cz/Prilohy/alergeny.pdf';
// Výchozí doba trvání animace v sekundách, pokud není přetíženo v konfiguračním JSONu // 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; const EASTER_EGG_DEFAULT_DURATION = 0.75;
@@ -51,7 +71,10 @@ function App() {
const departureChoiceRef = useRef<HTMLSelectElement>(null); const departureChoiceRef = useRef<HTMLSelectElement>(null);
const pizzaPoznamkaRef = 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 [loadingPizzaDay, setLoadingPizzaDay] = useState<boolean>(false);
const [noteModalOpen, setNoteModalOpen] = useState<boolean>(false); const [noteModalOpen, setNoteModalOpen] = useState<boolean>(false);
const [eggImage, setEggImage] = useState<Blob>(); const [eggImage, setEggImage] = useState<Blob>();
@@ -69,8 +92,9 @@ function App() {
const data = response.data const data = response.data
if (data) { if (data) {
setData(data); setData(data);
setDayIndex(data.dayIndex); const dayIndex = getDayOfWeekIndex(new Date(data.date));
dayIndexRef.current = data.dayIndex; setDayIndex(dayIndex);
dayIndexRef.current = dayIndex;
setFood(data.menus); setFood(data.menus);
} }
}).catch(e => { }).catch(e => {
@@ -83,6 +107,8 @@ function App() {
if (!auth?.login) { if (!auth?.login) {
return return
} }
setTodayDayIndex(getDayOfWeekIndex(new Date()));
setIsTodayWeekend(getIsWeekend(new Date()));
getData({ query: { dayIndex: dayIndex } }).then(response => { getData({ query: { dayIndex: dayIndex } }).then(response => {
const data = response.data; const data = response.data;
setData(data); setData(data);
@@ -105,7 +131,7 @@ function App() {
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.dayIndex === dayIndexRef.current) { if (dayIndexRef.current == null || getDayOfWeekIndex(new Date(newData.date)) === dayIndexRef.current) {
setData(newData); setData(newData);
} }
}); });
@@ -207,6 +233,7 @@ function App() {
if (foodChoiceRef.current?.value) { if (foodChoiceRef.current?.value) {
foodChoiceRef.current.value = ""; foodChoiceRef.current.value = "";
} }
choiceRef.current?.blur();
} }
} }
@@ -257,6 +284,12 @@ function App() {
} }
} }
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 [];
@@ -354,7 +387,20 @@ function App() {
(!hideSoups || !f.isSoup) && (!hideSoups || !f.isSoup) &&
<tr key={f.name} onClick={() => doAddClickFoodChoice(location, index)}> <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>
)} )}
@@ -399,30 +445,35 @@ function App() {
} }
const noOrders = data?.pizzaDay?.orders?.length === 0; const noOrders = data?.pizzaDay?.orders?.length === 0;
const canChangeChoice = dayIndex == null || data.todayDayIndex == null || dayIndex >= data.todayDayIndex; const canChangeChoice = dayIndex == null || dayIndex >= todayDayIndex;
const { path, url, startOffset, endOffset, duration, ...style } = easterEgg || {}; 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` }} />} {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 dayIndex={dayIndex} />
<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='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 }} /> */} <img alt="" src='snowman.png' style={{ position: "absolute", height: "110px", right: 10, top: 5 }} /> */}
Poslední změny: Poslední změny:
<ul> <ul>
<li>Migrace na generované <Link target='_blank' to="https://www.openapis.org">OpenAPI</Link></li> <li>Zobrazení alergenu při najetí myší a proklik na seznam alergenů</li>
<li>Odebrání zimní atmosféry</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'>
<FontAwesomeIcon title="Předchozí den" icon={faChevronLeft} style={{ cursor: "pointer", visibility: dayIndex > 0 ? "initial" : "hidden" }} onClick={() => handleDayChange(dayIndex - 1)} /> <span title='Předchozí den'>
<h1 className='title' style={{ color: dayIndex === data.todayDayIndex ? 'black' : 'gray' }}>{data.date}</h1> <FontAwesomeIcon icon={faChevronLeft} style={{ cursor: "pointer", visibility: dayIndex > 0 ? "initial" : "hidden" }} onClick={() => handleDayChange(dayIndex - 1)} />
<FontAwesomeIcon title="Následující den" icon={faChevronRight} style={{ cursor: "pointer", visibility: dayIndex < 4 ? "initial" : "hidden" }} onClick={() => handleDayChange(dayIndex + 1)} /> </span>
<h1 className='title' style={{ color: dayIndex === todayDayIndex ? 'black' : 'gray' }}>{getHumanDate(new Date(data.date))}</h1>
<span title="Následující den">
<FontAwesomeIcon icon={faChevronRight} style={{ cursor: "pointer", visibility: dayIndex < 4 ? "initial" : "hidden" }} onClick={() => handleDayChange(dayIndex + 1)} />
</span>
</div> </div>
} }
<Row className='food-tables'> <Row className='food-tables'>
@@ -435,7 +486,7 @@ function App() {
<div className='content-wrapper'> <div className='content-wrapper'>
<div className='content'> <div className='content'>
{canChangeChoice && <> {canChangeChoice && <>
<p>{`Jak to ${dayIndex == null || dayIndex === data.todayDayIndex ? '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(LunchChoice) {Object.entries(LunchChoice)
@@ -491,18 +542,27 @@ function App() {
const trusted = userPayload?.trusted || false; const trusted = userPayload?.trusted || false;
return <tr key={entry[0]}> return <tr key={entry[0]}>
<td> <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>}
{userPayload.note && <small style={{ overflowWrap: 'anywhere' }}> ({userPayload.note})</small>} {userPayload.note && <span style={{ fontSize: 'small' }}> ({userPayload.note})</span>}
{login === auth.login && canChangeChoice && <FontAwesomeIcon onClick={() => { {login !== auth.login && canChangeChoice && userPayload?.note?.length && <span title='Převzít poznámku'>
<FontAwesomeIcon onClick={() => {
copyNote(userPayload.note!);
}} className='action-icon' icon={faComment} />
</span>}
{login === auth.login && canChangeChoice && <span title='Upravit poznámku'>
<FontAwesomeIcon onClick={() => {
setNoteModalOpen(true); setNoteModalOpen(true);
}} title='Upravit poznámku' className='action-icon' icon={faNoteSticky} />} }} className='action-icon' icon={faNoteSticky} />
{login === auth.login && canChangeChoice && <FontAwesomeIcon onClick={() => { </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); doRemoveChoices(key as LunchChoice);
}} title={`Odstranit volbu ${locationName}, včetně případných zvolených jídel`} className='action-icon' icon={faTrashCan} />} }} className='action-icon' icon={faTrashCan} />
</span>}
</td> </td>
{userChoices?.length && food ? <td> {userChoices?.length && food ? <td>
<ul> <ul>
@@ -511,9 +571,12 @@ function App() {
const foodName = food[restaurantKey]?.food?.[foodIndex].name; const foodName = food[restaurantKey]?.food?.[foodIndex].name;
return <li key={foodIndex}> return <li key={foodIndex}>
{foodName} {foodName}
{login === auth.login && canChangeChoice && <FontAwesomeIcon onClick={() => { {login === auth.login && canChangeChoice &&
<span title={`Odstranit ${foodName}`}>
<FontAwesomeIcon onClick={() => {
doRemoveFoodChoice(restaurantKey, foodIndex); doRemoveFoodChoice(restaurantKey, foodIndex);
}} title={`Odstranit ${foodName}`} className='action-icon' icon={faTrashCan} />} }} className='action-icon' icon={faTrashCan} />
</span>}
</li> </li>
})} })}
</ul> </ul>
@@ -532,7 +595,7 @@ 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 === data.todayDayIndex && {dayIndex === todayDayIndex &&
<div className='mt-5'> <div className='mt-5'>
{!data.pizzaDay && {!data.pizzaDay &&
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
@@ -654,9 +717,13 @@ function App() {
</div> </div>
</> || "Jejda, něco se nám nepovedlo :("} </> || "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} /> <NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} />
</> </div>
); );
} }

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

@@ -73,6 +73,12 @@ export const getDayOfWeekIndex = (date: Date) => {
return (((date.getDay() - 1) % 7) + 7) % 7; 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. */ /** Vrátí první pracovní den v týdnu předaného data. */
export function getFirstWorkDayOfWeek(date: Date) { export function getFirstWorkDayOfWeek(date: Date) {
const firstDay = new Date(date.getTime()); const firstDay = new Date(date.getTime());

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={{
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> <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

@@ -5,17 +5,25 @@ import SettingsModal from "./modals/SettingsModal";
import { useSettings } from "../context/settings"; import { useSettings } from "../context/settings";
import FeaturesVotingModal from "./modals/FeaturesVotingModal"; import FeaturesVotingModal from "./modals/FeaturesVotingModal";
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal"; import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
import RefreshMenuModal from "./modals/RefreshMenuModal";
import GenerateQRModal from "./modals/GenerateQRModal";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { STATS_URL } from "../AppRoutes"; import { STATS_URL } from "../AppRoutes";
import { FeatureRequest, getVotes, updateVote } from "../../../types"; import { FeatureRequest, getVotes, updateVote } from "../../../types";
export default function Header() { type Props = {
dayIndex?: number;
}
export default function Header({ dayIndex }: Readonly<Props>) {
const auth = useAuth(); const auth = useAuth();
const settings = useSettings(); const settings = useSettings();
const navigate = useNavigate(); const navigate = useNavigate();
const [settingsModalOpen, setSettingsModalOpen] = useState<boolean>(false); const [settingsModalOpen, setSettingsModalOpen] = useState<boolean>(false);
const [votingModalOpen, setVotingModalOpen] = useState<boolean>(false); const [votingModalOpen, setVotingModalOpen] = useState<boolean>(false);
const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false); const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false);
const [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState<boolean>(false);
const [generateQRModalOpen, setGenerateQRModalOpen] = useState<boolean>(false);
const [featureVotes, setFeatureVotes] = useState<FeatureRequest[] | undefined>([]); const [featureVotes, setFeatureVotes] = useState<FeatureRequest[] | undefined>([]);
useEffect(() => { useEffect(() => {
@@ -38,6 +46,14 @@ export default function Header() {
setPizzaModalOpen(false); setPizzaModalOpen(false);
} }
const closeRefreshMenuModal = () => {
setRefreshMenuModalOpen(false);
}
const closeGenerateQRModal = () => {
setGenerateQRModalOpen(false);
}
const isValidInteger = (str: string) => { const isValidInteger = (str: string) => {
str = str.trim(); str = str.trim();
if (!str) { if (!str) {
@@ -114,6 +130,8 @@ export default function Header() {
<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={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item> <NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item>
<NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item>
<NavDropdown.Item onClick={() => setGenerateQRModalOpen(true)}>Generování QR</NavDropdown.Item>
<NavDropdown.Item onClick={() => setVotingModalOpen(true)}>Hlasovat o nových funkcích</NavDropdown.Item> <NavDropdown.Item onClick={() => setVotingModalOpen(true)}>Hlasovat o nových funkcích</NavDropdown.Item>
<NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item> <NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item> <NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item>
@@ -123,6 +141,8 @@ export default function Header() {
</Nav> </Nav>
</Navbar.Collapse> </Navbar.Collapse>
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} /> <SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
<GenerateQRModal isOpen={generateQRModalOpen} onClose={closeGenerateQRModal} dayIndex={dayIndex} bankAccount={settings?.bankAccount} bankAccountHolder={settings?.holderName} />
<FeaturesVotingModal isOpen={votingModalOpen} onClose={closeVotingModal} onChange={saveFeatureVote} initialValues={featureVotes} /> <FeaturesVotingModal isOpen={votingModalOpen} onClose={closeVotingModal} onChange={saveFeatureVote} initialValues={featureVotes} />
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} /> <PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
</Navbar> </Navbar>

View File

@@ -28,9 +28,11 @@ export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeMo
<span key={pizzaOrder.name}> <span key={pizzaOrder.name}>
{`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price} Kč)`} {`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price} Kč)`}
{auth?.login === order.customer && state === PizzaDayState.CREATED && {auth?.login === order.customer && state === PizzaDayState.CREATED &&
<span title='Odstranit'>
<FontAwesomeIcon onClick={() => { <FontAwesomeIcon onClick={() => {
onDelete(pizzaOrder); onDelete(pizzaOrder);
}} title='Odstranit' className='action-icon' icon={faTrashCan} /> }} className='action-icon' icon={faTrashCan} />
</span>
} }
</span>) </span>)
.reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])} .reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])}
@@ -38,7 +40,7 @@ export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeMo
<td style={{ maxWidth: "200px" }}>{order.note ?? '-'}</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 style={{ maxWidth: "200px" }}>{order.fee?.price ? `${order.fee.price}${order.fee.text ? ` (${order.fee.text})` : ''}` : '-'}</td>
<td> <td>
{order.totalPrice} {auth?.login === creator && state === PizzaDayState.CREATED && <FontAwesomeIcon onClick={() => { setIsFeeModalOpen(true) }} title='Nastavit příplatek' className='action-icon' icon={faMoneyBill1} />} {order.totalPrice} {auth?.login === creator && state === PizzaDayState.CREATED && <span title='Nastavit příplatek'><FontAwesomeIcon onClick={() => { setIsFeeModalOpen(true) }} className='action-icon' icon={faMoneyBill1} /></span>}
</td> </td>
<PizzaAdditionalFeeModal customerName={order.customer} isOpen={isFeeModalOpen} onClose={() => setIsFeeModalOpen(false)} onSave={saveFees} initialValues={{ text: order.fee?.text, price: order.fee?.price?.toString() }} /> <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,176 @@
import { useEffect, useState } from "react";
import { Modal, Button, Table, Form, Alert } from "react-bootstrap";
import { ClientData, generateQr, getData } from "../../../../types";
type Props = {
isOpen: boolean,
onClose: () => void,
dayIndex?: number,
bankAccount?: string,
bankAccountHolder?: string,
}
type UserQRData = {
login: string;
selected: boolean;
note: string;
amount: string;
}
/** Modální dialog pro generování QR kódů. */
export default function GenerateQRModal({ isOpen, onClose, dayIndex, bankAccount, bankAccountHolder }: Readonly<Props>) {
const [users, setUsers] = useState<UserQRData[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const isBankDataValid = bankAccount && bankAccountHolder;
useEffect(() => {
if (isOpen) {
setLoading(true);
getData({ query: { dayIndex } }).then(response => {
const data: ClientData = response.data;
const userList: UserQRData[] = [];
// Projdeme všechny volby stravování a získáme uživatele
if (data.choices) {
Object.entries(data.choices).forEach(([locationKey, locationUsers]) => {
Object.keys(locationUsers).forEach(login => {
// Přidáme uživatele pouze pokud tam ještě není
if (!userList.find(u => u.login === login)) {
userList.push({
login,
selected: false,
note: '',
amount: ''
});
}
});
});
}
setUsers(userList);
setLoading(false);
}).catch(() => {
setLoading(false);
});
}
}, [isOpen, dayIndex]);
const handleCheckboxChange = (login: string) => {
setUsers(users.map(u =>
u.login === login ? { ...u, selected: !u.selected } : u
));
};
const handleNoteChange = (login: string, note: string) => {
setUsers(users.map(u =>
u.login === login ? { ...u, note } : u
));
};
const handleAmountChange = (login: string, amount: string) => {
setUsers(users.map(u =>
u.login === login ? { ...u, amount } : u
));
};
const handleGenerate = async () => {
const selectedUsers = users.filter(u => u.selected);
// TODO: Implementovat generování QR kódů
console.log('Generování QR pro:', selectedUsers);
alert('Funkce generování QR bude implementována');
await generateQr({
body: {
bankAccount: bankAccount!,
bankAccountHolder: bankAccountHolder!,
qrCodes: selectedUsers.map(u => ({
login: u.login,
des: u.note,
amount: Number.parseFloat(u.amount)
}))
},
})
};
const handleClose = () => {
setUsers([]);
onClose();
};
return (
<Modal show={isOpen} onHide={handleClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Generování QR kódů</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{!isBankDataValid && (
<Alert variant="warning">
<strong>Upozornění:</strong> Pro generování QR kódů je nutné mít v nastavení vyplněné číslo bankovního účtu a jméno majitele účtu.
</Alert>
)}
{loading ? (
<p>Načítání uživatelů...</p>
) : users.length === 0 ? (
<p>Pro aktuální den nemá žádný uživatel vybranou volbu stravování.</p>
) : (
<Table striped bordered hover>
<thead>
<tr>
<th style={{ width: '50px' }}></th>
<th>Uživatel</th>
<th>Poznámka</th>
<th style={{ width: '120px' }}>Částka ()</th>
</tr>
</thead>
<tbody>
{users.map(user => (
<tr key={user.login}>
<td className="text-center">
<Form.Check
type="checkbox"
checked={user.selected}
onChange={() => handleCheckboxChange(user.login)}
/>
</td>
<td>{user.login}</td>
<td>
<Form.Control
type="text"
value={user.note}
onChange={(e) => handleNoteChange(user.login, e.target.value)}
placeholder="Poznámka"
disabled={!user.selected}
/>
</td>
<td>
<Form.Control
type="number"
step="0.01"
min="0"
value={user.amount}
onChange={(e) => handleAmountChange(user.login, e.target.value)}
placeholder="0.00"
disabled={!user.selected}
/>
</td>
</tr>
))}
</tbody>
</Table>
)}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={handleClose}>
Zavřít
</Button>
<Button
variant="primary"
onClick={handleGenerate}
disabled={users.filter(u => u.selected).length === 0 || !isBankDataValid}
>
Generovat
</Button>
</Modal.Footer>
</Modal>
);
}

View File

@@ -37,9 +37,7 @@ export default function PizzaCalculatorModal({ isOpen, onClose }: Readonly<Props
// 1. pizza // 1. pizza
if (diameter1Ref.current?.value) { if (diameter1Ref.current?.value) {
const diameter1 = parseInt(diameter1Ref.current?.value); const diameter1 = parseInt(diameter1Ref.current?.value);
if (!r.pizza1) { r.pizza1 ??= {};
r.pizza1 = {};
}
if (diameter1 && diameter1 > 0) { if (diameter1 && diameter1 > 0) {
r.pizza1.diameter = diameter1; r.pizza1.diameter = diameter1;
r.pizza1.area = Math.PI * Math.pow(diameter1 / 2, 2); r.pizza1.area = Math.PI * Math.pow(diameter1 / 2, 2);
@@ -59,9 +57,7 @@ export default function PizzaCalculatorModal({ isOpen, onClose }: Readonly<Props
// 2. pizza // 2. pizza
if (diameter2Ref.current?.value) { if (diameter2Ref.current?.value) {
const diameter2 = parseInt(diameter2Ref.current?.value); const diameter2 = parseInt(diameter2Ref.current?.value);
if (!r.pizza2) { r.pizza2 ??= {};
r.pizza2 = {};
}
if (diameter2 && diameter2 > 0) { if (diameter2 && diameter2 > 0) {
r.pizza2.diameter = diameter2; r.pizza2.diameter = diameter2;
r.pizza2.area = Math.PI * Math.pow(diameter2 / 2, 2); r.pizza2.area = Math.PI * Math.pow(diameter2 / 2, 2);

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

@@ -103,13 +103,17 @@ export default function StatsPage() {
return ( return (
<> <>
<Header /> <Header dayIndex={undefined} />
<div className="stats-page"> <div className="stats-page">
<h1>Statistiky</h1> <h1>Statistiky</h1>
<div className="week-navigator"> <div className="week-navigator">
<FontAwesomeIcon title="Předchozí týden" icon={faChevronLeft} style={{ cursor: "pointer" }} onClick={handlePreviousWeek} /> <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> <h2 className="date-range">{getHumanDate(dateRange[0])} - {getHumanDate(dateRange[1])}</h2>
<FontAwesomeIcon title="Následující týden" icon={faChevronRight} style={{ cursor: "pointer" }} onClick={handleNextWeek} /> <span title="Následující týden">
<FontAwesomeIcon icon={faChevronRight} style={{ cursor: "pointer" }} onClick={handleNextWeek} />
</span>
</div> </div>
<LineChart width={CHART_WIDTH} height={CHART_HEIGHT} data={data}> <LineChart width={CHART_WIDTH} height={CHART_HEIGHT} data={data}>
{Object.values(LunchChoice).map(location => renderLine(location))} {Object.values(LunchChoice).map(location => renderLine(location))}

View File

@@ -1,6 +1,5 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ESNext",
"lib": [ "lib": [
"dom", "dom",
"dom.iterable", "dom.iterable",
@@ -16,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"
} }

File diff suppressed because it is too large Load Diff

View File

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

1
server/.gitignore vendored
View File

@@ -1,3 +1,4 @@
/data
/dist /dist
/resources/easterEggs /resources/easterEggs
/src/gen /src/gen

View File

@@ -11,28 +11,28 @@
"test": "jest" "test": "jest"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.23.0", "@babel/core": "^7.28.5",
"@babel/preset-env": "^7.22.20", "@babel/preset-env": "^7.28.5",
"@babel/preset-typescript": "^7.23.0", "@babel/preset-typescript": "^7.28.5",
"@types/express": "^4.17.17", "@types/express": "^5.0.5",
"@types/jest": "^29.5.14", "@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.6", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20.11.20", "@types/node": "^24.10.0",
"@types/request-promise": "^4.1.48", "@types/request-promise": "^4.1.48",
"babel-jest": "^29.7.0", "babel-jest": "^30.2.0",
"jest": "^29.7.0", "jest": "^30.2.0",
"nodemon": "^3.1.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.4.5", "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

@@ -9,13 +9,15 @@ import { generateToken, verify } from "./auth";
import { InsufficientPermissions } from "./utils"; import { InsufficientPermissions } from "./utils";
import { initWebsocket } from "./websocket"; import { initWebsocket } from "./websocket";
import pizzaDayRoutes from "./routes/pizzaDayRoutes"; import pizzaDayRoutes from "./routes/pizzaDayRoutes";
import foodRoutes from "./routes/foodRoutes"; import foodRoutes, { refreshMetoda } from "./routes/foodRoutes";
import votingRoutes from "./routes/votingRoutes"; import votingRoutes from "./routes/votingRoutes";
import easterEggRoutes from "./routes/easterEggRoutes"; import easterEggRoutes from "./routes/easterEggRoutes";
import statsRoutes from "./routes/statsRoutes"; import statsRoutes from "./routes/statsRoutes";
import debugRoutes from "./routes/debugRoutes";
import qrRoutes from "./routes/qrRoutes";
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) {
@@ -96,6 +98,11 @@ app.get("/api/qr", (req, res) => {
// ---------------------------------------------------- // ----------------------------------------------------
// Přeskočení auth pro refresh dat xd
app.use("/api/food/refresh", refreshMetoda);
app.use("/api/debug", debugRoutes);
/** Middleware ověřující JWT token */ /** Middleware ověřující JWT token */
app.use("/api/", (req, res, next) => { app.use("/api/", (req, res, next) => {
if (HTTP_REMOTE_USER_ENABLED) { if (HTTP_REMOTE_USER_ENABLED) {
@@ -140,6 +147,7 @@ app.use("/api/food", foodRoutes);
app.use("/api/voting", votingRoutes); app.use("/api/voting", votingRoutes);
app.use("/api/easterEggs", easterEggRoutes); app.use("/api/easterEggs", easterEggRoutes);
app.use("/api/stats", statsRoutes); app.use("/api/stats", statsRoutes);
app.use("/api/qr", qrRoutes);
app.use('/stats', express.static('public')); app.use('/stats', express.static('public'));
app.use(express.static('public')); app.use(express.static('public'));

View File

@@ -6,79 +6,31 @@ const MOCK_DATA = {
[ [
{ {
amount: "0,25l", amount: "0,25l",
name: "Kulajda", name: "Česnečka s uzeným masem a krutony",
price: "35\xA0Kč", price: "35\xA0Kč",
isSoup: true, isSoup: true,
allergens: [1, 3, 7, 9]
}, },
{ {
amount: "250g", amount: "250g",
name: "Kuřecí křidélka s vařeným bramborem", name: "Přírodní roštěná s jasmínovou rýží",
price: "135\xA0Kč", price: "135\xA0Kč",
isSoup: false, isSoup: false,
allergens: [1, 9, 10]
}, },
{ {
amount: "150g", amount: "150g",
name: "Hovězí hamburger s BBQ omáčkou a hranolky", name: "Noky s kuřecím masem a sýrovou omáčkou",
price: "145\xA0Kč", price: "145\xA0Kč",
isSoup: false, isSoup: false,
allergens: [1, 3, 7]
}, },
{ {
amount: "150g", amount: "150g",
name: "Frankfurtská hovězí pečeně s jasmínovou rýží", name: "Kuřecí stehno pečené na Moravance s feferony, bramborový knedlík",
price: "135\xA0Kč",
isSoup: false,
}
],
[
{
amount: "0,25l",
name: "Hovězí vývar s kapáním",
price: "35\xA0Kč",
isSoup: true,
},
{
amount: "200g",
name: "Smažený karbanátek s bramborovou kaší",
price: "135\xA0Kč",
isSoup: false,
},
{
amount: "150g",
name: "Vepřová plec na smetaně s kynutým knedlíkem",
price: "135\xA0Kč",
isSoup: false,
},
{
amount: "150g",
name: "Trhané kachní maso se zeleninovým kuskusem",
price: "135\xA0Kč",
isSoup: false,
}
],
[
{
amount: "0,25l",
name: "Zelná polévka s klobásou",
price: "35\xA0Kč",
isSoup: true,
},
{
amount: "150g",
name: "Hovězí na česneku s bramborovým knedlíkem",
price: "135\xA0Kč",
isSoup: false,
},
{
amount: "250g",
name: "Přírodní holandský řízek s bramborovou kaší, rajčatový salát",
price: "135\xA0Kč",
isSoup: false,
},
{
amount: "350g",
name: "Bagel s vinnou klobásou, cibulový konfit, kysané zelí, slanina a hořčicová mayo, hranolky, curry omáčka",
price: "135\xA0Kč", price: "135\xA0Kč",
isSoup: false, isSoup: false,
allergens: [1, 3, 7, 9]
} }
], ],
[ [
@@ -87,50 +39,118 @@ const MOCK_DATA = {
name: "Kuřecí vývar s nudlemi", name: "Kuřecí vývar s nudlemi",
price: "35\xA0Kč", price: "35\xA0Kč",
isSoup: true, isSoup: true,
allergens: [1, 3, 7, 9]
}, },
{ {
amount: "150g", amount: "200g",
name: "Kovbojské fazole s klobásou a chlebem", name: "Hovězí maso v rajské omáčce s kynutým knedlíkem",
price: "125\xA0Kč",
isSoup: false,
},
{
amount: "150g",
name: "Kuřecí rarášci s vařeným bramborem",
price: "135\xA0Kč", price: "135\xA0Kč",
isSoup: false, isSoup: false,
allergens: [1, 3, 7]
}, },
{ {
amount: "150g", amount: "150g",
name: "Hovězí pečeně na slanině s jasmínovou rýží", name: "Krůtí roláda se sušenými rajčaty , mozzarellou a bramborovou kaší",
price: "135\xA0Kč", price: "135\xA0Kč",
isSoup: false, isSoup: false,
allergens: [1, 7]
},
{
amount: "150g",
name: "Telecí játra na grilu, restované brambory, tatarská omáčka , polníčkový salát",
price: "135\xA0Kč",
isSoup: false,
allergens: [3, 7]
} }
], ],
[ [
{ {
amount: "0,25l", amount: "0,25l",
name: "Dršťková polévka", name: "Zeleninová polévka",
price: "35\xA0Kč", price: "35\xA0Kč",
isSoup: true, isSoup: true,
allergens: [3, 9]
}, },
{ {
amount: "150g", amount: "150g",
name: "Tortilla s kuřecím masem, čedarem, zeleninou a papričkami jalapeňos", name: "Smažené rybí filé s vařeným bramborem, tatarka",
price: "135\xA0Kč", price: "135\xA0Kč",
isSoup: false, isSoup: false,
allergens: [3, 7]
}, },
{ {
amount: "150g", amount: "250g",
name: "Segedínský guláš s kynutým knedlíkem", name: "Vepřové výpečky se špenátem, bramborový knedlík 1,3,7",
price: "135\xA0Kč", price: "135\xA0Kč",
isSoup: false, isSoup: false,
allergens: [1, 3, 7]
},
{
amount: "350g",
name: "Kuřecí řízek \" Ondráš \" , kysané zelí",
price: "135\xA0Kč",
isSoup: false,
allergens: [3, 7]
}
],
[
{
amount: "0,25l",
name: "Hovězí vývar s játrovými knedlíčky",
price: "35\xA0Kč",
isSoup: true,
allergens: [1, 3]
}, },
{ {
amount: "150g", amount: "150g",
name: "Filet z krůtích prsou, omáčka z modrého sýra, pečené brambory", name: "Merguez klobáska, bílé fazole na kyselo, sázené vejce a vídeňská cibulka",
price: "125\xA0Kč",
isSoup: false,
allergens: [1, 3]
},
{
amount: "150g",
name: "Kuřecí steak s liškovou omáčkou a opečený brambor",
price: "135\xA0Kč",
isSoup: false,
allergens: [1, 7]
},
{
amount: "150g",
name: "Kaťák vepřové kostky s feferonou, cibulí, kečupem ,česnekem, smažené krokety",
price: "135\xA0Kč",
isSoup: false,
allergens: [3, 7]
}
],
[
{
amount: "0,25l",
name: "Čočková polévka",
price: "35\xA0Kč",
isSoup: true,
allergens: [9, 12]
},
{
amount: "150g",
name: "Ovocné knedlíky s tvarohem",
price: "135\xA0Kč",
isSoup: false,
allergens: [1, 3, 7]
},
{
amount: "150g",
name: "Smažený vepřový řízek s bramborovým salátem",
price: "135\xA0Kč",
isSoup: false,
allergens: [1, 3, 7, 9, 10]
},
{
amount: "150g",
name: "Znojemský hovězí guláš s jasmínovou rýží",
price: "145\xA0Kč", price: "145\xA0Kč",
isSoup: false, isSoup: false,
allergens: [1, 9]
} }
] ]
], ],
@@ -270,161 +290,185 @@ const MOCK_DATA = {
[ [
{ {
amount: "-", amount: "-",
name: "Uzený vývar s kapustou", name: "Batátový krém s chilli a kokosovým mlékem",
price: "40\xA0Kč", price: "40\xA0Kč",
isSoup: true, isSoup: true,
allergens: [1, 7]
}, },
{ {
amount: "-", amount: "-",
name: "Čočka na kyselo, opečená klobása, okurka, chléb", name: "Kuřecí stehno na paprice, knedlík",
price: "130\xA0Kč", price: "130\xA0Kč",
isSoup: false, isSoup: false,
allergens: [1, 3, 7]
}, },
{ {
amount: "-", amount: "-",
name: "Smažená brokolice, brambory, tatarská omáčka", name: "Těstoviny se sušenými rajčaty a cuketou, parmezán",
price: "na\xA0váhu", price: "na\xA0váhu",
isSoup: false, isSoup: false,
allergens: [1, 7]
}, },
{ {
amount: "-", amount: "-",
name: "Uzený vepřový bůček, bramborové pyré", name: "Quesadilla s trham vepřovým masem, salát coleslaw, hranolky",
price: "na\xA0váhu", price: "na\xA0váhu",
isSoup: false, isSoup: false,
allergens: [1, 7]
}, },
{ {
amount: "-", amount: "-",
name: "Kuřecí medailonky v sýrové omáčce, hranolky", name: "Smažený kuřecí řízek v sezamové strouhance, vařené brambory, wasabi majonéza",
price: "na\xA0váhu", price: "na\xA0váhu",
isSoup: false, isSoup: false,
allergens: [1, 3, 7]
} }
], ],
[ [
{ {
amount: "-", amount: "-",
name: "Slepičí s nudlemi", name: "Ovarová",
price: "40\xA0Kč", price: "40\xA0Kč",
isSoup: true, isSoup: true,
allergens: [1]
}, },
{ {
amount: "-", amount: "-",
name: "Zvěřinový guláš, knedlík", name: "Zapečené těstoviny s uzeným masem, okurka",
price: "130\xA0Kč", price: "130\xA0Kč",
isSoup: false, isSoup: false,
allergens: [1, 3, 7]
}, },
{ {
amount: "-", amount: "-",
name: "Čínské nudle se zeleninou a vejcem", name: "Cheddarové kuličky s jalapeños, máslové brambory, tatarská omáčka",
price: "na\xA0váhu", price: "na\xA0váhu",
isSoup: false, isSoup: false,
allergens: [1, 3, 7]
}, },
{ {
amount: "-", amount: "-",
name: "Jitrnice/jelito, brambory, zelný salát s křenem, hořčice", name: "Steak z krkovice s miso omáčkou, jasmínová rýže",
price: "na\xA0váhu", price: "na\xA0váhu",
isSoup: false, isSoup: false,
allergens: [1, 6, 11]
}, },
{ {
amount: "-", amount: "-",
name: "Vídeňská roštěná se smaženou cibulkou, jasmínová rýže", name: "Kuřecí supreme s bramborovo-mrkvovým pyré, restovaná cuketa",
price: "na\xA0váhu", price: "na\xA0váhu",
isSoup: false, isSoup: false,
allergens: [7]
} }
], ],
[ [
{ {
amount: "-", amount: "-",
name: "Dýňový krém se smetanou", name: "Hovězí s hráškem a rýží",
price: "40\xA0Kč", price: "40\xA0Kč",
isSoup: true, isSoup: true,
allergens: [9]
}, },
{ {
amount: "-", amount: "-",
name: "Kuřecí směs se zeleninou, rýže", name: "Rizoto s kuřecím masem a zeleninou, okurka, sýr",
price: "130\xA0Kč", price: "130\xA0Kč",
isSoup: false, isSoup: false,
allergens: [7, 9]
}, },
{ {
amount: "-", amount: "-",
name: "Tvarohové knedlíky s meruňkami, strouhaný tvaroh, máslo, cukr", name: "Smažené rýžové nudle Pad thai s arašídy, zeleninou a vejcem",
price: "na\xA0váhu",
isSoup: false,
allergens: [1, 3, 6, 8, 11]
},
{
amount: "-",
name: "Vykoštěné vepřové koleno s křenem a hořčicí, chléb",
price: "na\xA0váhu", price: "na\xA0váhu",
isSoup: false, isSoup: false,
}, },
{ {
amount: "-", amount: "-",
name: "Ovar, křen, hořčice, pečivo", name: "Gordon bleu, hranolky, pikantní dip",
price: "na\xA0váhu",
isSoup: false,
},
{
amount: "-",
name: "Telecí holandský řízek s uzeným sýrem, bramborové pyré",
price: "na\xA0váhu", price: "na\xA0váhu",
isSoup: false, isSoup: false,
allergens: [1, 3, 7]
} }
], ],
[ [
{ {
amount: "-", amount: "-",
name: "Zeleninová s jáhly", name: "Dýňová",
price: "40\xA0Kč", price: "40\xA0Kč",
isSoup: true, isSoup: true,
allergens: [1, 7]
}, },
{ {
amount: "-", amount: "-",
name: "Rizoto s vepřovým masem, okurka", name: "Uzená plec, křenová omáčka, knedlík",
price: "130\xA0Kč", price: "130\xA0Kč",
isSoup: false, isSoup: false,
allergens: [1, 3, 7]
}, },
{ {
amount: "-", amount: "-",
name: "Tortellini s parmezánovou omáčkou", name: "Palačinky s marmeládou přelité čokoládou, sypané cukrem",
price: "na\xA0váhu", price: "na\xA0váhu",
isSoup: false, isSoup: false,
allergens: [1, 3, 7]
}, },
{ {
amount: "-", amount: "-",
name: "Pečený prejt, brambory, zelný salát", name: "Smažený holandský řízek s bramborovou kaší a nakládanou zeleninou",
price: "na\xA0váhu", price: "na\xA0váhu",
isSoup: false, isSoup: false,
allergens: [1, 3, 7]
}, },
{ {
amount: "-", amount: "-",
name: "Chobotnice na grilu, grilovaná zelenina, bylinková bageta", name: "Kuřecí jatýrka na smetaně s čerstvou majoránkou, jasmínová rýže",
price: "na\xA0váhu", price: "na\xA0váhu",
isSoup: false, isSoup: false,
allergens: [7]
} }
], ],
[ [
{ {
amount: "-", amount: "-",
name: "Fazolová s uzeninou", name: "Hovězí vývar s játrovými knedlíčky",
price: "40\xA0Kč", price: "40\xA0Kč",
isSoup: true, isSoup: true,
allergens: [1, 3, 7, 9]
}, },
{ {
amount: "-", amount: "-",
name: "Krůtí perkelt, těstoviny", name: "Kuřecí Kung-pao, jasmínová rýže",
price: "130\xA0Kč", price: "130\xA0Kč",
isSoup: false, isSoup: false,
allergens: [1, 3, 5, 6]
}, },
{ {
amount: "-", amount: "-",
name: "Grilovaný hermelín, bulgurový salát se zeleninou", name: "Sýrové tortelliny s pažitkovou omáčkou",
price: "na\xA0váhu", price: "na\xA0váhu",
isSoup: false, isSoup: false,
allergens: [1, 3, 7]
}, },
{ {
amount: "-", amount: "-",
name: "Zabijačkový guláš, karlovarský knedlík", name: "Teriyaki losos burger s frisée salátem a citrusovou majonézou, bramborové lupínky",
price: "na\xA0váhu", price: "na\xA0váhu",
isSoup: false, isSoup: false,
allergens: [1, 3, 6, 7, 11]
}, },
{ {
amount: "-", amount: "-",
name: "Vepřový plátek na žampionech, jasmínová rýže", name: "Vepřové výpečky s červeným zelím, bramborové knedlíky se smaženou cibulkou",
price: "na\xA0váhu", price: "na\xA0váhu",
isSoup: false, isSoup: false,
allergens: [1, 3, 7]
} }
] ]
], ],
@@ -473,6 +517,24 @@ const MOCK_DATA = {
name: "Pečené vepřové koleno, křen, hořčice, chléb", name: "Pečené vepřové koleno, křen, hořčice, chléb",
price: "320\xA0Kč", price: "320\xA0Kč",
isSoup: false, isSoup: false,
},
{
amount: "-",
name: "Slovácké strapačky s uzenou slaninou, zelím, mletým pepřem & sekanou petrželkou",
price: "140\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Hovězí guláš s vejcem, zeleninovou garniturkou & žemlovými knedlíky",
price: "145\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Kuřecí roláda s kaštanovou nádivkou, demi-glace & smetanovou bramborovou kaší",
price: "150\xA0Kč",
isSoup: false,
} }
], ],
[ [
@@ -487,6 +549,24 @@ const MOCK_DATA = {
name: "Poutine (trhané vepřové, hranolky, sýr, čalamáda, pikantní omáčka)", name: "Poutine (trhané vepřové, hranolky, sýr, čalamáda, pikantní omáčka)",
price: "190\xA0Kč", price: "190\xA0Kč",
isSoup: false, isSoup: false,
},
{
amount: "-",
name: "Slovácké strapačky s uzenou slaninou, zelím, mletým pepřem & sekanou petrželkou",
price: "140\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Hovězí guláš s vejcem, zeleninovou garniturkou & žemlovými knedlíky",
price: "145\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Kuřecí roláda s kaštanovou nádivkou, demi-glace & smetanovou bramborovou kaší",
price: "150\xA0Kč",
isSoup: false,
} }
], ],
[ [
@@ -501,6 +581,24 @@ const MOCK_DATA = {
name: "Vepřový řízek z kotlety, domácí bramborový salát", name: "Vepřový řízek z kotlety, domácí bramborový salát",
price: "170\xA0Kč", price: "170\xA0Kč",
isSoup: false, isSoup: false,
},
{
amount: "-",
name: "Slovácké strapačky s uzenou slaninou, zelím, mletým pepřem & sekanou petrželkou",
price: "140\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Hovězí guláš s vejcem, zeleninovou garniturkou & žemlovými knedlíky",
price: "145\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Kuřecí roláda s kaštanovou nádivkou, demi-glace & smetanovou bramborovou kaší",
price: "150\xA0Kč",
isSoup: false,
} }
], ],
[ [
@@ -515,6 +613,24 @@ const MOCK_DATA = {
name: "Burger z Chuck rollu, hranolky, tatarská omáčka", name: "Burger z Chuck rollu, hranolky, tatarská omáčka",
price: "200\xA0Kč", price: "200\xA0Kč",
isSoup: false, isSoup: false,
},
{
amount: "-",
name: "Slovácké strapačky s uzenou slaninou, zelím, mletým pepřem & sekanou petrželkou",
price: "140\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Hovězí guláš s vejcem, zeleninovou garniturkou & žemlovými knedlíky",
price: "145\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Kuřecí roláda s kaštanovou nádivkou, demi-glace & smetanovou bramborovou kaší",
price: "150\xA0Kč",
isSoup: false,
} }
], ],
], ],
@@ -557,6 +673,18 @@ const MOCK_DATA = {
name: "Hovězí po Burgundsku, bramborová kaše", name: "Hovězí po Burgundsku, bramborová kaše",
price: "155\xA0Kč", price: "155\xA0Kč",
isSoup: false, isSoup: false,
},
{
amount: "-",
name: "Špagety s kuřecím masem, špenátem a smetanou",
price: "145\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Medailonky z vepřové panenky s fazolkami se slaninou, šťouchané brambory",
price: "185\xA0Kč",
isSoup: false,
} }
], ],
[ [
@@ -571,6 +699,18 @@ const MOCK_DATA = {
name: "Kuřecí plátky na sušených rajčatech, bylinkách a česneku, bramborová kaše", name: "Kuřecí plátky na sušených rajčatech, bylinkách a česneku, bramborová kaše",
price: "155\xA0Kč", price: "155\xA0Kč",
isSoup: false, isSoup: false,
},
{
amount: "-",
name: "Špagety s kuřecím masem, špenátem a smetanou",
price: "145\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Medailonky z vepřové panenky s fazolkami se slaninou, šťouchané brambory",
price: "185\xA0Kč",
isSoup: false,
} }
], ],
[ [
@@ -585,6 +725,18 @@ const MOCK_DATA = {
name: "Rajská s plněnou paprikou, knedlík", name: "Rajská s plněnou paprikou, knedlík",
price: "170\xA0Kč", price: "170\xA0Kč",
isSoup: false, isSoup: false,
},
{
amount: "-",
name: "Špagety s kuřecím masem, špenátem a smetanou",
price: "145\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Medailonky z vepřové panenky s fazolkami se slaninou, šťouchané brambory",
price: "185\xA0Kč",
isSoup: false,
} }
], ],
[ [
@@ -599,6 +751,18 @@ const MOCK_DATA = {
name: "Ragú z trhané kachny, onsen vejce, soté ze špenátu a ředkvičky, bramborové pyré, lanýžová sůl, zelený olej", name: "Ragú z trhané kachny, onsen vejce, soté ze špenátu a ředkvičky, bramborové pyré, lanýžová sůl, zelený olej",
price: "189\xA0Kč", price: "189\xA0Kč",
isSoup: false, isSoup: false,
},
{
amount: "-",
name: "Špagety s kuřecím masem, špenátem a smetanou",
price: "145\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Medailonky z vepřové panenky s fazolkami se slaninou, šťouchané brambory",
price: "185\xA0Kč",
isSoup: false,
} }
], ],
], ],
@@ -1358,7 +1522,7 @@ const MOCK_PIZZA_LIST = [
* Funkce vrací mock datu ve formátu YYYY-MM-DD * Funkce vrací mock datu ve formátu YYYY-MM-DD
*/ */
export const getTodayMock = (): Date => { export const getTodayMock = (): Date => {
return new Date('2025-01-10'); // pátek return new Date('2025-01-08'); // středa
} }
export const getMenuSladovnickaMock = () => { export const getMenuSladovnickaMock = () => {

View File

@@ -8,49 +8,6 @@ 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 || "{}";
// const gotifyData: GotifyServer[] = JSON.parse(gotifyDataRaw);
// export const gotifyCall = async (data: NotififaceInput, gotifyServers?: GotifyServer[]): Promise<any[]> => {
// if (!Array.isArray(gotifyServers)) {
// return []
// }
// const urls = gotifyServers.flatMap(gotifyServer =>
// gotifyServer.api_keys.map(apiKey => `${gotifyServer.server}/message?token=${apiKey}`));
//
// 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;
// }
// }
// // Handle unknown error without a response
// console.log(error, "unknown error");
// })
// );
// return promises;
// };
export const ntfyCall = async (data: NotifikaceInput) => { export const ntfyCall = async (data: NotifikaceInput) => {
const url = process.env.NTFY_HOST const url = process.env.NTFY_HOST
const username = process.env.NTFY_USERNAME; const username = process.env.NTFY_USERNAME;

View File

@@ -96,9 +96,7 @@ export async function addPizzaOrder(login: string, pizza: Pizza, size: PizzaSize
totalPrice: 0, totalPrice: 0,
hasQr: false, hasQr: false,
} }
if (!clientData.pizzaDay.orders) { clientData.pizzaDay.orders ??= [];
clientData.pizzaDay.orders = [];
}
clientData.pizzaDay.orders.push(order); clientData.pizzaDay.orders.push(order);
} }
const pizzaOrder: PizzaVariant = { const pizzaOrder: PizzaVariant = {
@@ -107,9 +105,7 @@ export async function addPizzaOrder(login: string, pizza: Pizza, size: PizzaSize
size: size.size, size: size.size,
price: size.price, price: size.price,
} }
if (!order.pizzaList) { order.pizzaList ??= [];
order.pizzaList = [];
}
order.pizzaList.push(pizzaOrder); order.pizzaList.push(pizzaOrder);
order.totalPrice += pizzaOrder.price; order.totalPrice += pizzaOrder.price;
await storage.setData(today, clientData); await storage.setData(today, clientData);

View File

@@ -23,7 +23,7 @@ const SOUP_NAMES = [
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 ZASTAVKAUMICHALA_URL = 'https://www.zastavkaumichala.cz';
@@ -53,6 +53,28 @@ const sanitizeText = (text: string): string => {
return text.replace('\t', '').replace(' , ', ', ').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: [] };
}
/** /**
* Stáhne a vrátí aktuální HTML z dané URL. * Stáhne a vrátí aktuální HTML z dané URL.
* *
@@ -78,83 +100,97 @@ export const getMenuSladovnicka = async (firstDayOfWeek: Date, mock: boolean = f
const html = await getHtml(SLADOVNICKA_URL); const html = await getHtml(SLADOVNICKA_URL);
const $ = load(html); const $ = load(html);
const list = $('ul.tab-links').children(); // Nejdříve zjistíme, které dny jsou k dispozici z tab elementů
const result: Food[][] = []; const tabElements = $('#daily-menu-tab-list').children('button[id^="daily-menu-tab-"]');
for (let dayIndex = 0; dayIndex < 5; dayIndex++) { const availableDays: { [dayIndex: number]: number } = {}; // mapování dayIndex -> contentIndex
const currentDate = new Date(firstDayOfWeek);
currentDate.setDate(firstDayOfWeek.getDate() + dayIndex); tabElements.each((contentIndex, tabElement) => {
const searchedDayText = `${currentDate.getDate()}.${currentDate.getMonth() + 1}.${capitalize(DAYS_IN_WEEK[dayIndex])}`; const dayText = $(tabElement).find('.daily-menu-tab__day').text().toLowerCase();
// Najdeme index pro vstupní datum (např. při svátcích bude posunutý) const dayIndex = DAYS_IN_WEEK.indexOf(dayText);
// TODO validovat, že vstupní datum je v aktuálním týdnu if (dayIndex !== -1 && dayIndex < 5) { // pouze pracovní dny (0-4)
// 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) availableDays[dayIndex] = contentIndex;
let index = undefined;
list.each((i, dayRow) => {
const rowText = $(dayRow).first().text().trim();
if (rowText === searchedDayText) {
index = i;
} }
}) });
if (index === undefined) {
// Pravděpodobně svátek, nebo je zavřeno const menuContentElements = $('#daily-menu-content-list').children('[id^="daily-menu-content-"]');
result[dayIndex] = [{
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] = [];
}
// Projdeme pouze dostupné dny
for (const [dayIndex, contentIndex] of Object.entries(availableDays)) {
const dayIndexNum = Number.parseInt(dayIndex);
const contentIndexNum = contentIndex;
if (contentIndexNum >= menuContentElements.length) {
continue; // Přeskočíme, pokud content element neexistuje
}
const dayChildren = $(menuContentElements[contentIndexNum]).children();
// Ověříme, že má element očekávanou strukturu
if (dayChildren.length < 2) {
console.warn(`Neočekávaný počet children v menu Sladovnické pro den ${dayIndexNum}: ${dayChildren.length}, očekávány alespoň 2 (polévka a hlavní jídlo)`);
continue; continue;
} }
// Dle dohledaného indexu najdeme správný tabpanel // Parsování polévky
const rows = $('div.tab-content').children(); const soupElement = dayChildren.get(0);
if (index >= rows.length) { const soupTable = $(soupElement).find('table tbody tr');
throw Error("V HTML nebyl nalezen řádek menu pro index " + index); const soupCells = soupTable.children('td');
}
const tabPanel = $(rows.get(index));
// Opětovná validace, že daný tabpanel je pro vstupní datum
const headers = tabPanel.find('h2');
if (headers.length !== 3) {
throw Error("Neočekávaný počet elementů h2 v menu pro datum " + searchedDayText + ", očekávány 3, ale nalezeno bylo " + headers.length);
}
const dayText = $(headers.get(0)).text().trim();
if (dayText !== searchedDayText) {
throw Error("Neočekávaný datum na řádce nalezeného dne: '" + dayText + "', ale očekáváno bylo '" + searchedDayText + "'");
}
// V tabpanelu očekáváme dvě tabulky - pro polévku a pro hlavní jídlo
const tables = tabPanel.find('table');
if (tables.length !== 2) {
throw Error("Neočekávaný počet tabulek na řádce nalezeného dne: " + tables.length + ", ale očekávány byly 2");
}
const currentDayFood: Food[] = [];
// Polévka - div -> table -> tbody -> tr -> 3x td
const soupCells = $(tables.get(0)).children().first().children().first().children();
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;
} }
const soupAmount = sanitizeText($(soupCells.get(0)).text());
const soupNameRaw = sanitizeText($(soupCells.get(1)).text());
const soupPrice = sanitizeText($(soupCells.get(2)).text().replace(' ', '\xA0'));
const soupParsed = parseAllergens(soupNameRaw);
// Parsování hlavních jídel
const mainCourseElement = dayChildren.get(1);
const mainCourseTable = $(mainCourseElement).find('table tbody');
const mainCourseRows = mainCourseTable.children('tr');
const currentDayFood: Food[] = [];
// Přidáme polévku do seznamu jídel
currentDayFood.push({ currentDayFood.push({
amount: sanitizeText($(soupCells.get(0)).text()), amount: soupAmount,
name: sanitizeText($(soupCells.get(1)).text()), name: soupParsed.cleanName,
price: sanitizeText($(soupCells.get(2)).text().replace(' ', '\xA0')), 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
mainCourseRows.each((i, foodRow) => { mainCourseRows.each((i, row) => {
const foodCells = $(foodRow).children(); const cells = $(row).children('td');
if (foodCells.length !== 3) { const amount = sanitizeText($(cells.get(0)).text());
throw Error("Neočekávaný počet buněk v řádku jídla: " + foodCells.length + ", ale očekávány byly 3"); const nameRaw = sanitizeText($(cells.get(1)).text());
} const price = sanitizeText($(cells.get(2)).text().replace(' ', '\xA0'));
const parsed = parseAllergens(nameRaw);
// Přeskočíme prázdné řádky (první řádek může být prázdný)
if (parsed.cleanName.trim().length > 0) {
currentDayFood.push({ 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,
}); });
})
result[dayIndex] = currentDayFood;
} }
});
result[dayIndexNum] = currentDayFood;
}
return result; return result;
} }
@@ -176,7 +212,7 @@ export const getMenuUMotliku = async (firstDayOfWeek: Date, mock: boolean = fals
// Najdeme první tabulku, nad kterou je v H3 datum začínající co nejdřívějším dnem v aktuálním týdnu // 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'); const tables = $('table.table.table-hover.Xtable-striped');
let usedTable; let usedTable;
let usedDate = new Date(firstDayOfWeek.getTime()); let usedDate = new Date(firstDayOfWeek);
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {
const dayOfWeekString = `${usedDate.getDate()}.${usedDate.getMonth() + 1}.`; const dayOfWeekString = `${usedDate.getDate()}.${usedDate.getMonth() + 1}.`;
for (const tableNode of tables) { for (const tableNode of tables) {
@@ -196,7 +232,7 @@ export const getMenuUMotliku = async (firstDayOfWeek: Date, mock: boolean = fals
if (usedTable == null) { if (usedTable == null) {
const firstDayOfWeekString = `${firstDayOfWeek.getDate()}.${firstDayOfWeek.getMonth() + 1}.`; const firstDayOfWeekString = `${firstDayOfWeek.getDate()}.${firstDayOfWeek.getMonth() + 1}.`;
throw Error(`Nepodařilo se najít tabulku pro týden začínající ${firstDayOfWeekString}`); throw new Error(`Nepodařilo se najít tabulku pro týden začínající ${firstDayOfWeekString}`);
} }
const body = usedTable.children().first(); const body = usedTable.children().first();
@@ -229,11 +265,11 @@ export const getMenuUMotliku = async (firstDayOfWeek: Date, mock: boolean = fals
} 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());
@@ -286,12 +322,12 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
}) })
} }
if (!font) { if (!font) {
throw Error('Chyba: nenalezen <font> pro obědy v HTML Techtower.'); throw new Error('Chyba: nenalezen <font> pro obědy v HTML Techtower.');
} }
const result: Food[][] = []; 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 = secondTry ? $(font).parent().siblings() : $(font).parent().parent().siblings(); const siblings = secondTry ? $(font).parent().parent().parent().siblings('p') : $(font).parent().parent().siblings();
let parsing = false; let parsing = false;
let currentDayIndex = 0; let currentDayIndex = 0;
for (let i = 0; i < siblings.length; i++) { for (let i = 0; i < siblings.length; i++) {
@@ -309,21 +345,25 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
continue; continue;
} }
let price = 'na\xA0váhu'; let price = 'na\xA0váhu';
let name = text.replace('•', ''); 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].replace('•', ''); nameRaw = split[0].replace('•', '');
} }
if (result[currentDayIndex] == null) { if (nameRaw.endsWith('')) {
result[currentDayIndex] = []; nameRaw = nameRaw.slice(0, -1).trim();
} }
const parsed = parseAllergens(nameRaw);
result[currentDayIndex] ??= [];
result[currentDayIndex].push({ 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,
}) })
} }
} }
@@ -342,7 +382,8 @@ export const getMenuZastavkaUmichala = async (firstDayOfWeek: Date, mock: boolea
return getMenuZastavkaUmichalaMock(); return getMenuZastavkaUmichalaMock();
} }
const nowDate = new Date().getDate(); const today = new Date();
today.setHours(0,0,0,0);
const headers = { const headers = {
"Cookie": "_nss=1; PHPSESSID=9e37de17e0326b0942613d6e67a30e69", "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", "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",
@@ -351,8 +392,8 @@ export const getMenuZastavkaUmichala = async (firstDayOfWeek: Date, mock: boolea
for (let dayIndex = 0; dayIndex < 5; dayIndex++) { for (let dayIndex = 0; dayIndex < 5; dayIndex++) {
const currentDate = new Date(firstDayOfWeek); const currentDate = new Date(firstDayOfWeek);
currentDate.setDate(firstDayOfWeek.getDate() + dayIndex); currentDate.setDate(firstDayOfWeek.getDate() + dayIndex);
currentDate.setHours(0,0,0,0);
if (currentDate.getDate() < nowDate || (currentDate.getDate() === nowDate && new Date().getHours() >= 14)) { if (currentDate < today || (currentDate.getTime() === today.getTime() && new Date().getHours() >= 14)) {
result[dayIndex] = [{ result[dayIndex] = [{
amount: undefined, amount: undefined,
name: "Pro tento den není uveřejněna nabídka jídel", name: "Pro tento den není uveřejněna nabídka jídel",
@@ -360,8 +401,9 @@ export const getMenuZastavkaUmichala = async (firstDayOfWeek: Date, mock: boolea
isSoup: false, isSoup: false,
}]; }];
} else { } else {
const url = (currentDate.getDate() === nowDate) ? const url = (currentDate.getTime() === today.getTime())
ZASTAVKAUMICHALA_URL : ZASTAVKAUMICHALA_URL + '/?do=dailyMenu-changeDate&dailyMenu-dateString=' + formatDate(currentDate, 'DD.MM.YYYY'); ? ZASTAVKAUMICHALA_URL
: ZASTAVKAUMICHALA_URL + '/?do=dailyMenu-changeDate&dailyMenu-dateString=' + formatDate(currentDate, 'DD.MM.YYYY');
const html = await axios.get(url, { const html = await axios.get(url, {
headers, headers,
}).then(res => res.data).then(content => content); }).then(res => res.data).then(content => content);
@@ -401,11 +443,13 @@ export const getMenuSenkSerikova = async (firstDayOfWeek: Date, mock: boolean =
}).then(res => decoder.decode(new Uint8Array(res.data))).then(content => content); }).then(res => decoder.decode(new Uint8Array(res.data))).then(content => content);
const $ = load(html); const $ = load(html);
const nowDate = new Date().getDate(); const today = new Date();
today.setHours(0,0,0,0);
const currentDate = new Date(firstDayOfWeek); const currentDate = new Date(firstDayOfWeek);
const result: Food[][] = []; const result: Food[][] = [];
let dayIndex = 0; let dayIndex = 0;
while (currentDate.getDate() < nowDate) { currentDate.setHours(0,0,0,0);
while (currentDate < today) {
result[dayIndex] = [{ result[dayIndex] = [{
amount: undefined, amount: undefined,
name: "Pro tento den není uveřejněna nabídka jídel", name: "Pro tento den není uveřejněna nabídka jídel",
@@ -419,10 +463,12 @@ export const getMenuSenkSerikova = async (firstDayOfWeek: Date, mock: boolean =
$('.menicka').each((i, element) => { $('.menicka').each((i, element) => {
const currentDayFood: Food[] = []; const currentDayFood: Food[] = [];
$(element).find('.popup-gallery li').each((j, element) => { $(element).find('.popup-gallery li').each((j, element) => {
const rawName = $(element).children('div.polozka').text();
const nameWithoutNumber = rawName.replace(/^\d+\.\s*/, '');
currentDayFood.push({ currentDayFood.push({
amount: '-', amount: '-',
name: $(element).children('div.polozka').text(), name: nameWithoutNumber,
price: $(element).children('div.cena').text().replace(/ /g, '\xA0'), price: $(element).children('div.cena').text().replaceAll(' ', '\xA0'),
isSoup: $(element).hasClass('polevka'), isSoup: $(element).hasClass('polevka'),
}); });
}); });

View File

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

View File

@@ -5,7 +5,7 @@ import path from "path";
import fs from "fs"; import fs from "fs";
import { EasterEgg } from "../../../types/gen/types.gen"; import { EasterEgg } from "../../../types/gen/types.gen";
const EASTER_EGGS_JSON_PATH = path.join(__dirname, "../../.easter-eggs.json"); const EASTER_EGGS_JSON_PATH = path.join(__dirname, "../../resources/.easter-eggs.json");
const IMAGES_PATH = '../../resources/easterEggs'; const IMAGES_PATH = '../../resources/easterEggs';
type EasterEggsJson = { type EasterEggsJson = {

View File

@@ -1,11 +1,52 @@
import express, { Request } from "express"; import express, { Request, Response } from "express";
import { getLogin, getTrusted } from "../auth"; import { getLogin, getTrusted } from "../auth";
import { addChoice, getDateForWeekIndex, getToday, removeChoice, removeChoices, updateDepartureTime, updateNote } from "../service"; import { addChoice, getDateForWeekIndex, getToday, removeChoice, removeChoices, updateDepartureTime, updateNote, fetchRestaurantWeekMenuData, saveRestaurantWeekMenu } from "../service";
import { getDayOfWeekIndex, parseToken } from "../utils"; import { getDayOfWeekIndex, parseToken, getFirstWorkDayOfWeek } from "../utils";
import { getWebsocket } from "../websocket"; import { getWebsocket } from "../websocket";
import { callNotifikace } from "../notifikace"; import { callNotifikace } from "../notifikace";
import { AddChoiceData, ChangeDepartureTimeData, RemoveChoiceData, RemoveChoicesData, UdalostEnum, UpdateNoteData } from "../../../types/gen/types.gen"; 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ň * 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. * roven nebo vyšší indexu dnešního dne.
@@ -141,4 +182,85 @@ router.post("/jdemeObed", async (req, res, next) => {
} catch (e: any) { next(e) } } 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; export default router;

View File

@@ -0,0 +1,15 @@
import express, { Request, Response } from "express";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import { GenerateQrData } from "../../../types";
const router = express.Router();
router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, res: Response<any>) => {
getLogin(parseToken(req));
console.log("Bank account for QR codes:", req.body.bankAccount);
console.log("Bank account holder for QR codes:", req.body.bankAccountHolder);
console.log("Requested QR codes for users:", req.body.qrCodes);
});
export default router;

View File

@@ -1,4 +1,4 @@
import { InsufficientPermissions, formatDate, getDayOfWeekIndex, getFirstWorkDayOfWeek, getHumanDate, getIsWeekend, getWeekNumber } from "./utils"; import { InsufficientPermissions, formatDate, getDayOfWeekIndex, getFirstWorkDayOfWeek, getIsWeekend, getWeekNumber } from "./utils";
import getStorage from "./storage"; import getStorage from "./storage";
import { getMenuSladovnicka, getMenuTechTower, getMenuZastavkaUmichala, getMenuSenkSerikova } from "./restaurants"; import { getMenuSladovnicka, getMenuTechTower, getMenuZastavkaUmichala, getMenuSenkSerikova } from "./restaurants";
import { getTodayMock } from "./mock"; import { getTodayMock } from "./mock";
@@ -31,10 +31,7 @@ export const getDateForWeekIndex = (index: number) => {
function getEmptyData(date?: Date): ClientData { function getEmptyData(date?: Date): ClientData {
const usedDate = date || getToday(); const usedDate = date || getToday();
return { return {
todayDayIndex: getDayOfWeekIndex(getToday()), date: usedDate.toISOString().split('T')[0],
date: getHumanDate(usedDate),
isWeekend: getIsWeekend(usedDate),
dayIndex: getDayOfWeekIndex(usedDate),
choices: {}, choices: {},
}; };
} }
@@ -76,15 +73,107 @@ async function getMenu(date: Date): Promise<WeekMenu | undefined> {
} }
// TODO přesun do restaurants.ts // TODO přesun do restaurants.ts
/**
* Načte menu dané restaurace pro celý týden bez ukládání do storage.
* Používá se pro validaci dat před uložením.
*
* @param restaurant restaurace
* @param firstDay první pracovní den týdne
* @returns pole menu pro jednotlivé dny týdne
*/
export async function fetchRestaurantWeekMenuData(restaurant: Restaurant, firstDay: Date): Promise<any[]> {
return await fetchRestaurantWeekMenu(restaurant, firstDay);
}
/**
* Uloží týdenní menu restaurace do storage.
*
* @param restaurant restaurace
* @param date datum z týdne, pro který ukládat menu
* @param weekData data týdenního menu
*/
export async function saveRestaurantWeekMenu(restaurant: Restaurant, date: Date, weekData: any[]): Promise<void> {
const now = new Date().getTime();
let weekMenu = await getMenu(date);
weekMenu ??= [{}, {}, {}, {}, {}];
// Inicializace struktury pro restauraci
for (let i = 0; i < 5; i++) {
weekMenu[i] ??= {};
weekMenu[i][restaurant] ??= {
lastUpdate: now,
closed: false,
food: [],
};
}
// Uložení dat pro všechny dny
for (let i = 0; i < weekData.length && i < weekMenu.length; i++) {
weekMenu[i][restaurant]!.food = weekData[i];
weekMenu[i][restaurant]!.lastUpdate = now;
// Detekce uzavření pro každou restauraci
switch (restaurant) {
case 'SLADOVNICKA':
if (weekData[i].length === 1 && weekData[i][0].name.toLowerCase() === 'pro daný den nebyla nalezena denní nabídka') {
weekMenu[i][restaurant]!.closed = true;
}
break;
case 'TECHTOWER':
if (weekData[i]?.length === 1 && weekData[i][0].name.toLowerCase() === 'svátek') {
weekMenu[i][restaurant]!.closed = true;
}
break;
case 'ZASTAVKAUMICHALA':
if (weekData[i]?.length === 1 && weekData[i][0].name === 'Pro tento den není uveřejněna nabídka jídel.') {
weekMenu[i][restaurant]!.closed = true;
}
break;
case 'SENKSERIKOVA':
if (weekData[i]?.length === 1 && weekData[i][0].name === 'Pro tento den nebylo zadáno menu.') {
weekMenu[i][restaurant]!.closed = true;
}
break;
}
}
// Uložení do storage
await storage.setData(getMenuKey(date), weekMenu);
}
/**
* Načte menu dané restaurace pro celý týden bez ukládání do storage.
*
* @param restaurant restaurace
* @param firstDay první pracovní den týdne
* @returns pole menu pro jednotlivé dny týdne
*/
async function fetchRestaurantWeekMenu(restaurant: Restaurant, firstDay: Date): Promise<any[]> {
const mock = process.env.MOCK_DATA === 'true';
switch (restaurant) {
case 'SLADOVNICKA':
return await getMenuSladovnicka(firstDay, mock);
case 'TECHTOWER':
return await getMenuTechTower(firstDay, mock);
case 'ZASTAVKAUMICHALA':
return await getMenuZastavkaUmichala(firstDay, mock);
case 'SENKSERIKOVA':
return await getMenuSenkSerikova(firstDay, mock);
default:
throw new Error(`Nepodporovaná restaurace: ${restaurant}`);
}
}
/** /**
* Vrátí menu dané restaurace pro předaný den. * 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. * Pokud neexistuje, provede stažení menu pro příslušný týden a uložení do DB.
* *
* @param restaurant restaurace * @param restaurant restaurace
* @param date datum, ke kterému získat menu * @param date datum, ke kterému získat menu
* @param mock příznak, zda chceme pouze mock data * @param forceRefresh příznak vynuceného obnovení
*/ */
export async function getRestaurantMenu(restaurant: Restaurant, date?: Date): Promise<RestaurantDayMenu> { export async function getRestaurantMenu(restaurant: Restaurant, date?: Date, forceRefresh = false): Promise<RestaurantDayMenu> {
const usedDate = date ?? getToday(); const usedDate = date ?? getToday();
const dayOfWeekIndex = getDayOfWeekIndex(usedDate); const dayOfWeekIndex = getDayOfWeekIndex(usedDate);
const now = new Date().getTime(); const now = new Date().getTime();
@@ -97,93 +186,56 @@ export async function getRestaurantMenu(restaurant: Restaurant, date?: Date): Pr
} }
let weekMenu = await getMenu(usedDate); let weekMenu = await getMenu(usedDate);
if (weekMenu == null) { weekMenu ??= [{}, {}, {}, {}, {}];
weekMenu = [{}, {}, {}, {}, {}];
}
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
if (weekMenu[i] == null) { weekMenu[i] ??= {};
weekMenu[i] = {}; weekMenu[i][restaurant] ??= {
}
if (weekMenu[i][restaurant] == null) {
weekMenu[i][restaurant] = {
lastUpdate: now, lastUpdate: now,
closed: false, closed: false,
food: [], food: [],
}; };
} }
} if (forceRefresh || (!weekMenu[dayOfWeekIndex][restaurant]?.food?.length && !weekMenu[dayOfWeekIndex][restaurant]?.closed)) {
if (!weekMenu[dayOfWeekIndex][restaurant]?.food?.length) {
const firstDay = getFirstWorkDayOfWeek(usedDate); const firstDay = getFirstWorkDayOfWeek(usedDate);
const mock = process.env.MOCK_DATA === 'true';
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) { switch (restaurant) {
case 'SLADOVNICKA': case 'SLADOVNICKA':
try { if (restaurantWeekFood[i].length === 1 && restaurantWeekFood[i][0].name.toLowerCase() === 'pro daný den nebyla nalezena denní nabídka') {
const sladovnickaFood = await getMenuSladovnicka(firstDay, mock);
for (let i = 0; i < sladovnickaFood.length; i++) {
weekMenu[i][restaurant]!.food = sladovnickaFood[i];
// Velice chatrný a nespolehlivý způsob detekce uzavření...
if (sladovnickaFood[i].length === 1 && sladovnickaFood[i][0].name.toLowerCase() === 'pro daný den nebyla nalezena denní nabídka') {
weekMenu[i][restaurant]!.closed = true; weekMenu[i][restaurant]!.closed = true;
} }
}
} catch (e: any) {
console.error("Selhalo načtení jídel pro podnik Sladovnická", e);
}
break; break;
// case 'UMOTLIKU':
// try {
// const uMotlikuFood = await getMenuUMotliku(firstDay, mock);
// for (let i = 0; i < uMotlikuFood.length; i++) {
// menus[i][restaurant]!.food = uMotlikuFood[i];
// if (uMotlikuFood[i].length === 1 && uMotlikuFood[i][0].name.toLowerCase() === 'zavřeno') {
// menus[i][restaurant]!.closed = true;
// }
// }
// } catch (e: any) {
// console.error("Selhalo načtení jídel pro podnik U Motlíků", e);
// }
// break;
case 'TECHTOWER': case 'TECHTOWER':
try { if (restaurantWeekFood[i]?.length === 1 && restaurantWeekFood[i][0].name.toLowerCase() === 'svátek') {
const techTowerFood = await getMenuTechTower(firstDay, mock);
for (let i = 0; i < techTowerFood.length; i++) {
weekMenu[i][restaurant]!.food = techTowerFood[i];
if (techTowerFood[i]?.length === 1 && techTowerFood[i][0].name.toLowerCase() === 'svátek') {
weekMenu[i][restaurant]!.closed = true; weekMenu[i][restaurant]!.closed = true;
} }
}
} catch (e: any) {
console.error("Selhalo načtení jídel pro podnik TechTower", e);
}
break; break;
case 'ZASTAVKAUMICHALA': case 'ZASTAVKAUMICHALA':
try { if (restaurantWeekFood[i]?.length === 1 && restaurantWeekFood[i][0].name === 'Pro tento den není uveřejněna nabídka jídel.') {
const zastavkaUmichalaFood = await getMenuZastavkaUmichala(firstDay, mock);
for (let i = 0; i < zastavkaUmichalaFood.length; i++) {
weekMenu[i][restaurant]!.food = zastavkaUmichalaFood[i];
if (zastavkaUmichalaFood[i]?.length === 1 && zastavkaUmichalaFood[i][0].name === 'Pro tento den není uveřejněna nabídka jídel.') {
weekMenu[i][restaurant]!.closed = true; weekMenu[i][restaurant]!.closed = true;
} }
}
} catch (e: any) {
console.error("Selhalo načtení jídel pro podnik Zastávka u Michala", e);
}
break; break;
case 'SENKSERIKOVA': case 'SENKSERIKOVA':
try { if (restaurantWeekFood[i]?.length === 1 && restaurantWeekFood[i][0].name === 'Pro tento den nebylo zadáno menu.') {
const senkSerikovaFood = await getMenuSenkSerikova(firstDay, mock);
for (let i = 0; i < senkSerikovaFood.length; i++) {
weekMenu[i][restaurant]!.food = senkSerikovaFood[i];
if (senkSerikovaFood[i]?.length === 1 && senkSerikovaFood[i][0].name === 'Pro tento den nebylo zadáno menu.') {
weekMenu[i][restaurant]!.closed = true; weekMenu[i][restaurant]!.closed = true;
} }
}
} catch (e: any) {
console.error("Selhalo načtení jídel pro podnik Pivovarský šenk Šeříková", e);
}
break; break;
} }
}
// Uložení do storage
await storage.setData(getMenuKey(usedDate), weekMenu); await storage.setData(getMenuKey(usedDate), weekMenu);
} catch (e: any) {
console.error(`Selhalo načtení jídel pro podnik ${restaurant}`, e);
}
} }
return weekMenu[dayOfWeekIndex][restaurant]!; return weekMenu[dayOfWeekIndex][restaurant]!;
} }
@@ -326,9 +378,7 @@ export async function addChoice(login: string, trusted: boolean, locationKey: Lu
removeChoiceIfPresent(login, usedDate, locationKey); removeChoiceIfPresent(login, usedDate, locationKey);
} }
// TODO vytáhnout inicializaci "prázdné struktury" do vlastní funkce // TODO vytáhnout inicializaci "prázdné struktury" do vlastní funkce
if (!(data.choices[locationKey])) { data.choices[locationKey] ??= {};
data.choices[locationKey] = {}
}
if (!(login in data.choices[locationKey])) { if (!(login in data.choices[locationKey])) {
if (!data.choices[locationKey]) { if (!data.choices[locationKey]) {
data.choices[locationKey] = {} data.choices[locationKey] = {}
@@ -433,9 +483,5 @@ export async function updateDepartureTime(login: string, time?: string, date?: D
export async function getClientData(date?: Date): Promise<ClientData> { export async function getClientData(date?: Date): Promise<ClientData> {
const targetDate = date ?? getToday(); const targetDate = date ?? getToday();
const dateString = formatDate(targetDate); const dateString = formatDate(targetDate);
const clientData = await storage.getData<ClientData>(dateString) || getEmptyData(date); return await storage.getData<ClientData>(dateString) || getEmptyData(date);
return {
...clientData,
todayDayIndex: getDayOfWeekIndex(getToday()),
}
} }

View File

@@ -5,6 +5,12 @@
* 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 {
/**
* Inicializuje úložiště, pokud je potřeba (např. připojení k databázi).
*/
initialize?(): Promise<void>;
/** /**
* Vrátí příznak, zda existují data pro předaný klíč. * Vrátí příznak, zda existují data pro předaný klíč.
* @param key klíč, pro který zjišťujeme data (typicky datum) * @param key klíč, pro který zjišťujeme data (typicky datum)

View File

@@ -5,7 +5,7 @@ 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

@@ -11,6 +11,9 @@ export default class RedisStorage implements StorageInterface {
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();
} }

View File

@@ -29,9 +29,7 @@ export async function getUserVotes(login: string) {
*/ */
export async function updateFeatureVote(login: string, option: FeatureRequest, active: boolean): Promise<VotingData> { export async function updateFeatureVote(login: string, option: FeatureRequest, active: boolean): Promise<VotingData> {
let data = await storage.getData<VotingData>(STORAGE_KEY); let data = await storage.getData<VotingData>(STORAGE_KEY);
if (data == null) { data ??= {};
data = {};
}
if (!(login in data)) { if (!(login in data)) {
data[login] = []; data[login] = [];
} }

View File

@@ -1,5 +1,4 @@
import { Server } from "socket.io"; import { DefaultEventsMap, Server } from "socket.io";
import { DefaultEventsMap } from "socket.io/dist/typed-events";
let io: Server<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, any>; let io: Server<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, any>;

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": "../",

File diff suppressed because it is too large Load Diff

View File

@@ -65,6 +65,10 @@ paths:
/voting/updateVote: /voting/updateVote:
$ref: "./paths/voting/updateVote.yml" $ref: "./paths/voting/updateVote.yml"
# QR kódy (/api/qr)
/qr/generate:
$ref: "./paths/qr/generateQr.yml"
components: components:
schemas: schemas:
$ref: "./schemas/_index.yml" $ref: "./schemas/_index.yml"

View File

@@ -6,6 +6,6 @@
"devDependencies": { "devDependencies": {
"@hey-api/client-fetch": "^0.8.2", "@hey-api/client-fetch": "^0.8.2",
"@hey-api/openapi-ts": "^0.64.7", "@hey-api/openapi-ts": "^0.64.7",
"typescript": "^5.0.2" "typescript": "^5.9.3"
} }
} }

View File

@@ -0,0 +1,12 @@
post:
operationId: generateQr
summary: Generování QR kódů.
requestBody:
required: true
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/GenerateQrCodesRequest"
responses:
"200":
description: QR kódy byly úspěšně vygenerovány.

View File

@@ -21,23 +21,13 @@ ClientData:
type: object type: object
additionalProperties: false additionalProperties: false
required: required:
- todayDayIndex
- date - date
- isWeekend
- choices - choices
properties: properties:
todayDayIndex:
description: Index dnešního dne v týdnu
$ref: "#/DayIndex"
date: date:
description: Human-readable datum dne description: Datum konkrétního dne
type: string type: string
isWeekend: format: date
description: Příznak, zda je tento den víkend
type: boolean
dayIndex:
description: Index dne v týdnu, ke kterému se vztahují tato data
$ref: "#/DayIndex"
choices: choices:
$ref: "#/LunchChoices" $ref: "#/LunchChoices"
menus: menus:
@@ -151,6 +141,12 @@ Food:
isSoup: isSoup:
description: Příznak, zda se jedná o polévku description: Příznak, zda se jedná o polévku
type: boolean type: boolean
allergens:
description: Seznam čísel alergenů obsažených v jídle
type: array
items:
type: integer
example: [1, 3, 7]
RestaurantDayMenu: RestaurantDayMenu:
description: Menu restaurace na konkrétní den description: Menu restaurace na konkrétní den
type: object type: object
@@ -473,6 +469,43 @@ PizzaDay:
items: items:
$ref: "#/PizzaOrder" $ref: "#/PizzaOrder"
# --- QR KÓDY ---
QrCodeRequest:
description: Data potřebná pro vygenerování jednoho QR kódu pro platbu
type: object
required:
- login
- note
- amount
properties:
login:
description: Přihlašovací jméno uživatele, pro kterého bude QR kód vygenerován
type: string
note:
description: Popis platby
type: string
amount:
description: Částka platby v Kč
type: number
GenerateQrCodesRequest:
description: Data potřebná pro vygenerování QR kódů pro platbu
type: object
required:
- bankAccount
- bankAccountHolder
properties:
bankAccount:
description: Číslo bankovního účtu objednávajícího
type: string
bankAccountHolder:
description: Jméno majitele bankovního účtu
type: string
qrCodes:
description: Pole požadavků na vygenerování QR kódů
type: array
items:
$ref: "#/QrCodeRequest"
# --- NOTIFIKACE --- # --- NOTIFIKACE ---
UdalostEnum: UdalostEnum:
type: string type: string

View File

@@ -283,10 +283,10 @@ tinyexec@^0.3.2:
resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-0.3.2.tgz#941794e657a85e496577995c6eef66f53f42b3d2" resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-0.3.2.tgz#941794e657a85e496577995c6eef66f53f42b3d2"
integrity sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA== integrity sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==
typescript@^5.0.2: typescript@^5.9.3:
version "5.8.2" version "5.9.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.2.tgz#8170b3702f74b79db2e5a96207c15e65807999e4" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f"
integrity sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ== integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
ufo@^1.5.4: ufo@^1.5.4:
version "1.5.4" version "1.5.4"