267 Commits

Author SHA1 Message Date
batmanisko fe6bb3290e feat: přidání testů – Jest unit testy + Playwright E2E + CI pipeline
ci/woodpecker/push/workflow Pipeline was canceled
ci/woodpecker/pr/workflow Pipeline was canceled
Server:
- Jest unit testy (88 testů): auth, utils, restaurants, service, voting, pizza
- in-memory storage mock pro izolaci testů
- oprava race condition při inicializaci Redis (storageReady promise)
- dev route dostupná i pro NODE_ENV=test
- getStatsMock deterministický (nahrazení Math.random)
- exporty interních helperů pro testovatelnost
- /api/health endpoint pro Playwright readiness check
- tsconfig vylučuje test soubory z produkčního buildu

E2E (e2e/):
- Playwright s Firefoxem + Chromiem
- testy: login, menu, výběr jídla, pizza day životní cyklus, QR/nastavení
- trusted-header auth bypass pro testy, video + trace při selhání

CI (Woodpecker):
- pipeline spouštěna na všech větvích a PR (nejen master)
- redis-stack-server service pro E2E – čistý Redis per větev automaticky
- kroky: unit testy, build, E2E testy (parallel kde možné)
- Docker build zůstává pouze pro master

Co-Authored-By: Claude Opus (extra usage) 4.7 <noreply@anthropic.com>
2026-04-29 00:25:22 +02:00
batmanisko 1e1e23df80 feat: úhrada za všechny jednou osobou (issue #29, SINGLE_PAYMENT)
ci/woodpecker/push/workflow Pipeline was canceled
Přidává možnost, aby jeden strávník zaplatil celý účet v restauraci a ostatní
obdrželi QR kód pro refundaci.

Prerekvizita — podpora více QR kódů na (příjemce, den):
- PendingQr.id (UUID) nahrazuje deduplikaci podle data; každý QR má vlastní klíč
- QR obrázky uloženy do Redis/storage (base64) místo tmpdir — přežijí redeploy
- GET /api/qr vyžaduje ?id= parametr; dismissQr přijímá {id} místo {date}

Feature:
- Ikona 'Zaplatit za všechny' v choices-table pro každou LunchChoice (kromě
  PIZZA/NEOBEDVAM/ROZHODUJI); viditelná jen při ≥2 strávnících a vyplněném účtu
- PayForAllModal: tabulka strávníků s prefillovanými cenami z menu, příplatky
  per-diner, celkové dýško rozpočtené rovnoměrně, generování QR přes POST /api/qr/generate
- parsePriceCzk() helper pro parsing 'N Kč' → number

Co se nemění: POST /api/qr/generate API kontrakt, PizzaOrder.hasQr boolean

Co se mění v OpenAPI: PendingQr.id (required), getPizzaQr ?id param, dismissQr body

Co-Authored-By: opmrdkazkrtkaus <opmrdkazkrtkaus@melancholik.eu>
2026-04-28 22:44:32 +02:00
mates e5999852b7 docs: aktualizace CLAUDE.md
ci/woodpecker/push/workflow Pipeline was canceled
2026-04-28 13:40:32 +02:00
mates 4e7b83b667 fix: oprava parsování pro aktuální podobu TechTower
ci/woodpecker/push/workflow Pipeline failed
2026-04-28 12:50:19 +02:00
mates d6729388ab feat: podpora salátů z Pizza Chefie
ci/woodpecker/push/workflow Pipeline failed
2026-04-02 10:51:46 +02:00
mates e9696f722c feat: automatický výběr výchozího času
ci/woodpecker/push/workflow Pipeline failed
2026-03-09 11:50:24 +01:00
mates fdeb2636c2 fix: potvrzovací dialog pro Pizza day akce (#44)
ci/woodpecker/push/workflow Pipeline failed
2026-03-09 07:55:42 +01:00
mates 82ed16715f fix: odstranění textu "nepovinné"
ci/woodpecker/push/workflow Pipeline failed
2026-03-09 07:40:38 +01:00
mates 44cf749bc9 feat: nový způsob zobrazování novinek
ci/woodpecker/push/workflow Pipeline is pending
fix: oprava kopírování changelogů do Docker image

fix: oprava kopírování changelogů do Docker image

fix: oprava
2026-03-08 10:55:50 +01:00
batmanisko a1b1eed86d docs: přidána strategie vyhledávání kódu do CLAUDE.md
ci/woodpecker/push/workflow Pipeline was successful
2026-03-05 22:13:19 +01:00
batmanisko f8a65d7177 feat: detekce starého menu TechTower, příznak isStale
Pokud TechTower vrátí menu z jiného týdne, uloží data s příznakem
isStale a zobrazí varování "Data jsou z minulého týdne" místo chybové
hlášky. Odstraněno staré varování o datech starších 24 hodin.
2026-03-05 22:11:45 +01:00
batmanisko 607bcd9bf5 feat: uprava refresh menu hesel
každý může udělat refresh, jen ne tak často, bypass mimo zdrojak
2026-03-05 21:50:17 +01:00
batmanisko b6fdf1de98 feat: akce "Neobědvám" přímo z push notifikace
ci/woodpecker/push/workflow Pipeline was successful
2026-03-04 14:37:05 +01:00
batmanisko 27e56954bd fix: nahrazení selectu časovým inputem pro výběr času připomínky
ci/woodpecker/push/workflow Pipeline was successful
2026-03-04 14:14:29 +01:00
batmanisko 20cc7259a3 chore: test endpoint na push 2026-03-04 14:11:22 +01:00
batmanisko d62f6c1f5a feat: push notifikace pro připomínku výběru oběda
ci/woodpecker/push/workflow Pipeline was successful
2026-03-04 13:33:58 +01:00
batmanisko b77914404b retarded dsstore 2026-03-04 10:36:14 +01:00
batmanisko 8506b4e79f claudemd init
ci/woodpecker/push/workflow Pipeline failed
2026-03-04 10:35:13 +01:00
mates 5f79a9431c fix: oprava závislostí
ci/woodpecker/push/workflow Pipeline was successful
2026-02-20 14:32:02 +01:00
mates cc98c2be0d feat: podpora ručního generování QR kódů pro platby
ci/woodpecker/push/workflow Pipeline was successful
2026-02-20 14:17:39 +01:00
stanekpa a849f4e922 feat: zarovnani ikony varovani doprava
ci/woodpecker/push/workflow Pipeline was successful
2026-02-11 13:55:40 +01:00
mates ac6727efa5 feat: vylepšení Pizza day
ci/woodpecker/push/workflow Pipeline was successful
2026-02-10 23:59:58 +01:00
Stánek Pavel f13cd4ffa9 fix: opravy zobrazeni sekce vybranych jidel
ci/woodpecker/push/workflow Pipeline was successful
2026-02-05 10:18:58 +01:00
batmanisko 086646fd1c fix: přidání nových typů do OpenAPI spec pro přežití regenerace
ci/woodpecker/push/workflow Pipeline was successful
Typy PendingQr, NotificationSettings a nové endpointy
(dismissQr, notifications/settings) byly přidány přímo
do YAML specifikace místo ručních úprav generovaných souborů.
2026-02-04 17:34:05 +01:00
batmanisko b8629afef2 feat: trvalé zobrazení QR kódu do ručního zavření (#31)
QR kódy pro platbu za pizza day jsou nyní zobrazeny persistentně
i po následující dny, dokud uživatel nepotvrdí platbu tlačítkem
"Zaplatil jsem". Nevyřízené QR kódy jsou uloženy per-user v storage
a zobrazeny v sekci "Nevyřízené platby".
2026-02-04 17:34:05 +01:00
batmanisko d366ac39d4 feat: podpora per-user notifikací s Discord, ntfy a Teams (#39)
Uživatelé mohou v nastavení konfigurovat vlastní webhook URL/topic
pro Discord, MS Teams a ntfy, a zvolit události k odběru.
Notifikace jsou odesílány pouze uživatelům se stejnou zvolenou lokalitou.
2026-02-04 17:33:53 +01:00
batmanisko fdd42dc46a feat: zobrazení minulého týdne o víkendu místo "Užívejte víkend" (#30)
Na víkendu se nyní zobrazuje páteční menu s možností procházet celý týden.
Editační ovládací prvky jsou automaticky skryté díky existující logice canChangeChoice.
2026-02-04 14:56:24 +01:00
batmanisko 2b7197eff6 fix: zobrazení popisů funkcí místo varnames ve statistikách (#26)
ci/woodpecker/push/workflow Pipeline failed
2026-02-04 13:40:20 +01:00
batmanisko 6f43c74769 fix: resolve 6 Gitea issues (#9, #10, #12, #14, #15, #21)
- #21: Add missing await in removeChoiceIfPresent() to prevent user appearing in two restaurants
- #15: Add 1-hour TTL for menu refetching to avoid scraping on every page load
- #9: Block stats API and UI navigation for future dates
- #14: Add restaurant warnings (missing soup/prices, stale data) with warning icon
- #12: Pre-fill restaurant/departure dropdowns from existing choices on page refresh
- #10: Add voting statistics endpoint and table on stats page
2026-02-04 13:18:27 +01:00
Stánek Pavel d85c764c88 fix: opravy a ladeni vzhledu a UX 2026-02-04 12:06:17 +01:00
Stánek Pavel 37cacd895a feat: redesign aplikace pomocí claude code 2026-02-03 10:37:27 +01:00
batmanisko 6a1da97ef1 feat: podpora dark mode 2026-01-30 07:47:03 +01:00
mates f91973f1a4 Oprava parsování názvů TechTower 2026-01-19 10:18:24 +01:00
mates 7cf9179a87 Neumožnění výběru jídla kliknutím do minulosti 2026-01-13 16:03:31 +01:00
mates 54e5be6b6a Povýšení závislostí 2026-01-13 15:43:19 +01:00
mates c264f9921e Opravy dle SonarLint - klient 2026-01-13 15:35:00 +01:00
mates e03ba45415 Možnost označení objednávajícího 2026-01-13 14:06:16 +01:00
mates 20f4ee0427 Zimní atmosféra 2026-01-09 08:46:14 +01:00
mates be4cee4cdb Oprava parsování Sladovnická a TechTower 2026-01-09 08:35:35 +01:00
mates 8285dd2780 Povýšení závislostí
ci/woodpecker/push/workflow Pipeline was successful
2025-11-10 22:03:48 +01:00
mates 039d8457f3 Povýšení závislostí
ci/woodpecker/push/workflow Pipeline was successful
2025-11-06 21:22:39 +01:00
mates 091e25f446 Kopírování poznámky jen pokud je vyplněna
ci/woodpecker/push/workflow Pipeline was successful
2025-11-03 14:17:22 +01:00
mates 00b4a0cce2 Oprava/narovnání ikony kopírování poznámky
ci/woodpecker/push/workflow Pipeline was successful
2025-11-03 14:05:01 +01:00
batmanisko 979c79e090 poznamka, pak icon
ci/woodpecker/push/workflow Pipeline was successful
2025-11-03 13:43:20 +01:00
batmanisko d29c7863dd chat ikona misto barvicek
ci/woodpecker/push/workflow Pipeline was successful
2025-11-03 13:36:54 +01:00
batmanisko 0781b84f11 Kopírování komentaře na kliknutí.
ci/woodpecker/push/workflow Pipeline was successful
2025-11-03 12:57:22 +01:00
mates e3d217822a Oprava výběru jídla při přepínání dnů šipkami
ci/woodpecker/push/workflow Pipeline was successful
2025-11-03 12:48:56 +01:00
mates ccfe9a9ae1 Oprava parsování Sladovnické v případě chybějících dnů
ci/woodpecker/push/workflow Pipeline was successful
2025-10-31 08:59:30 +01:00
mates 7407a3d881 Oprava hover popisků u Font Awesome ikon
ci/woodpecker/push/workflow Pipeline was successful
2025-10-16 08:06:22 +02:00
mates 8b139eba4e Oprava zobrazení tooltipu při hoveru
ci/woodpecker/push/workflow Pipeline was successful
2025-10-16 07:39:39 +02:00
mates 74eeef1de9 Aktualizace posledních změn
ci/woodpecker/push/workflow Pipeline was successful
2025-10-11 13:24:11 +02:00
mates fd67c0e646 Oprava buildu
ci/woodpecker/push/workflow Pipeline was successful
2025-10-11 13:15:47 +02:00
mates 60150889b0 Povýšení závislostí
ci/woodpecker/push/workflow Pipeline failed
2025-10-11 13:02:56 +02:00
mates 9e0e842c2d Podzimní atmosféra
ci/woodpecker/push/workflow Pipeline was successful
2025-10-11 11:03:20 +02:00
mates 86c50b315a Přidání alergenů do mock dat
ci/woodpecker/push/workflow Pipeline failed
2025-10-11 11:01:42 +02:00
mates fe7d609b5f Oprava zobrazení patičky
ci/woodpecker/push/workflow Pipeline was successful
2025-10-11 10:36:21 +02:00
mates 331a890cc5 Oddělení přenačtení menu do vlastní komponenty
ci/woodpecker/push/workflow Pipeline was successful
2025-10-10 14:00:01 +02:00
mates 523bbbfb0f Proklik na seznam alergenů
ci/woodpecker/push/workflow Pipeline was successful
2025-10-10 13:15:18 +02:00
mates 1acf9bf092 Úprava cesty pro čtení easter eggs
ci/woodpecker/push/workflow Pipeline was successful
2025-10-10 08:49:17 +02:00
mates 81f67c8424 Podpora parsování a zobrazení alergenů
ci/woodpecker/push/workflow Pipeline was successful
2025-10-06 16:28:38 +02:00
mates c2a001b7e5 Oprava parsování TechTower dle aktuální podoby HTML
ci/woodpecker/push/workflow Pipeline was successful
2025-10-06 13:12:58 +02:00
mates 670e45b805 Nevyvolávat přenačtení u zavřených podniků
ci/woodpecker/push/workflow Pipeline was successful
2025-08-11 10:30:42 +02:00
mates 52769fc981 Opravy dle SonarQube
ci/woodpecker/push/workflow Pipeline was successful
2025-08-07 13:12:55 +02:00
mates 0d90453c38 Oprava chybného čtení .env souborů 2025-08-07 13:02:41 +02:00
mates a9709a944f Úprava pro novou podobu stránek Sladovnická
ci/woodpecker/push/workflow Pipeline was successful
2025-08-04 17:30:04 +02:00
mates 593ffcf02b Vylepšení run_dev.sh pro vývoj 2025-08-04 17:27:03 +02:00
mates b4b62870e3 Úprava .gitignore 2025-08-04 17:26:38 +02:00
mates 480fe725f1 Oprava načítání jídel pro Šenk Šeříková na přelomu měsíce
ci/woodpecker/push/workflow Pipeline was successful
2025-08-01 14:00:27 +02:00
batmanisko d2845f7d0f Merge pull request 'feat/odflaknutyRefreshDat' (#17) from feat/odflaknutyRefreshDat into master
ci/woodpecker/push/workflow Pipeline was successful
Reviewed-on: #17
2025-08-01 09:05:50 +02:00
batmanisko 269f1994bc Update novinky 2025-08-01 09:04:28 +02:00
batmanisko 3dcda2028e Error pri fetch do klienta 2025-07-31 23:47:43 +02:00
batmanisko cfffd2b31d pro refresh endpoint nevyzadovat authtoken 2025-07-31 23:45:47 +02:00
batmanisko 58bb5f4e7d pro refresh endpoint nevyzadovat authtoken 2025-07-31 23:41:51 +02:00
batmanisko a3dfdb17e8 fix async.... 2025-07-31 23:37:21 +02:00
batmanisko 124fdce69d tak jsem to mozna robil, ale mozna taky ne lol 2025-07-31 23:35:38 +02:00
batmanisko ff20394b97 feat: Přidání funkce pro manuální refresh jidel. 2025-07-31 23:29:19 +02:00
batmanisko a77a04bcdf umichal patek fix
ci/woodpecker/push/workflow Pipeline was successful
2025-07-30 12:00:03 +02:00
mates 42852805e0 Oprava plnění data a času poslední aktualizace menu
ci/woodpecker/push/workflow Pipeline was successful
2025-07-29 15:41:05 +02:00
batmanisko d767730b19 sonar
ci/woodpecker/push/workflow Pipeline was successful
2025-07-29 15:15:16 +02:00
batmanisko fa4f9903cb parametr forceupdate jidla
ci/woodpecker/push/workflow Pipeline was successful
2025-07-29 11:33:31 +02:00
batmanisko cf8be8c64f fix: feat jsem to dal na spatnej radaek
ci/woodpecker/push/workflow Pipeline was successful
2025-07-29 10:55:04 +02:00
batmanisko 4c2b08adf8 feat: refresh jidla endpoint
ci/woodpecker/push/workflow Pipeline failed
2025-07-29 10:43:49 +02:00
mates 62cc82da9a Úprava parsování TechTower pro aktuální týden
ci/woodpecker/push/workflow Pipeline was successful
2025-07-22 17:10:39 +02:00
mates 40c113a4c8 Oprava pádů při načítání z menicka.cz
ci/woodpecker/push/workflow Pipeline was successful
2025-07-07 08:47:39 +02:00
mates 7681584d11 Oprava parsování TechTower pro aktuální stav
ci/woodpecker/push/workflow Pipeline was successful
2025-07-07 08:24:26 +02:00
mates c670b4212a Revert "TechTower hack pro tento specifický týden"
ci/woodpecker/push/workflow Pipeline was successful
This reverts commit 5fd90de3f8.
2025-05-26 10:13:35 +02:00
mates 5fd90de3f8 TechTower hack pro tento specifický týden
ci/woodpecker/push/workflow Pipeline was successful
2025-05-20 08:02:34 +02:00
batmanisko 49b8ab5c13 Update server/src/index.ts
ci/woodpecker/push/workflow Pipeline was successful
delete req.headers["cookie"]
2025-04-11 12:06:52 +02:00
batmanisko 9a05ef1fe6 Update server/src/index.ts
ci/woodpecker/push/workflow Pipeline was successful
vic logov
2025-04-11 12:01:58 +02:00
batmanisko 0bfea3765f properta pro logovani headeru
ci/woodpecker/push/workflow Pipeline was successful
2025-04-11 10:42:27 +02:00
batmanisko 962fbe2947 fix hardcoded header name xd
ci/woodpecker/push/workflow Pipeline was successful
2025-04-11 10:06:35 +02:00
mates d6d6ebb682 Aktualizace posledních změn
ci/woodpecker/push/workflow Pipeline was successful
2025-03-21 00:24:26 +01:00
mates 5bb7de58e7 Odebrání zimní atmosféry 2025-03-21 00:24:17 +01:00
mates 739c7707e1 Migrace serveru na OpenAPI
ci/woodpecker/push/workflow Pipeline was successful
2025-03-20 23:50:47 +01:00
mates d366882f6b Migrace klienta na OpenAPI
ci/woodpecker/push/workflow Pipeline was successful
2025-03-19 23:08:46 +01:00
mates f09bc44d63 Oprava nefunkčního odebrání prvního vybraného jídla
ci/woodpecker/push/workflow Pipeline was successful
2025-03-11 20:57:37 +01:00
mates f0d56f11aa Oprava popisu varianty "neobědvám"
ci/woodpecker/push/workflow Pipeline was successful
2025-03-11 19:41:37 +01:00
mates f74ec379c8 Oprava výběru možnosti stravování
ci/woodpecker/push/workflow Pipeline was successful
2025-03-06 08:03:49 +01:00
mates c9fa710070 Oprava buildu
ci/woodpecker/push/workflow Pipeline failed
2025-03-06 07:59:33 +01:00
mates e55ee7c11e Refaktor: Nálezy SonarQube
ci/woodpecker/push/workflow Pipeline is running
2025-03-05 21:48:02 +01:00
mates 55fd368663 Oprava Woodpecker pipeline
ci/woodpecker/push/workflow Pipeline was successful
2025-03-05 21:19:33 +01:00
mates 61f13d2132 Validace TypeScript typů při sestavení klienta
ci/woodpecker/push/workflow Pipeline failed
2025-03-05 21:05:40 +01:00
mates d69e09afee Migrace na OpenAPI - TypeScript typy 2025-03-05 21:05:21 +01:00
batmanisko d144c55bf7 feat: #11 je tohle feat?, pridani poctu lidi k restauraci
ci/woodpecker/push/workflow Pipeline was successful
2025-03-05 18:56:21 +01:00
mates 999a517404 Oprava lokalizace datumu
ci/woodpecker/push/workflow Pipeline was successful
2025-03-03 10:20:41 +01:00
mates 68bafa808c Oprava #8
ci/woodpecker/push/workflow Pipeline was successful
2025-02-27 21:46:50 +01:00
mates a34614c8db Oprava #6
ci/woodpecker/push/workflow Pipeline failed
2025-02-27 21:36:21 +01:00
mates f4e31cea36 Oprava #4, #5
ci/woodpecker/push/workflow Pipeline was successful
2025-02-27 21:29:43 +01:00
mates 8dda6b1014 Oprava #7
ci/woodpecker/push/workflow Pipeline was successful
2025-02-27 21:19:38 +01:00
mates f9c7d647f7 Migrace Node v18 -> v22
ci/woodpecker/push/workflow Pipeline was successful
2025-02-27 00:28:14 +01:00
mates ca400638d1 Přidání základních statistik
ci/woodpecker/push/workflow Pipeline failed
2025-02-27 00:22:34 +01:00
mates 0af78e72d9 Nastavení časové zóny
ci/woodpecker/push/workflow Pipeline was successful
2025-02-24 12:06:29 +01:00
Michal Hájek 8137ca6fc0 Teamsová notifikace "Jdeme na oběd"
ci/woodpecker/push/workflow Pipeline was successful
2025-02-22 20:43:34 +01:00
Michal Hájek 3817126ac0 Výběr restaurace kliknutím na její název
ci/woodpecker/push/workflow Pipeline was successful
2025-02-19 20:39:27 +01:00
Michal Hájek c1856b2eee Pokud bylo v osobním nastavení vypnuto zobraování polévky, předával se do funkce doAddClickFoodChoice spatně foodIndex
ci/woodpecker/push/workflow Pipeline was successful
2025-02-19 20:21:17 +01:00
Michal Hájek eaf0bc353d Výběr obědu kliknutím
ci/woodpecker/push/workflow Pipeline was successful
2025-02-18 10:07:35 +01:00
batmanisko ff650ec3b8 rm db.json
ci/woodpecker/push/workflow Pipeline was successful
2025-02-17 09:32:23 +01:00
batmanisko f8aa293413 fix
ci/woodpecker/push/workflow Pipeline is running
2025-02-17 09:26:03 +01:00
batmanisko cafcd0a467 Log username a email pri kazdem dotazu pouze pro neproduction env
ci/woodpecker/push/workflow Pipeline was successful
2025-02-17 09:19:28 +01:00
mates 9e247eb2a1 Podpora sestavování přes Woodpecker CI
ci/woodpecker/push/workflow Pipeline was successful
2025-02-09 00:34:59 +01:00
mates 469a6b9031 Oprava .gitignore 2025-02-08 23:28:20 +01:00
Michal Hájek 89dec1c194 Založení složky server/data, pokud neexistuje, do které je vytvořen soubor db.json 2025-02-02 19:46:20 +01:00
Michal Hájek f3af64923c Přesun json databaze (souboru db.json) do složky data, související úpravy v Dockerfile 2025-02-02 16:09:07 +01:00
Michal Hájek 44b09a9d1a Začištění souborů .gitignore 2025-02-02 16:06:52 +01:00
Michal Hájek c311cc2fd7 Oprava importů klienta do složky types, aby nebylo potřeba složku kokírovat 2025-02-02 16:01:21 +01:00
Michal Hájek a9fe369abc Oprava možnosti vybrat V kolik hodin preferuješ odchod pro následující dny 2025-01-29 08:48:43 +01:00
Michal Hájek ea9fe980f0 U restaurace Pivovarský šenk Šeříková nahrazena mezera mezi cenou a Kč pevnou mezerou, aby nedocházelo k zalomení 2025-01-29 01:29:51 +01:00
Michal Hájek d367826ce0 Přidání restaurace Pivovarský šenk Šeříková 2025-01-29 01:14:03 +01:00
Michal Hájek fdf1ae938f Načtení menu celého týdne restaurace Zastávka u Michala 2025-01-28 22:20:44 +01:00
mates 57c22958be Oprava chybné detekce některých jídel TechTower jako polévka 2025-01-20 14:41:18 +01:00
Michal Hájek fe9cee3a80 Odfiltrovat ze selectboxu pro výběr preferovaného odchodu časy z minulosti 2025-01-15 00:35:17 +01:00
Michal Hájek 1d995faf8e Odfiltrovat ze selectboxu pro výběr preferovaného odchodu časy z minulosti 2025-01-15 00:34:47 +01:00
Michal Hájek 62fff22a12 Přidání restaurace Zastávka u Michala do výběru "Jak to dnes vidíš s obědem" 2025-01-15 00:03:15 +01:00
Michal Hájek 0fd1482810 Přidání restaurace Zastávka u Michala 2025-01-14 23:45:06 +01:00
mates 02de6691a8 Migrace z pořadových indexů na unikátní klíče 2025-01-09 22:05:20 +01:00
mates 774cb4f9d2 Oprava syntaxe - zapomenutá migrace interface 2025-01-09 21:04:12 +01:00
mates fd9aa547e2 Migrace "interface" na "type" 2025-01-08 20:53:48 +01:00
mates e611d36995 Otypování requestů na API 2025-01-08 17:58:49 +01:00
mates 414664b2d7 Úprava API pro podporu TypeScript 2025-01-08 17:43:47 +01:00
mates a2167038da Redukce velikosti obrázku 2025-01-07 15:52:10 +01:00
mates 219f7ffbc8 Zimní atmosféra 2025-01-07 15:49:22 +01:00
mates 4d2ec529bb Skrytí podniku U Motlíků 2025-01-07 15:44:39 +01:00
mates 86af490e94 Oprava parsování TechTower 2025-01-07 15:21:10 +01:00
mates e21da059c6 Aktualizace posledních změn 2024-12-11 23:31:55 +01:00
mates e990108140 Migrace na React 19 2024-12-11 23:04:03 +01:00
mates 18f2b72133 Migrace sestavování klienta na Vite 2024-12-11 22:54:57 +01:00
mates b0d8a1a830 Povýšení závislostí 2024-12-11 20:24:06 +01:00
mates 7e4fa236b1 Podpora easter eggů 2024-12-11 20:09:45 +01:00
mates 98f2b2a1e0 Přidání vánočních prvků 2024-12-06 16:53:24 +01:00
batmanisko 9b7abb0703 throw error 2024-11-19 12:10:02 +01:00
batmanisko 5678e4a606 Snad fix timeout 2024-11-19 12:01:59 +01:00
mates 582216015c Přidání easter-eggs.json do .gitignore 2024-11-13 23:30:54 +01:00
mates 31daf4fb36 Začištění Dockerfile a compose.yml 2024-10-30 13:05:05 +01:00
mates 4f858a19d8 Oprava parsování TechTower 2024-10-30 13:01:32 +01:00
mates 91ea07a539 Oprava parsování TechTower 2024-07-08 20:45:09 +02:00
mates 101bd60ddb Oprava case-sensitive parsování TechTower 2024-06-10 12:56:20 +02:00
mates 7e061aa890 Nové možnosti hlasování 2024-04-11 22:00:34 +02:00
mates ff2d9e4fdb Vylepšení mobilního zobrazení 2024-04-09 17:40:13 +02:00
mates e261d32170 Úprava zápatí 2024-03-24 18:53:51 +01:00
mates 731fd2eeb9 Oprava validace délky poznámky 2024-03-24 18:51:38 +01:00
mates 93ba8def03 Oprava posunů mezi dny v inputech 2024-03-05 23:10:38 +01:00
mates 1e280e9d05 Možnost zadání obecné poznámky k volbě 2024-03-04 23:35:58 +01:00
mates 44187bc316 Oprava vyhodnocení nastavení trusted headers 2024-03-04 23:33:22 +01:00
mates 4bd825fbcf Aktualizace TODO 2024-02-26 20:34:14 +01:00
mates b087c790ad Povýšení závislostí 2024-02-26 20:23:14 +01:00
mates e4a146995f Oprava nodemon hotreload 2024-02-26 20:16:11 +01:00
batmanisko 2883e80658 urcite neco rozbije a pavel to najde jako prvni 2024-02-05 20:02:21 +01:00
mates 5830cde9ac Aktualizace frází pro detekci polévek v TechTower 2024-02-02 22:05:54 +01:00
mates 52c4a53b9e Aktualizace changelogu 2024-01-28 21:15:38 +01:00
mates e9ea42c636 Oprava varování linteru 2024-01-28 20:46:38 +01:00
mates e735af4fc1 Neuskakování šipek 2024-01-25 19:18:05 +01:00
mates 56125eea2e Přidání možnosti "Rozhoduji se" 2024-01-24 19:29:09 +01:00
mates 61b6ec04f4 Rozšíření výčtu polévek pro TechTower 2024-01-24 19:11:21 +01:00
mates b954374425 Aktualizace mock dat 2024-01-24 19:07:49 +01:00
mates 72c7bfe80c Možnost skrytí polévek 2024-01-24 18:54:07 +01:00
mates 2633d445cc Doplnění chybějící nedělitelné mezery 2024-01-23 08:00:21 +01:00
mates 3fd6b7dfcb Text "na váhu" v případě neznámé ceny u TechTower 2024-01-22 21:32:04 +01:00
mates 8e075dd904 Základní Pizza kalkulačka 2024-01-08 23:39:12 +01:00
mates 74f6e1ab69 Oprava parsování Sladovnická
Opravena chyba, kdy docházelo k posunu nabídky o den, pokud nabídka nezačínala pondělím.
2024-01-03 14:03:38 +01:00
mates fcad338921 Oprava parsování U Motlíků
Nyní je počítáno s případy, kdy neexistuje nabídka pro první den v daném týdnu.
2024-01-03 14:01:45 +01:00
mates aca4055d57 Přidání trusted headers do .env.template 2023-12-02 21:07:15 +01:00
mates 4991b813bf Umožnit zadání trusted IPs s bílými znaky 2023-12-02 20:54:39 +01:00
mates 515d4bb47e Opravy překlepů 2023-12-02 20:50:19 +01:00
mates b9b2492cb4 Odstranění zbytečné proměnné 2023-12-02 20:47:38 +01:00
batmanisko 4ff5d70331 tohle prepsalo muj list ip adres 2023-12-02 19:09:12 +01:00
batmanisko 44de01f6eb doufam ze jsem to hodne rozjebal lol 2023-12-02 17:45:56 +01:00
mates f85d19bbd6 Vylepšení parsování U Motlíků
Nově je počítáno s případy, kdy na stránkách existuje menu pro více týdnů současně.
2023-11-20 19:33:59 +01:00
mates 7024c75f37 Přidání favicony 2023-11-10 21:04:33 +01:00
mates b9b9487375 Neumožnit změnu volby do minulosti 2023-11-10 20:48:34 +01:00
mates bbcb4c34b1 Lepší zobrazení poslední aktualizace menu 2023-11-10 20:40:59 +01:00
mates 2b9d817af5 Zobrazování data u poslední aktualizace menu 2023-11-10 20:22:47 +01:00
mates 3021e6159d Oprava parsování pro TechTower 2023-10-23 12:24:32 +02:00
mates c5103f902d Oprava sestavování ntfy URL 2023-10-17 08:44:14 +02:00
mates 60563eaf0d Oprava sestavování URL pro ntfy 2023-10-16 09:56:22 +02:00
mates dc959543f4 Úprava uživatelských jmen pro ntfy URL 2023-10-16 09:18:08 +02:00
mates 9736646b03 Kódování username pro ntfy v base64 2023-10-16 09:10:51 +02:00
mates 9c2808d4ec Oprava funkčnosti při selhání načtení dat 2023-10-16 08:54:17 +02:00
mates eb82c23386 Odstranění přebytečných dat, aktualizace TODO 2023-10-15 21:02:58 +02:00
mates f2983b4397 Oprava pádu o víkendech 2023-10-15 20:47:59 +02:00
Reallag 6d89858e3e feat: podpora notifikací z notify 2023-10-15 20:28:58 +02:00
mates 3460d69899 Oprava mizejícího Pizza day 2023-10-15 19:42:03 +02:00
mates 74c8ab9e39 Testy datumových funkcí 2023-10-15 19:05:27 +02:00
mates ca9a7c5c23 Parsování jídel na celý týden 2023-10-15 19:05:19 +02:00
mates 74893c38eb Refaktor, rozdělení api, zpřehlednění kódu 2023-10-03 22:52:09 +02:00
mates 829197e17a Oprava výchozích dat pro hlasování 2023-10-02 19:13:47 +02:00
mates c15f33323d Oprava počátečního hlasování 2023-10-01 19:27:34 +02:00
mates 8e285e9197 Možnost hlasování o nových funkcích 2023-09-27 18:35:18 +02:00
mates 401833f763 Oprava zbytečného posílání loginu na server 2023-09-27 18:34:14 +02:00
mates bef6178a6f Generalizace úložiště pro libovolná data 2023-09-27 15:09:36 +02:00
mates 87beb5b66e Oprava zvýraznění aktuálního dne 2023-09-26 18:44:09 +02:00
mates 8d80678a9a Vylepšená detekce uzavření podniků 2023-09-24 21:16:47 +02:00
mates eb27591727 Možnost příplatků u Pizza day objednávek 2023-09-24 20:15:04 +02:00
mates c3d35ccc9c Odstranění neexistujícího types workspace 2023-09-24 09:28:50 +02:00
mates 47cd9f90c8 Oddělení TODO do samostatného souboru 2023-09-24 09:25:24 +02:00
mates f5ecedb3b9 Loader při zakládání Pizza day, mock data 2023-09-24 09:21:43 +02:00
mates 3f16485368 Aktualizace TODO v README.md 2023-09-24 08:53:29 +02:00
mates dc9d1d0e9a Vylepšení začištění názvů jídel 2023-09-24 08:45:47 +02:00
mates e4451e299a Serverová validace času odchodu 2023-09-24 08:38:40 +02:00
mates c286bd1778 Začištění závislostí klienta 2023-09-24 08:15:49 +02:00
mates 00cf8655e5 Opravy eslint 2023-09-24 08:10:48 +02:00
mates 862614ae9d Refaktor, oddělení Pizza Day do vlastní servisky 2023-09-24 08:08:41 +02:00
mates 1b132a7ca7 Oprava generování QR kódů pro Pizza day 2023-09-22 20:30:27 +02:00
mates 3a357f077f Neorientovat se dle datumu klienta 2023-09-22 19:41:33 +02:00
mates 8ec87ec200 Navigace mezi dny klávesovými šipkami 2023-09-18 22:56:38 +02:00
mates bc181defa8 Zpracování chyb z API 2023-09-18 22:38:04 +02:00
batmanisko 8a67325c85 tmp fix pondelku (mozna) 2023-09-11 09:32:05 +02:00
mates bf2683182e Aktualizace posledních změn 2023-09-06 22:57:43 +02:00
mates 8615286c45 Opravy TypeScriptu 2023-09-06 22:43:51 +02:00
mates 1a2b3c425e Oprava TypeScript typu 2023-09-06 19:30:21 +02:00
mates 832d3089ec Možnost náhledu a výběru na další dny v týdnu 2023-09-06 19:22:19 +02:00
mates 5379c21203 Redukce zbytečných pokusů o příhlášení 2023-09-06 19:19:13 +02:00
mates 700a6980ca Aktualizace posledních změn 2023-08-20 21:12:33 +02:00
mates 3d2dfb10f1 Vylepšené loadery 2023-08-19 11:56:24 +02:00
mates 47d23c1581 Aktualizace TODO, oprava překlepu 2023-08-11 10:06:03 +02:00
mates 282184b80b Neumožnit výběr zavřených podniků 2023-08-11 00:17:11 +02:00
mates 45bd84f96f Oprava stahování pizz pro Pizza day 2023-08-10 21:47:56 +02:00
mates e78f9cfd3a Začištění kódu 2023-08-08 21:45:21 +02:00
mates d41e0e9113 Podpora odhlášení přes Authelia 2023-08-08 21:32:11 +02:00
mates 6b824ce33a Oprava časové zóny a výběru storage dle .env 2023-08-08 20:44:15 +02:00
mates fd2e460a82 Dekódování jména uživatele z trusted headers 2023-08-08 20:05:41 +02:00
mates f008d364c5 Opravy brainfart v README.md 2023-08-07 16:24:44 +02:00
mates ce41c14446 Oprava stahování HTML restaurací 2023-08-07 09:40:49 +02:00
mates 0b8f00fa49 Oprava chybného zpracování env proměnné MOCK_DATA 2023-08-07 09:38:36 +02:00
mates c4b14bdf6b Ukládání dat výhradně do DB 2023-08-06 21:45:27 +02:00
mates 18cb172e06 Ukládání pizz do DB místo dočasného souboru 2023-08-06 18:52:18 +02:00
mates 3f303ea5ea Možnost zadání preferovaného času odchodu 2023-08-06 18:13:54 +02:00
mates 37542499a9 Zavedení podpory pro Redis, agnostické úložiště dat 2023-08-06 17:46:51 +02:00
mates 8a75c98c9a Základ zobrazování ověřených uživatelů 2023-07-30 23:36:18 +02:00
mates 028186c8ea Možnost zvolit pouze jednu variantu obědu 2023-07-30 21:24:15 +02:00
mates c0efb01803 Zpřehlednění tabulky výběru 2023-07-29 10:43:00 +02:00
mates 5727c8eca1 Aktualizace changelogu 2023-07-28 00:06:07 +02:00
mates 75fd75510b Přidání footeru s odkazem na zdrojové kódy 2023-07-26 22:26:50 +02:00
mates 8cfe415bb3 Přidání nových návrhů do TODO, seřazení dle priorit 2023-07-26 21:39:39 +02:00
mates 8ac0e72371 NOMERGE: Sloučits předchozími commity 2023-07-26 00:21:22 +02:00
mates bbf1cf1850 Možnost výběru konkrétních jídel 2023-07-25 23:53:05 +02:00
mates 24c301b141 Oprava ukládání dat při změně 2023-07-25 23:51:17 +02:00
mates b1138bc104 NOMERGE: Přenos změn 2023-07-23 22:12:47 +02:00
mates bc6035862a Oprava jazyka v Dockerfile 2023-07-23 00:14:47 +02:00
mates 839f51d8a3 Oprava URL pro websockety 2023-07-23 00:04:53 +02:00
mates 1c1a8b7111 Oprava parseru, nedělitelné mezery v cenách 2023-07-22 19:58:28 +02:00
mates 4d0096c064 Zastavení serveru pomocí SIGINT 2023-07-22 19:39:13 +02:00
mates 347cbc7228 Úprava socket.io pro relativní URL serveru 2023-07-22 19:38:44 +02:00
mates 3c0e8b2297 Deduplikace typů a sloučení kontejnerů
- Zavedení yarn workspaces
- Sloučení klienta a serveru do jednoho Docker kontejneru
- Společný dockerfile, builder
- Zbavení se nginx (není již potřeba)
2023-07-22 19:37:03 +02:00
mates 0d0c5cb946 Aktualizace .gitignore 2023-07-22 19:30:31 +02:00
mates c5e3c76cc1 Podpora nedělitelných mezer u názvů v TechTower 2023-07-04 10:24:43 +02:00
mates 13f3c1178f Opravy parseru pro Sladovnickou 2023-07-04 10:19:42 +02:00
mates 814aa98721 Narovnání závislostí 2023-07-04 10:16:11 +02:00
193 changed files with 19316 additions and 11588 deletions
+5 -22
View File
@@ -1,23 +1,6 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
types/gen
**.DS_Store
.mcp.json
.claude/settings.local.json
server/public/
+123
View File
@@ -0,0 +1,123 @@
variables:
- &node_image "node:22-alpine"
- &playwright_image "mcr.microsoft.com/playwright:v1.59.1-jammy"
- &branch "master"
# Spustit na všech větvích a pull requestech.
# Docker build probíhá jen na master větvi (viz when: v posledních krocích).
when:
- event: [push, pull_request]
services:
redis:
image: redis/redis-stack-server:7.2.0-RC3
environment:
REDIS_ARGS: "--save '' --loglevel warning"
steps:
- name: Generate TypeScript types
image: *node_image
commands:
- cd types
- yarn install --frozen-lockfile
- yarn openapi-ts
- name: Install server dependencies
image: *node_image
commands:
- cd server
- yarn install --frozen-lockfile
depends_on: [Generate TypeScript types]
- name: Install client dependencies
image: *node_image
commands:
- cd client
- yarn install --frozen-lockfile
depends_on: [Generate TypeScript types]
- name: Install e2e dependencies
image: *playwright_image
commands:
- cd e2e
- yarn install --frozen-lockfile
depends_on: [Generate TypeScript types]
- name: Server unit tests
image: *node_image
environment:
NODE_ENV: test
JWT_SECRET: test-secret-min-32-chars-aaaaaaa!
MOCK_DATA: "true"
STORAGE: json
commands:
- cd server
- yarn test
depends_on: [Install server dependencies]
- name: Build server
image: *node_image
commands:
- cd server
- yarn build
depends_on: [Install server dependencies]
- name: Build client
image: *node_image
commands:
- cd client
- yarn build
depends_on: [Install client dependencies]
- name: Playwright E2E tests
image: *playwright_image
environment:
CI: "true"
NODE_ENV: test
JWT_SECRET: test-secret-min-32-chars-aaaaaaa!
MOCK_DATA: "true"
STORAGE: redis
REDIS_HOST: redis
REDIS_PORT: "6379"
HTTP_REMOTE_USER_ENABLED: "true"
HTTP_REMOTE_USER_HEADER_NAME: remote-user
HTTP_REMOTE_TRUSTED_IPS: "0.0.0.0/0,::/0"
commands:
# Zkopírujeme build klienta do server/public, aby Express mohl SPA servírovat
- cp -r client/dist server/public
- cd e2e
- yarn playwright install firefox --with-deps
- yarn test
depends_on: [Build server, Build client, Install e2e dependencies]
- name: Build Docker image
depends_on: [Build server, Build client]
image: woodpeckerci/plugin-docker-buildx
when:
- event: push
branch: *branch
settings:
dockerfile: Dockerfile-Woodpecker
platforms: linux/amd64
registry:
from_secret: REPO_URL
username:
from_secret: REPO_USERNAME
password:
from_secret: REPO_PASSWORD
repo:
from_secret: REPO_NAME
- name: Discord notification - build
image: appleboy/drone-discord
depends_on: [Build Docker image]
when:
- status: [success, failure]
event: push
branch: *branch
settings:
webhook_id:
from_secret: DISCORD_WEBHOOK_ID
webhook_token:
from_secret: DISCORD_WEBHOOK_TOKEN
message: "{{#success build.status}}✅ Sestavení {{build.number}} proběhlo úspěšně.{{else}}❌ Sestavení {{build.number}} selhalo.{{/success}}\n\nPipeline: {{build.link}}\nPoslední commit: {{commit.message}}Autor: {{commit.author}}"
+102
View File
@@ -0,0 +1,102 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Luncher is a lunch management app for teams — daily restaurant menus, food ordering, pizza day events, and payment QR codes. Czech-language UI. Full-stack TypeScript monorepo.
## Monorepo Structure
```
types/ → Shared OpenAPI-generated TypeScript types (source of truth: types/api.yml)
server/ → Express 5 backend (Node.js 22, ts-node)
client/ → React 19 frontend (Vite 7, React Bootstrap)
```
Each directory has its own `package.json` and `tsconfig.json`. Package manager: **Yarn Classic**.
## Development Commands
### Initial setup
```bash
cd types && yarn install && yarn openapi-ts # Generate API types first
cd ../server && yarn install
cd ../client && yarn install
```
### Running dev environment
```bash
# All-in-one (tmux):
./run_dev.sh
# Or manually in separate terminals:
cd server && NODE_ENV=development yarn startReload # Port 3001, nodemon watch
cd client && yarn start # Port 3000, proxies /api → 3001
```
### Building
```bash
cd types && yarn openapi-ts # Regenerate types from api.yml
cd server && yarn build # tsc → server/dist
cd client && yarn build # tsc --noEmit + vite build → client/dist
```
### Tests
```bash
cd server && yarn test # Jest (tests in server/src/tests/)
cd server && yarn test dates # Run one test file
cd server && yarn test -t "name" # Run by test name pattern
```
### Formatting
Prettier is installed in `client/` (devDependency only, no script or config) — invoke via `yarn prettier <path>` with defaults.
## Architecture
### API Types (types/)
- OpenAPI 3.0 spec in `types/api.yml` — all API endpoints and DTOs defined here
- `api.yml` is a thin aggregator — actual endpoint specs live in `types/paths/<domain>/*.yml`, shared schemas in `types/schemas/_index.yml`
- `yarn openapi-ts` generates `types/gen/` (client.gen.ts, sdk.gen.ts, types.gen.ts)
- Both server and client import from these generated types
- **When changing API contracts: update api.yml first, then regenerate**
### Server (server/src/)
- **Entry:** `index.ts` — Express app + Socket.io setup
- **Routes:** `routes/` — modular Express route handlers (food, pizzaDay, voting, notifications, qr, stats, easterEgg, dev)
- **Services:** domain logic files at root level (`service.ts`, `pizza.ts`, `restaurants.ts`, `chefie.ts`, `voting.ts`, `stats.ts`, `qr.ts`, `notifikace.ts`)
- **Auth:** `auth.ts` — JWT + optional trusted-header authentication
- **Storage:** `storage/StorageInterface.ts` defines the interface; implementations in `storage/json.ts` (file-based, dev) and `storage/redis.ts` (production). Data keyed by date (YYYY-MM-DD).
- **Restaurant scrapers:** Cheerio-based HTML parsing for daily menus from multiple restaurants
- **Pizza Chefie integration:** `chefie.ts` scrapes pizzachefie.cz for Pizza day menus and salads (only active when a Pizza day is open)
- **WebSocket:** `websocket.ts` — Socket.io for real-time client updates
- **Mock mode:** `MOCK_DATA=true` env var returns fake menus (useful for weekend/holiday dev)
- **Config:** `.env.development` / `.env.production` (see `.env.template` for all options)
### Client (client/src/)
- **Entry:** `index.tsx``App.tsx``AppRoutes.tsx`
- **Pages:** `pages/` (StatsPage)
- **Components:** `components/` (Header, Footer, Loader, modals/, PizzaOrderList, PizzaOrderRow)
- **Context providers:** `context/` — AuthContext, SettingsContext, SocketContext, EasterEggContext
- **Styling:** Bootstrap 5 + React Bootstrap + custom SCSS files (co-located with components)
- **API calls:** use OpenAPI-generated SDK from `types/gen/`
- **Routing:** React Router DOM v7
### Data Flow
1. Client calls API via generated SDK → Express routes
2. Server scrapes restaurant websites or returns cached data
3. Storage: Redis (production) or JSON file (development)
4. Socket.io broadcasts changes to all connected clients
## Environment
- **Server env files:** `server/.env.development`, `server/.env.production` (see `server/.env.template`)
- Key vars: `JWT_SECRET`, `STORAGE` (json/redis), `MOCK_DATA`, `REDIS_HOST`, `REDIS_PORT`
- **Docker:** multi-stage Dockerfile, `compose.yml` for app + Redis. Timezone: Europe/Prague.
## Conventions
- Czech naming for domain variables and UI strings; English for infrastructure code
- TypeScript strict mode in both client and server
- Server module resolution: Node16; Client: ESNext/bundler
- `TODO.md` tracks open bugs and roadmap items — worth scanning before starting non-trivial work
+96
View File
@@ -0,0 +1,96 @@
ARG NODE_VERSION="node:22-alpine"
# Builder
FROM ${NODE_VERSION} AS builder
WORKDIR /build
# Zkopírování závislostí - OpenAPI generátor
COPY types/package.json ./types/
COPY types/yarn.lock ./types/
COPY types/api.yml ./types/
COPY types/schemas ./types/schemas/
COPY types/paths ./types/paths/
COPY types/openapi-ts.config.ts ./types/
# Zkopírování závislostí - server
COPY server/package.json ./server/
COPY server/yarn.lock ./server/
# Zkopírování závislostí - klient
COPY client/package.json ./client/
COPY client/yarn.lock ./client/
# Instalace závislostí - OpenAPI generátor
WORKDIR /build/types
RUN yarn install --frozen-lockfile
# Instalace závislostí - server
WORKDIR /build/server
RUN yarn install --frozen-lockfile
# Instalace závislostí - klient
WORKDIR /build/client
RUN yarn install --frozen-lockfile
WORKDIR /build
# Zkopírování build závislostí - server
COPY server/tsconfig.json ./server/
COPY server/src ./server/src/
# Zkopírování build závislostí - klient
COPY client/tsconfig.json ./client/
COPY client/vite.config.ts ./client/
COPY client/vite-env.d.ts ./client/
COPY client/index.html ./client/
COPY client/src ./client/src
COPY client/public ./client/public
# Zkopírování společných typů
COPY types/index.ts ./types/
# Vygenerování společných typů z OpenAPI
WORKDIR /build/types
RUN yarn openapi-ts
# Sestavení serveru
WORKDIR /build/server
RUN yarn build
# Sestavení klienta
WORKDIR /build/client
RUN yarn build
# Runner
FROM ${NODE_VERSION}
RUN apk add --no-cache tzdata
ENV TZ=Europe/Prague \
LC_ALL=cs_CZ.UTF-8 \
NODE_ENV=production
WORKDIR /app
# Vykopírování sestaveného serveru
COPY --from=builder /build/server/node_modules ./server/node_modules
COPY --from=builder /build/server/dist ./
# Vykopírování sestaveného klienta
COPY --from=builder /build/client/dist ./public
# Zkopírování produkčních .env serveru
COPY /server/.env.production ./server
# Zkopírování changelogů (seznamu novinek)
COPY /server/changelogs ./server/changelogs
# Zkopírování konfigurace easter eggů a changelogů
RUN if [ -f ./server/.easter-eggs.json ]; then cp ./server/.easter-eggs.json ./server/; fi
# Export /data/db.json do složky /data
VOLUME ["/data"]
EXPOSE 3000
CMD [ "node", "./server/src/index.js" ]
+30
View File
@@ -0,0 +1,30 @@
ARG NODE_VERSION="node:22-alpine"
FROM ${NODE_VERSION}
RUN apk add --no-cache tzdata
ENV TZ=Europe/Prague \
LC_ALL=cs_CZ.UTF-8 \
NODE_ENV=production
WORKDIR /app
# Vykopírování sestaveného serveru
COPY ./server/node_modules ./server/node_modules
COPY ./server/dist ./
# TODO tohle není dobře, má to být součástí serveru
# COPY ./server/resources ./resources
# Vykopírování sestaveného klienta
COPY ./client/dist ./public
# Zkopírování changelogů (seznamu novinek)
COPY ./server/changelogs ./server/changelogs
# Zkopírování konfigurace easter eggů a changelogů
RUN if [ -f ./server/.easter-eggs.json ]; then cp ./server/.easter-eggs.json ./server/; fi \
&& if [ -d ./server/changelogs ]; then cp -r ./server/changelogs ./server/changelogs; fi
EXPOSE 3000
CMD [ "node", "./server/src/index.js" ]
+12 -51
View File
@@ -1,79 +1,40 @@
# Luncher
Aplikace pro profesionální management obědů.
Aplikace sestává ze dvou (tří) modulů.
Aplikace sestává ze tří modulů.
- types
- OpenAPI definice společných typů, generované přes [openapi-ts](https://github.com/hey-api/openapi-ts)
- server
- backend psaný v [node.js](https://nodejs.dev)
- client
- frontend psaný v [React.js](https://react.dev)
- [nginx](https://nginx.org)
- proxy pro snadné propojení Docker kontejnerů pod jednou URL
## Spuštění pro vývoj
### Závislosti
#### Klient/server
- [Node.js 18.x](https://nodejs.dev)
- [Node.js 22.x (>= 22.11)](https://nodejs.dev)
- [Yarn 1.22.x (Classic)](https://classic.yarnpkg.com)
### Spuštění na *nix platformách
- Nainstalovat závislosti viz předchozí bod
- Zkopírovat `client/.env.template` do `client/.env.development` a upravit dle potřeby
- Zkopírovat `server/.env.template` do `server/.env.development` a upravit dle potřeby
- Spustit `./run_dev.sh`. Na jiných platformách se lze inspirovat jeho obsahem, postup by měl být víceméně stejný.
- Vygenerovat společné TypeScript typy
- `cd types && yarn install && yarn openapi-ts`
- Server
- `cd server && yarn install && export NODE_ENV=development && yarn startReload`
- Klient
- `cd client && yarn install && yarn start`
## Sestavení a spuštění produkční verze v Docker
### Závislosti
- [Docker](https://www.docker.com)
- [Docker Compose](https://docs.docker.com/compose)
### Spuštění s nginx
### Spuštění
- `docker compose up --build -d`
### Spuštení s traefik
- `docker compose -f compose-traefik.yml up --build -d`
## TODO
- [x] Umožnit smazání aktuální volby "popelnicí", místo nutnosti vybrat prázdnou položku v selectu
- [x] Přívětivější možnost odhlašování
- [x] Vyřešit responzivní design pro použití na mobilu
- [x] Vyndat URL na Food API do .env
- [x] Neselhat při nedostupnosti nebo chybě z Food API
- [x] Dokončit docker-compose pro kompletní funkčnost
- [x] Implementovat Pizza day
- [x] Umožnit uzamčení objednávek zakladatelem
- [x] Možnost uložení čísla účtu
- [x] Automatické generování a zobrazení QR kódů
- [x] https://qr-platba.cz/pro-vyvojare/restful-api/
- [x] Zobrazovat celkovou cenu objednávky pod tabulkou objednávek
- [ ] Zobrazit upozornění před smazáním/zamknutím/odemknutím pizza day
- [x] Umožnit přidat k objednávce poznámku (např. "bez oliv")
- [x] Negenerovat QR kód pro objednávajícího
- [ ] Pizzy se samy budou při naklikání přidávat do košíku
- [ ] Nutno nejprve vyřešit předávání PHPSESSIONID cookie na pizzachefie.cz pomocí fetch()
- [ ] Ceny krabic za pizzu jsou napevno v kódu - problém, pokud se někdy změní
- [ ] Předvyplnění poslední vybrané hodnoty občas nefunguje, viz komentář
- [ ] Nasazení nové verze v Docker smaže veškerá data (protože data.json není venku)
- [ ] Vylepšit dokumentaci projektu
- [ ] Popsat nginx
- [x] Popsat závislosti, co je nutné provést před vývojem a postup spuštění pro vývoj
- [x] Popsat dostupné env
- [x] Přesunout autentizaci na server (JWT?)
- [x] Zavést .env.template a přidat .env do .gitignore
- [x] Zkrášlit dialog pro vyplnění čísla účtu, vypadá mizerně
- [ ] Podpora pro notifikace v externích systémech (Gotify, Discord, MS Teams)
- [ ] Umožnit zadat URL/tokeny uživatelem
- [ ] Umožnit uživatelsky konfigurovat typy notifikací, které se budou odesílat
- [ ] Skripty pro snadné spuštění vývoje na Windows (ekvivalent ./run_dev.sh)
- [ ] Možnost náhledu na jiné dny v týdnu (např. pomocí šipek)
- [ ] Možnost zadat si oběd dopředu na následující dny v týdnu
- [x] Zbavit se Food API, potřebnou funkcionalitu zahrnout do serveru
- [ ] Vyřešit API mezi serverem a klientem, aby nebyl v obou projektech duplicitní kód (viz types.ts a Types.tsx)
- [ ] Mazat z databáze předchozí dny, aktuálně je to k ničemu
- [ ] Vylepšit parsery restaurací
- [ ] Sladovnická
- [ ] Zbytečná prvotní validace indexu, datum konkrétního dne je i v samotné tabulce s jídly, viz TODO v parseru
- [ ] U Motlíků
- [ ] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (např. '12.6.-16.6.')
- [ ] Jídelní lístek se stahuje jednou každý den, stačilo by to jednou týdně ("ID" by mohlo být číslo týdne v roce, místo celého datumu)
- [ ] TechTower
- [ ] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (typicky 'Obědy 12. 6. - 16. 6. 2023 (každý den vždy i obědový bufet)')
Dostupné [zde](TODO.md).
+73
View File
@@ -0,0 +1,73 @@
# TODO
- [ ] HTTP_REMOTE_TRUSTED_IPS se nikde nevalidují, hlavičky jsou přijímány odkudkoli
- [ ] V případě zapnutí přihlašování přes trusted headers nefunguje standardní přihlášení (nevrátí žádnou odpověď)
- [ ] Nemělo by se jít dostat na přihlašovací formulář (měla by tam být nanejvýš hláška nebo přesměrování)
- [ ] Možnost úhrady celé útraty jednou osobou
- Základní myšlenka: jedna osoba uhradí celou útratu (v zájmu rychlosti odbavení), ostatním se automaticky vygeneruje QR kód, kterým následně uhradí svoji část útraty
- Obecně to bude problém např. pokud si někdo objedná něco navíc (pití apod.)
- [ ] Tlačítko "Uhradit" u každého řádku podniku - platí ten, kdo kliknul
- [ ] Zobrazeno bude pouze, pokud má daný uživatel nastaveno číslo účtu
- [ ] Dialog pro zadání spropitného, které se následně rozpočte rovnoměrně všem strávníkům
- [ ] Generování a zobrazení QR kódů ostatním strávníkům
- [ ] Umožnit u každého strávníka připočíst vlastní částku (např. za pití)
- [ ] Umožnit (např. zaškrtávátky) vybrat, za koho bude zaplaceno (pokud někdo bude platit zvlášť)
- [ ] Podpora pro notifikace v externích systémech (Gotify, Discord, MS Teams)
- [ ] Umožnit zadat URL/tokeny uživatelem
- [ ] Umožnit uživatelsky konfigurovat typy notifikací, které se budou odesílat
- [ ] Zavést notifikace typu "Jdeme na oběd"
- [ ] Notifikaci dostanou pouze uživatelé, kteří mají vybranou stejnou lokalitu
- [ ] Vylepšit parsery restaurací
- [ ] Sladovnická
- [ ] Zbytečná prvotní validace indexu, datum konkrétního dne je i v samotné tabulce s jídly, viz TODO v parseru
- [ ] U Motlíků
- [ ] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (např. '12.6.-16.6.')
- [ ] Jídelní lístek se stahuje jednou každý den, teoreticky by stačilo jednou týdně (za předpokladu, že se během týdne nemění)
- [ ] TechTower
- [ ] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (typicky 'Obědy 12. 6. - 16. 6. 2023 (každý den vždy i obědový bufet)')
- [ ] Jídelní lístek se stahuje v rámci prvního požadavku daný den, ale často se jídelní lístek na stránkách aktualizuje až v průběhu pondělního dopoledne a ten zobrazený je proto neaktuální
- Stránka neposílá hlavičku o času poslední modifikace, takže o to se nelze opřít
- Nevím aktuálně jak řešit jinak, než častějším scrapováním celé stránky
- [X] Někdy jsou v názvech jídel přebytečné mezery kolem čárek ( , )
- [ ] Nasazení nové verze v Docker smaže veškerá data (protože data.json není vystrčený ven z kontejneru)
- [ ] Zavést složku /data
- [ ] Mazat z databáze data z minulosti, aktuálně je to k ničemu
- [ ] Skripty pro snadné spuštění vývoje na Windows (ekvivalent ./run_dev.sh)
- [ ] Implementovat Pizza day
- [ ] Zobrazit upozornění před smazáním/zamknutím/odemknutím pizza day
- [ ] Pizzy se samy budou při naklikání přidávat do košíku
- [ ] Nutno nejprve vyřešit předávání PHPSESSIONID cookie na pizzachefie.cz pomocí fetch()
- [ ] Ceny krabic za pizzu jsou napevno v kódu - problém, pokud se někdy změní
- [X] Umožnit u Pizza day ručně připočíst cenu za přísady
- [X] Prvotní načtení pizz při založení Pizza Day trvá a nic se během toho nezobrazuje (např. loader)
- [X] Po doručení zobrazit komu zaplatit (kdo objednával)
- [x] Zbytečně nescrapovat každý den pizzy z Pizza Chefie, dokud není založen Pizza Day
- [x] Umožnit uzamčení objednávek zakladatelem
- [x] Možnost uložení čísla účtu
- [x] Automatické generování a zobrazení QR kódů
- [x] https://qr-platba.cz/pro-vyvojare/restful-api/
- [x] Zobrazovat celkovou cenu objednávky pod tabulkou objednávek
- [x] Umožnit přidat k objednávce poznámku (např. "bez oliv")
- [x] Negenerovat QR kód pro objednávajícího
- [X] Možnost náhledu na ostatní dny v týdnu (např. pomocí šipek)
- [X] Možnost výběru oběda na následující dny v týdnu
- [X] Umožnit vybrat libovolný čas odchodu
- [X] Validace zadání smysluplného času (ideálně i klientská)
- [x] Umožnit smazání aktuální volby "popelnicí", místo nutnosti vybrat prázdnou položku v selectu
- [x] Přívětivější možnost odhlašování
- [x] Vyřešit responzivní design pro použití na mobilu
- [x] Vyndat URL na Food API do .env
- [x] Neselhat při nedostupnosti nebo chybě z Food API
- [x] Dokončit docker-compose pro kompletní funkčnost
- [x] Vylepšit dokumentaci projektu
- [x] Popsat závislosti, co je nutné provést před vývojem a postup spuštění pro vývoj
- [x] Popsat dostupné env
- [x] Přesunout autentizaci na server (JWT?)
- [x] Zavést .env.template a přidat .env do .gitignore
- [x] Zkrášlit dialog pro vyplnění čísla účtu, vypadá mizerně
- [x] Zbavit se Food API, potřebnou funkcionalitu zahrnout do serveru
- [x] Vyřešit API mezi serverem a klientem, aby nebyl v obou projektech duplicitní kód (viz types.ts a Types.tsx)
- [X] Vybraná jídla strávníků zobrazovat v samostatném sloupci
- [X] Umožnit výběr/zadání preferovaného času odchodu na oběd
- Hodí se např. pokud má někdo schůzky
- [X] Ukládat dostupné pizzy do DB místo souborů
- [X] Ukládat jídla do DB místo souborů
-3
View File
@@ -1,3 +0,0 @@
**/node_modules
**/npm-debug.log
build
-3
View File
@@ -1,3 +0,0 @@
# Veřejná URL, na které bude dostupný klient (typicky přes proxy).
# Pro vývoj není potřeba, bude použita výchozí hodnota http://127.0.0.1:3001
# PUBLIC_URL=http://example:3001
+1 -1
View File
@@ -1,2 +1,2 @@
build
.env.production
dist
-23
View File
@@ -1,23 +0,0 @@
FROM node:18-alpine3.18 AS builder
COPY package.json .
COPY yarn.lock .
COPY tsconfig.json .
COPY .env.production .
RUN yarn install
COPY ./src ./src
COPY ./public ./public
RUN yarn build
FROM node:18-alpine3.18
ENV NODE_ENV production
WORKDIR /app
COPY --from=builder /build .
EXPOSE 3000
RUN yarn global add serve && yarn
CMD ["serve", "-s", "."]
-2
View File
@@ -1,2 +0,0 @@
#!/bin/bash
docker build -t luncher-client .
+41
View File
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Moderní webová aplikace pro lepší správu obědových preferencí" />
<link rel="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" />
<title>Luncher</title>
<script>
(function() {
try {
var saved = localStorage.getItem('theme_preference');
var theme;
if (saved === 'dark') {
theme = 'dark';
} else if (saved === 'light') {
theme = 'light';
} else {
// 'system' nebo neuloženo - použij systémové nastavení
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-bs-theme', theme);
} catch (e) {
// Fallback pokud localStorage není dostupný
document.documentElement.setAttribute('data-bs-theme', 'light');
}
})();
</script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
+33 -29
View File
@@ -1,43 +1,46 @@
{
"name": "luncher-client",
"name": "@luncher/client",
"version": "0.1.0",
"license": "MIT",
"private": true,
"type": "module",
"homepage": ".",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/free-regular-svg-icons": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fortawesome/react-fontawesome": "^0.2.0",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.23",
"@types/react": "^18.0.33",
"@types/react-dom": "^18.0.11",
"bootstrap": "^5.2.3",
"react": "^18.2.0",
"react-bootstrap": "^2.7.2",
"react-dom": "^18.2.0",
"react-jwt": "^1.2.0",
"react-modal": "^3.16.1",
"react-scripts": "5.0.1",
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.1.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"bootstrap": "^5.3.8",
"react": "^19.2.0",
"react-bootstrap": "^2.10.10",
"react-dom": "^19.2.0",
"react-jwt": "^1.3.0",
"react-modal": "^3.16.3",
"react-router": "^7.9.5",
"react-router-dom": "^7.9.5",
"react-select-search": "^4.1.6",
"react-toastify": "^9.1.3",
"react-snow-overlay": "^1.0.14",
"react-snowfall": "^2.3.0",
"react-toastify": "^11.0.5",
"recharts": "^3.4.1",
"sass": "^1.93.3",
"socket.io-client": "^4.6.1",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"
"typescript": "^5.9.3",
"vite": "^7.2.2",
"vite-tsconfig-paths": "^5.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
"start": "yarn vite",
"build": "tsc --noEmit && yarn vite build"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
"react-app"
]
},
"browserslist": {
@@ -53,6 +56,7 @@
]
},
"devDependencies": {
"prettier": "^2.8.8"
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"prettier": "^3.6.2"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

-43
View File
@@ -1,43 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Moderní webová aplikace pro lepší správu obědových preferencí" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Luncher</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
+22
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

+22
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

+22
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

+22
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

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

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+46
View File
@@ -0,0 +1,46 @@
// Service Worker pro Web Push notifikace (připomínka výběru oběda)
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? { title: 'Luncher', body: 'Ještě nemáte zvolený oběd!' };
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/favicon.ico',
tag: 'lunch-reminder',
actions: [
{ action: 'neobedvam', title: 'Mám vlastní/neobědvám' },
],
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'neobedvam') {
event.waitUntil(
self.registration.pushManager.getSubscription().then((subscription) => {
if (!subscription) return;
return fetch('/api/notifications/push/quickChoice', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: subscription.endpoint }),
});
})
);
return;
}
event.waitUntil(
self.clients.matchAll({ type: 'window' }).then((clientList) => {
// Pokud je již otevřené okno, zaostříme na něj
for (const client of clientList) {
if (client.url.includes(self.location.origin) && 'focus' in client) {
return client.focus();
}
}
// Jinak otevřeme nové
return self.clients.openWindow('/');
})
);
});
-83
View File
@@ -1,83 +0,0 @@
import { PizzaOrder } from "./Types";
import { getBaseUrl, getToken } from "./Utils";
async function request<TResponse>(
url: string,
config: RequestInit = {}
): Promise<TResponse> {
if (!config.headers) {
config.headers = {};
}
config.headers["Authorization"] = `Bearer ${getToken()}`;
return fetch(getBaseUrl() + url, config).then(response => {
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json() as TResponse;
});
}
const api = {
get: <TResponse>(url: string) => request<TResponse>(url),
post: <TBody extends BodyInit, TResponse>(url: string, body: TBody) => request<TResponse>(url, { method: 'POST', body, headers: { 'Content-Type': 'application/json' } }),
}
export const getQrUrl = (login: string) => {
return `${getBaseUrl()}/api/qr?login=${login}`;
}
export const getData = async () => {
return await api.get<any>('/api/data');
}
export const getFood = async () => {
return await api.get<any>('/api/food');
}
export const getPizzy = async () => {
return await api.get<any>('/api/pizza');
}
export const createPizzaDay = async () => {
return await api.post<any, any>('/api/createPizzaDay', undefined);
}
export const deletePizzaDay = async () => {
return await api.post<any, any>('/api/deletePizzaDay', undefined);
}
export const lockPizzaDay = async () => {
return await api.post<any, any>('/api/lockPizzaDay', undefined);
}
export const unlockPizzaDay = async () => {
return await api.post<any, any>('/api/unlockPizzaDay', undefined);
}
export const finishOrder = async () => {
return await api.post<any, any>('/api/finishOrder', undefined);
}
export const finishDelivery = async (bankAccount, bankAccountHolder) => {
return await api.post<any, any>('/api/finishDelivery', JSON.stringify({ bankAccount, bankAccountHolder }));
}
export const updateChoice = async (choice: number | null) => {
return await api.post<any, any>('/api/updateChoice', JSON.stringify({ choice }));
}
export const addPizza = async (pizzaIndex: number, pizzaSizeIndex: number) => {
return await api.post<any, any>('/api/addPizza', JSON.stringify({ pizzaIndex, pizzaSizeIndex }));
}
export const removePizza = async (pizzaOrder: PizzaOrder) => {
return await api.post<any, any>('/api/removePizza', JSON.stringify({ pizzaOrder }));
}
export const updateNote = async (note?: string) => {
return await api.post<any, any>('/api/updateNote', JSON.stringify({ note }));
}
export const login = async (login: string) => {
return await api.post<any, any>('/api/login', JSON.stringify({ login }));
}
-82
View File
@@ -1,82 +0,0 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.wrapper {
padding: 20px;
}
.title {
margin: 50px 0;
}
.food-tables {
margin-bottom: 50px;
}
.content-wrapper {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.navbar {
background-color: #3c3c3c;
padding-left: 20px;
padding-right: 20px;
}
#basic-navbar-nav {
justify-content: flex-end;
}
.trash-icon {
color: rgb(0, 89, 255);
cursor: pointer;
margin-left: 10px;
}
.qr-code {
text-align: center;
}
.select-search-container {
margin: auto;
}
+1127
View File
File diff suppressed because it is too large Load Diff
+783 -214
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
import { Routes, Route } from "react-router-dom";
import { ProvideSettings } from "./context/settings";
// import Snowfall from "react-snowfall";
import { SnowOverlay } from 'react-snow-overlay';
import { ToastContainer } from "react-toastify";
import { SocketContext, socket } from "./context/socket";
import StatsPage from "./pages/StatsPage";
import App from "./App";
export const STATS_URL = '/stats';
export default function AppRoutes() {
return (
<Routes>
<Route path={STATS_URL} element={<StatsPage />} />
<Route path="/" element={
<ProvideSettings>
<SocketContext.Provider value={socket}>
<>
{/* <Snowfall style={{
zIndex: 2,
position: 'fixed',
width: '100vw',
height: '100vh'
}} /> */}
<SnowOverlay color={'rgba(240, 240, 240, 0.9)'} disabledOnSingleCpuDevices={true} />
<App />
</>
<ToastContainer />
</SocketContext.Provider>
</ProvideSettings>
} />
</Routes>
);
}
+31
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;
}
}
+317
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;
+82 -6
View File
@@ -1,13 +1,89 @@
.login {
height: 100%;
.login-page {
min-height: 100vh;
display: flex;
flex-direction: column;
text-align: center;
align-items: center;
justify-content: center;
background: var(--luncher-bg);
padding: 24px;
}
.login-inner {
.login-card {
background: var(--luncher-bg-card);
border-radius: var(--luncher-radius-xl);
box-shadow: var(--luncher-shadow-lg);
padding: 48px;
max-width: 420px;
width: 100%;
text-align: center;
border: 1px solid var(--luncher-border-light);
}
.login-logo {
font-size: 2.5rem;
font-weight: 700;
color: var(--luncher-text);
margin-bottom: 8px;
letter-spacing: -0.5px;
}
.login-subtitle {
color: var(--luncher-text-secondary);
font-size: 1rem;
margin-bottom: 40px;
line-height: 1.5;
}
.login-form {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.login-form label {
display: block;
text-align: left;
font-weight: 500;
color: var(--luncher-text);
margin-bottom: 8px;
}
.login-form .hint {
font-size: 0.85rem;
color: var(--luncher-text-muted);
margin-top: 8px;
text-align: left;
line-height: 1.5;
}
.login-form input[type="text"] {
width: 100%;
padding: 14px 18px;
font-size: 1rem;
border: 2px solid var(--luncher-border);
border-radius: var(--luncher-radius-sm);
background: var(--luncher-bg);
color: var(--luncher-text);
transition: var(--luncher-transition);
}
.login-form input[type="text"]:hover {
border-color: var(--luncher-text-muted);
}
.login-form input[type="text"]:focus {
border-color: var(--luncher-primary);
box-shadow: 0 0 0 3px var(--luncher-primary-light);
outline: none;
}
.login-form input[type="text"]::placeholder {
color: var(--luncher-text-muted);
}
.login-form .btn {
width: 100%;
padding: 14px 24px;
font-size: 1rem;
font-weight: 600;
margin-top: 8px;
}
+46 -19
View File
@@ -1,7 +1,7 @@
import React, { useCallback, useRef } from 'react';
import React, { useCallback, useEffect, useRef } from 'react';
import { Button } from 'react-bootstrap';
import { useAuth } from './context/auth';
import { login } from './Api';
import { login } from '../../types';
import './Login.css';
/**
@@ -11,33 +11,60 @@ export default function Login() {
const auth = useAuth();
const loginRef = useRef<HTMLInputElement>(null);
const doLogin = useCallback(async () => {
const length = loginRef?.current?.value && loginRef?.current?.value.length && loginRef.current.value.replace(/\s/g, '').length
if (length) {
// TODO odchytávat cokoliv mimo 200
const token = await login(loginRef.current.value);
useEffect(() => {
if (auth && !auth.login) {
// Vyzkoušíme přihlášení "naprázdno", pokud projde, přihlásili nás trusted headers
login().then(response => {
const token = response.data;
if (token) {
auth?.setToken(token);
auth?.setToken(token as unknown as string); // TODO vyřešit, API definice je špatně, je to skutečně string
}
}).catch(error => {
// nezajímá nás
});
}
}, [auth]);
const doLogin = useCallback(async () => {
const length = loginRef?.current?.value.length && loginRef.current.value.replaceAll(/\s/g, '').length
if (length) {
const response = await login({ body: { login: loginRef.current?.value } });
if (response.data) {
auth?.setToken(response.data as unknown as string); // TODO vyřešit
}
}
}, [auth]);
if (!auth || !auth.login) {
return <div className='login'>
<h1>Luncher</h1>
<h4 style={{ marginBottom: "50px" }}>Aplikace pro profesionální management obědů</h4>
<div className='login-inner'>
<p style={{ fontSize: "12px", marginTop: "10px" }}>
Zobrazované jméno by mělo být vaše jméno nebo přezdívka, pod kterou vás kolegové dokáží snadno identifikovat. Jméno je možné kdykoli změnit.
</p>
Zobrazované jméno: <input style={{ marginTop: "10px" }} ref={loginRef} type='text' onKeyDown={event => {
if (!auth?.login) {
return (
<div className='login-page'>
<div className='login-card'>
<h1 className='login-logo'>Luncher</h1>
<p className='login-subtitle'>Aplikace pro profesionální management obědů</p>
<div className='login-form'>
<div>
<label htmlFor="login-input">Zobrazované jméno</label>
<input
id="login-input"
ref={loginRef}
type='text'
placeholder="Např. Jan Novák"
onKeyDown={event => {
if (event.key === 'Enter') {
doLogin()
}
}} />
<Button onClick={doLogin} style={{ marginTop: "20px" }}>Uložit</Button>
}}
/>
<p className='hint'>
Zadejte jméno nebo přezdívku, pod kterou vás kolegové snadno identifikují.
Jméno je možné kdykoli změnit.
</p>
</div>
<Button onClick={doLogin}>Pokračovat</Button>
</div>
</div>
</div>
);
}
return <div>Neplatný stav</div>
}
-84
View File
@@ -1,84 +0,0 @@
// TODO všechno v tomto souboru jsou duplicity se serverem, ale aktuálně nevím jaký je nejlepší způsob jejich sdílení
export interface PizzaSize {
varId: number, // unikátní ID varianty pizzy
size: string, // velikost pizzy, např. "30cm"
pizzaPrice: number, // cena samotné pizzy
boxPrice: number, // cena krabice
price: number, // celková cena (pizza + krabice)
}
/** Jedna konkrétní pizza */
export interface Pizza {
name: string, // název pizzy
ingredients: string[], // seznam ingrediencí
sizes: PizzaSize[], // dostupné velikosti pizzy
}
/** Objednávka jedné konkrétní pizzy */
export interface PizzaOrder {
varId: number, // unikátní ID varianty pizzy
name: string, // název pizzy
size: string, // velikost pizzy jako string (30cm)
price: number, // cena pizzy v Kč, včetně krabice
}
/** Celková objednávka jednoho člověka */
export interface Order {
customer: string, // jméno objednatele
pizzaList: PizzaOrder[], // seznam objednaných pizz
totalPrice: number, // celková cena všech objednaných pizz a krabic
hasQr?: boolean, // zda je pro objednávku vygenerován QR kód pro platbu
note?: string, // volitelná poznámka uživatele k objednávce
}
export interface Choices {
[location: string]: string[],
}
/** Údaje o Pizza day. */
export interface PizzaDay {
state: State,
creator: string,
orders: Order[]
}
export interface ClientData {
date: string, // dnešní datum pro zobrazení
isWeekend: boolean, // příznak zda je dnešní den víkend
choices: Choices, // seznam voleb
pizzaDay?: PizzaDay, // údaje o pizza day, pokud je pro dnešek založen
}
export enum Locations {
SLADOVNICKA = 'Sladovnická',
UMOTLIKU = 'U Motlíků',
TECHTOWER = 'TechTower',
SPSE = 'SPŠE',
PIZZA = 'Pizza day',
OBJEDNAVAM = 'Budu objednávat',
NEOBEDVAM = 'Mám vlastní/neobědvám',
}
/** Jídlo z obědového menu restaurace. */
export interface Food {
amount?: string, // množství standardní porce, např. 0,33l nebo 150g
name: string, // název/popis jídla
price: string, // cena ve formátu '135 Kč'
isSoup: boolean, // příznak, zda se jedná o polévku
}
/** Výčtový typ pro restaurace, pro které umíme získat a parsovat obědové menu. */
export enum Restaurants {
SLADOVNICKA = 'sladovnicka',
UMOTLIKU = 'uMotliku',
TECHTOWER = 'techTower',
}
export enum State {
NOT_CREATED, // Pizza day nebyl založen
CREATED, // Pizza day je založen
LOCKED, // Objednávky uzamčeny
ORDERED, // Objednáno
DELIVERED, // Doručeno
}
+87 -13
View File
@@ -1,14 +1,4 @@
/**
* Vrátí kořenovou URL serveru na základě aktuálního prostředí (vývojovou či produkční).
*
* @returns kořenová URL serveru
*/
export const getBaseUrl = (): string => {
if (process.env.PUBLIC_URL) {
return process.env.PUBLIC_URL;
}
return 'http://127.0.0.1:3001';
}
import { DepartureTime } from "../../types";
const TOKEN_KEY = "token";
@@ -26,8 +16,8 @@ export const storeToken = (token: string) => {
*
* @returns token nebo null
*/
export const getToken = (): string | null => {
return localStorage.getItem(TOKEN_KEY);
export const getToken = (): string | undefined => {
return localStorage.getItem(TOKEN_KEY) ?? undefined;
}
/**
@@ -36,3 +26,87 @@ export const getToken = (): string | null => {
export const deleteToken = () => {
localStorage.removeItem(TOKEN_KEY);
}
/**
* Vrátí human-readable reprezentaci předaného data a času pro zobrazení.
* Příklady:
* - dnes 10:52
* - 10.05.2023 10:52
*/
export function getHumanDateTime(datetime: Date) {
let hours = String(datetime.getHours()).padStart(2, '0');
let minutes = String(datetime.getMinutes()).padStart(2, "0");
if (new Date().toDateString() === datetime.toDateString()) {
return `dnes ${hours}:${minutes}`;
} else {
let day = String(datetime.getDate()).padStart(2, '0');
let month = String(datetime.getMonth() + 1).padStart(2, "0");
let year = datetime.getFullYear();
return `${day}.${month}.${year} ${hours}:${minutes}`;
}
}
/**
* Vrátí true, pokud je předaný čas větší než aktuální čas.
*/
export function isInTheFuture(time: DepartureTime) {
const now = new Date();
const currentHours = now.getHours();
const currentMinutes = now.getMinutes();
const currentDate = now.toDateString();
const [hours, minutes] = time.split(':').map(Number);
if (currentDate === now.toDateString()) {
return hours > currentHours || (hours === currentHours && minutes > currentMinutes);
}
return true;
}
/**
* Vrátí index dne v týdnu, kde pondělí=0, neděle=6
*
* @param date datum
* @returns index dne v týdnu
*/
export const getDayOfWeekIndex = (date: Date) => {
// https://stackoverflow.com/a/4467559
return (((date.getDay() - 1) % 7) + 7) % 7;
}
/** Vrátí první pracovní den v týdnu předaného data. */
export function getFirstWorkDayOfWeek(date: Date) {
const firstDay = new Date(date);
firstDay.setDate(date.getDate() - getDayOfWeekIndex(date));
return firstDay;
}
/** Vrátí poslední pracovní den v týdnu předaného data. */
export function getLastWorkDayOfWeek(date: Date) {
const lastDay = new Date(date);
lastDay.setDate(date.getDate() + (4 - getDayOfWeekIndex(date)));
return lastDay;
}
/** Vrátí datum v ISO formátu. */
export function formatDate(date: Date, format?: string) {
let day = String(date.getDate()).padStart(2, '0');
let month = String(date.getMonth() + 1).padStart(2, "0");
let year = String(date.getFullYear());
const f = format ?? 'YYYY-MM-DD';
return f.replace('DD', day).replace('MM', month).replace('YYYY', year);
}
/** Vrátí human-readable reprezentaci předaného data pro zobrazení. */
export function getHumanDate(date: Date) {
let currentDay = String(date.getDate()).padStart(2, '0');
let currentMonth = String(date.getMonth() + 1).padStart(2, "0");
let currentYear = date.getFullYear();
return `${currentDay}.${currentMonth}.${currentYear}`;
}
/** Převede datum ve formátu YYYY-MM-DD na DD.MM.YYYY */
export function formatDateString(dateString: string): string {
const [year, month, day] = dateString.split('-');
return `${day}.${month}.${year}`;
}
+12
View File
@@ -0,0 +1,12 @@
export default function Footer() {
return (
<footer className="footer">
<span>
Zdroj. kódy dostupné na{' '}
<a href="https://gitea.melancholik.eu/mates/Luncher" target="_blank" rel="noopener noreferrer">
Gitea
</a>
</span>
</footer>
);
}
+215 -38
View File
@@ -1,25 +1,121 @@
import React, { useRef, useState } from "react";
import { useEffect, useState } from "react";
import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap";
import { useAuth } from "../context/auth";
import { useBank } from "../context/bank";
import SettingsModal from "./modals/SettingsModal";
import { useSettings, ThemePreference } from "../context/settings";
import FeaturesVotingModal from "./modals/FeaturesVotingModal";
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
import RefreshMenuModal from "./modals/RefreshMenuModal";
import GenerateQrModal from "./modals/GenerateQrModal";
import GenerateMockDataModal from "./modals/GenerateMockDataModal";
import ClearMockDataModal from "./modals/ClearMockDataModal";
import { useNavigate } from "react-router";
import { STATS_URL } from "../AppRoutes";
import { FeatureRequest, getVotes, updateVote, LunchChoices, getChangelogs } from "../../../types";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
import { formatDateString } from "../Utils";
const LAST_SEEN_CHANGELOG_KEY = "lastChangelogDate";
export default function Header() {
const IS_DEV = process.env.NODE_ENV === 'development';
type Props = {
choices?: LunchChoices;
dayIndex?: number;
};
export default function Header({ choices, dayIndex }: Props) {
const auth = useAuth();
const bank = useBank();
const [modalOpen, setModalOpen] = useState<boolean>(false);
const bankAccountRef = useRef<HTMLInputElement>(null);
const nameRef = useRef<HTMLInputElement>(null);
const settings = useSettings();
const navigate = useNavigate();
const [settingsModalOpen, setSettingsModalOpen] = useState<boolean>(false);
const [votingModalOpen, setVotingModalOpen] = useState<boolean>(false);
const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false);
const [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState<boolean>(false);
const [changelogModalOpen, setChangelogModalOpen] = useState<boolean>(false);
const [changelogEntries, setChangelogEntries] = useState<Record<string, string[]>>({});
const [qrModalOpen, setQrModalOpen] = useState<boolean>(false);
const [generateMockModalOpen, setGenerateMockModalOpen] = useState<boolean>(false);
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
const [featureVotes, setFeatureVotes] = useState<FeatureRequest[] | undefined>([]);
const openBankSettings = () => {
setModalOpen(true);
// Zjistíme aktuální efektivní téma (pro zobrazení správné ikony)
const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark'>('light');
useEffect(() => {
const updateEffectiveTheme = () => {
if (settings?.themePreference === 'system') {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
setEffectiveTheme(isDark ? 'dark' : 'light');
} else {
setEffectiveTheme(settings?.themePreference || 'light');
}
};
updateEffectiveTheme();
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', updateEffectiveTheme);
return () => mediaQuery.removeEventListener('change', updateEffectiveTheme);
}, [settings?.themePreference]);
useEffect(() => {
if (auth?.login) {
getVotes().then(response => {
setFeatureVotes(response.data);
})
}
}, [auth?.login]);
useEffect(() => {
if (!auth?.login) return;
const lastSeen = localStorage.getItem(LAST_SEEN_CHANGELOG_KEY) ?? undefined;
getChangelogs({ query: lastSeen ? { since: lastSeen } : {} }).then(response => {
const entries = response.data;
if (!entries || Object.keys(entries).length === 0) return;
setChangelogEntries(entries);
setChangelogModalOpen(true);
const newestDate = Object.keys(entries).sort((a, b) => b.localeCompare(a))[0];
localStorage.setItem(LAST_SEEN_CHANGELOG_KEY, newestDate);
});
}, [auth?.login]);
const closeSettingsModal = () => {
setSettingsModalOpen(false);
}
const closeModal = () => {
setModalOpen(false);
const closeVotingModal = () => {
setVotingModalOpen(false);
}
const isValidInteger = (str) => {
const closePizzaModal = () => {
setPizzaModalOpen(false);
}
const closeRefreshMenuModal = () => {
setRefreshMenuModalOpen(false);
}
const closeQrModal = () => {
setQrModalOpen(false);
}
const handleQrMenuClick = () => {
if (!settings?.bankAccount || !settings?.holderName) {
alert('Pro generování QR kódů je nutné mít v nastavení vyplněné číslo účtu a jméno držitele účtu.');
return;
}
setQrModalOpen(true);
}
const toggleTheme = () => {
// Přepínáme mezi light a dark (ignorujeme system pro jednoduchost)
const newTheme: ThemePreference = effectiveTheme === 'dark' ? 'light' : 'dark';
settings?.setThemePreference(newTheme);
}
const isValidInteger = (str: string) => {
str = str.trim();
if (!str) {
return false;
@@ -29,19 +125,19 @@ export default function Header() {
return n !== Infinity && String(n) === str && n >= 0;
}
const save = () => {
if (bankAccountRef.current?.value) {
const saveSettings = (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean, themePreference?: ThemePreference) => {
if (bankAccountNumber) {
try {
// Validace kódu banky
if (bankAccountRef.current?.value.indexOf('/') < 0) {
throw Error("Číslo účtu neobsahuje lomítko/kód banky")
if (!bankAccountNumber.includes('/')) {
throw new Error("Číslo účtu neobsahuje lomítko/kód banky")
}
const split = bankAccountRef.current?.value.split("/");
const split = bankAccountNumber.split("/");
if (split[1].length !== 4) {
throw Error("Kód banky musí být 4 číslice")
throw new Error("Kód banky musí být 4 číslice")
}
if (!isValidInteger(split[1])) {
throw Error("Kód banky není číslo")
throw new Error("Kód banky není číslo")
}
// Validace čísla a předčíslí
@@ -51,57 +147,138 @@ export default function Header() {
cislo = cislo.replace('-', '');
}
if (!isValidInteger(cislo)) {
throw Error("Předčíslí nebo číslo účtu neobsahuje pouze číslice")
throw new Error("Předčíslí nebo číslo účtu neobsahuje pouze číslice")
}
if (cislo.length < 16) {
cislo = cislo.padStart(16, '0');
}
let sum = 0;
for (var i = 0; i < cislo.length; i++) {
for (let i = 0; i < cislo.length; i++) {
const char = cislo.charAt(i);
const order = (cislo.length - 1) - i;
const weight = (2 ** order) % 11;
sum += Number.parseInt(char) * weight
}
if (sum % 11 !== 0) {
throw Error("Číslo účtu je neplatné")
throw new Error("Číslo účtu je neplatné")
}
} catch (e) {
} catch (e: any) {
alert(e.message)
return
}
}
bank?.setBankAccountNumber(bankAccountRef.current?.value);
bank?.setBankAccountHolderName(nameRef.current?.value);
closeModal();
settings?.setBankAccountNumber(bankAccountNumber);
settings?.setBankAccountHolderName(bankAccountHolderName);
settings?.setHideSoupsOption(hideSoupsOption);
if (themePreference) {
settings?.setThemePreference(themePreference);
}
closeSettingsModal();
}
const saveFeatureVote = async (option: FeatureRequest, active: boolean) => {
await updateVote({ body: { option, active } });
const votes = [...featureVotes || []];
if (active) {
votes.push(option);
} else {
votes.splice(votes.indexOf(option), 1);
}
setFeatureVotes(votes);
}
return <Navbar variant='dark' expand="lg">
<Navbar.Brand>Luncher</Navbar.Brand>
<Navbar.Brand href="/">Luncher</Navbar.Brand>
<Navbar.Toggle aria-controls="basic-navbar-nav" />
<Navbar.Collapse id="basic-navbar-nav">
<Nav className="nav">
<button
className="theme-toggle"
onClick={toggleTheme}
title={effectiveTheme === 'dark' ? 'Přepnout na světlý režim' : 'Přepnout na tmavý režim'}
aria-label="Přepnout barevný motiv"
>
<FontAwesomeIcon icon={effectiveTheme === 'dark' ? faSun : faMoon} />
</button>
<NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown">
<NavDropdown.Item onClick={openBankSettings}>Nastavit číslo účtu</NavDropdown.Item>
<NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item>
<NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item>
<NavDropdown.Item onClick={() => setVotingModalOpen(true)}>Hlasovat o nových funkcích</NavDropdown.Item>
<NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item>
<NavDropdown.Item onClick={handleQrMenuClick}>Generování QR kódů</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item>
<NavDropdown.Item onClick={() => {
getChangelogs().then(response => {
const entries = response.data ?? {};
setChangelogEntries(entries);
setChangelogModalOpen(true);
const dates = Object.keys(entries).sort((a, b) => b.localeCompare(a));
if (dates.length > 0) {
localStorage.setItem(LAST_SEEN_CHANGELOG_KEY, dates[0]);
}
});
}}>Novinky</NavDropdown.Item>
{IS_DEV && (
<>
<NavDropdown.Divider />
<NavDropdown.Item onClick={() => setGenerateMockModalOpen(true)}>🔧 Generovat mock data</NavDropdown.Item>
<NavDropdown.Item onClick={() => setClearMockModalOpen(true)}>🔧 Smazat data dne</NavDropdown.Item>
</>
)}
<NavDropdown.Divider />
<NavDropdown.Item onClick={auth?.logout}>Odhlásit se</NavDropdown.Item>
</NavDropdown>
</Nav>
</Navbar.Collapse>
<Modal show={modalOpen} onHide={closeModal} size="lg">
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
<FeaturesVotingModal isOpen={votingModalOpen} onClose={closeVotingModal} onChange={saveFeatureVote} initialValues={featureVotes} />
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
{choices && settings?.bankAccount && settings?.holderName && (
<GenerateQrModal
isOpen={qrModalOpen}
onClose={closeQrModal}
choices={choices}
bankAccount={settings.bankAccount}
bankAccountHolder={settings.holderName}
/>
)}
{IS_DEV && (
<>
<GenerateMockDataModal
isOpen={generateMockModalOpen}
onClose={() => setGenerateMockModalOpen(false)}
currentDayIndex={dayIndex}
/>
<ClearMockDataModal
isOpen={clearMockModalOpen}
onClose={() => setClearMockModalOpen(false)}
currentDayIndex={dayIndex}
/>
</>
)}
<Modal show={changelogModalOpen} onHide={() => setChangelogModalOpen(false)} size="lg">
<Modal.Header closeButton>
<Modal.Title>Bankovní účet</Modal.Title>
<Modal.Title><h2>Novinky</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
<p>Nastavením čísla účtu umožníte automatické generování QR kódů pro úhradu za vámi provedené objednávky v rámci Pizza day.<br />Pokud vaše číslo účtu neobsahuje předčíslí, je možné ho zcela vynechat.<br /><br />Číslo účtu není ukládáno na serveru, posílá se na něj pouze za účelem vygenerování QR kódů.</p>
Číslo účtu: <input className="mb-3" ref={bankAccountRef} type="text" placeholder="123456-1234567890/1234" defaultValue={bank?.bankAccount} /> <br />
Název příjemce (jméno majitele účtu): <input ref={nameRef} type="text" placeholder="Jan Novák" defaultValue={bank?.holderName} />
{Object.keys(changelogEntries).sort((a, b) => b.localeCompare(a)).map(date => (
<div key={date}>
<strong>{formatDateString(date)}</strong>
<ul>
{changelogEntries[date].map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
))}
{Object.keys(changelogEntries).length === 0 && (
<p>Žádné novinky.</p>
)}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={closeModal}>
Storno
</Button>
<Button variant="primary" onClick={save}>
Uložit
<Button variant="secondary" onClick={() => setChangelogModalOpen(false)}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
+21
View File
@@ -0,0 +1,21 @@
import { IconDefinition } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
type Props = {
title?: string,
icon: IconDefinition,
description: string,
animation?: string,
}
function Loader(props: Readonly<Props>) {
return (
<div className='loader'>
<FontAwesomeIcon icon={props.icon} className={`loader-icon ${props.animation ?? ''}`} />
<h2 className='loader-title'>{props.title ?? 'Prosím čekejte...'}</h2>
<p className='loader-description'>{props.description}</p>
</div>
);
}
export default Loader;
+43 -35
View File
@@ -1,49 +1,57 @@
import React from "react";
import { Table } from "react-bootstrap";
import { Order, PizzaOrder, State } from "../Types";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrashCan } from "@fortawesome/free-regular-svg-icons";
import { useAuth } from "../context/auth";
import PizzaOrderRow from "./PizzaOrderRow";
import { PizzaDayState, PizzaOrder, PizzaVariant, updatePizzaFee } from "../../../types";
export default function PizzaOrderList({ state, orders, onDelete }: { state: State, orders: Order[], onDelete: (pizzaOrder: PizzaOrder) => void }) {
const auth = useAuth();
if (!orders?.length) {
return <p className="mt-3"><i>Zatím žádné objednávky...</i></p>
type Props = {
state: PizzaDayState,
orders: PizzaOrder[],
onDelete: (pizzaOrder: PizzaVariant) => void,
creator: string,
}
const total = orders.map(order => order.pizzaList.map(o => o.price).reduce((total, i) => total + i)).reduce((total, i) => total + i);
export default function PizzaOrderList({ state, orders, onDelete, creator }: Readonly<Props>) {
const saveFees = async (customer: string, text?: string, price?: number) => {
await updatePizzaFee({ body: { login: customer, text, price } });
}
return <Table className="mt-3" striped bordered hover>
<thead>
if (!orders?.length) {
return <p className="mt-4" style={{ color: 'var(--luncher-text-muted)', fontStyle: 'italic' }}>Zatím žádné objednávky...</p>
}
const total = orders.reduce((total, order) => total + order.totalPrice, 0);
return (
<div className="mt-4" style={{
background: 'var(--luncher-bg-card)',
borderRadius: 'var(--luncher-radius-lg)',
overflow: 'hidden',
border: '1px solid var(--luncher-border-light)',
boxShadow: 'var(--luncher-shadow)'
}}>
<Table className="mb-0" style={{ color: 'var(--luncher-text)' }}>
<thead style={{ background: 'var(--luncher-primary-light)' }}>
<tr>
<th>Jméno</th>
<th>Objednávka</th>
<th>Poznámka</th>
<th>Cena</th>
<th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none' }}>Jméno</th>
<th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none' }}>Objednávka</th>
<th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none' }}>Poznámka</th>
<th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none' }}>Příplatek</th>
<th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none', textAlign: 'right' }}>Cena</th>
</tr>
</thead>
<tbody>
{orders.map(order => <tr key={order.customer}>
<td>{order.customer}</td>
<td>{order.pizzaList.map<React.ReactNode>((pizzaOrder, index) =>
<span key={index}>
{`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price} Kč)`}
{auth?.login === order.customer && state === State.CREATED &&
<FontAwesomeIcon onClick={() => {
onDelete(pizzaOrder);
}} title='Odstranit' className='trash-icon' icon={faTrashCan} />
}
</span>)
.reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])}
</td>
<td>{order.note || '-'}</td>
<td>{order.totalPrice} </td>
{orders.map(order => <tr key={order.customer} style={{ borderColor: 'var(--luncher-border-light)' }}>
<PizzaOrderRow creator={creator} state={state} order={order} onDelete={onDelete} onFeeModalSave={saveFees} />
</tr>)}
<tr style={{ fontWeight: 'bold' }}>
<td colSpan={3}>Celkem</td>
<td>{`${total}`}</td>
<tr style={{
fontWeight: 700,
background: 'var(--luncher-bg-hover)',
borderTop: '2px solid var(--luncher-border)'
}}>
<td colSpan={4} style={{ padding: '16px 20px', border: 'none' }}>Celkem</td>
<td style={{ padding: '16px 20px', border: 'none', textAlign: 'right', color: 'var(--luncher-primary)' }}>{`${total}`}</td>
</tr>
</tbody>
</Table>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import React, { useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faMoneyBill1, faTrashCan } from "@fortawesome/free-regular-svg-icons";
import { useAuth } from "../context/auth";
import PizzaAdditionalFeeModal from "./modals/PizzaAdditionalFeeModal";
import { PizzaDayState, PizzaOrder, PizzaVariant } from "../../../types";
type Props = {
creator: string,
order: PizzaOrder,
state: PizzaDayState,
onDelete: (order: PizzaVariant) => void,
onFeeModalSave: (customer: string, name?: string, price?: number) => void,
}
export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeModalSave }: Readonly<Props>) {
const auth = useAuth();
const [isFeeModalOpen, setIsFeeModalOpen] = useState<boolean>(false);
const saveFees = (customer: string, text?: string, price?: number) => {
onFeeModalSave(customer, text, price);
setIsFeeModalOpen(false);
}
return <>
<td>{order.customer}</td>
<td>{order.pizzaList!.map<React.ReactNode>(pizzaOrder =>
<span key={pizzaOrder.name}>
{`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price} Kč)`}
{auth?.login === order.customer && state === PizzaDayState.CREATED &&
<span title='Odstranit'>
<FontAwesomeIcon onClick={() => {
onDelete(pizzaOrder);
}} className='action-icon' icon={faTrashCan} />
</span>
}
</span>)
.reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])}
</td>
<td style={{ maxWidth: "200px" }}>{order.note ?? '-'}</td>
<td style={{ maxWidth: "200px" }}>{order.fee?.price ? `${order.fee.price}${order.fee.text ? ` (${order.fee.text})` : ''}` : '-'}</td>
<td>
{order.totalPrice} {auth?.login === creator && state === PizzaDayState.CREATED && <span title='Nastavit příplatek'><FontAwesomeIcon onClick={() => { setIsFeeModalOpen(true) }} className='action-icon' icon={faMoneyBill1} /></span>}
</td>
<PizzaAdditionalFeeModal customerName={order.customer} isOpen={isFeeModalOpen} onClose={() => setIsFeeModalOpen(false)} onSave={saveFees} initialValues={{ text: order.fee?.text, price: order.fee?.price?.toString() }} />
</>
}
@@ -0,0 +1,104 @@
import { useState } from "react";
import { Modal, Button, Alert } from "react-bootstrap";
import { clearMockData, DayIndex } from "../../../../types";
type Props = {
isOpen: boolean;
onClose: () => void;
currentDayIndex?: number;
};
const DAY_NAMES = ['Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek'];
/** Modální dialog pro smazání mock dat (pouze DEV). */
export default function ClearMockDataModal({ isOpen, onClose, currentDayIndex }: Readonly<Props>) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const handleClear = async () => {
setError(null);
setLoading(true);
try {
const body: any = {};
if (currentDayIndex !== undefined) {
body.dayIndex = currentDayIndex as DayIndex;
}
const response = await clearMockData({ body });
if (response.error) {
setError((response.error as any).error || 'Nastala chyba při mazání dat');
} else {
setSuccess(true);
setTimeout(() => {
onClose();
setSuccess(false);
}, 1500);
}
} catch (e: any) {
setError(e.message || 'Nastala chyba při mazání dat');
} finally {
setLoading(false);
}
};
const handleClose = () => {
setError(null);
setSuccess(false);
onClose();
};
const dayName = currentDayIndex !== undefined ? DAY_NAMES[currentDayIndex] : 'aktuální den';
return (
<Modal show={isOpen} onHide={handleClose}>
<Modal.Header closeButton>
<Modal.Title><h2>Smazat data</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{success ? (
<Alert variant="success">
Data byla úspěšně smazána!
</Alert>
) : (
<>
<Alert variant="warning">
<strong>DEV režim</strong> - Tato funkce je dostupná pouze ve vývojovém prostředí.
</Alert>
{error && (
<Alert variant="danger" onClose={() => setError(null)} dismissible>
{error}
</Alert>
)}
<p>
Opravdu chcete smazat všechny volby stravování pro <strong>{dayName}</strong>?
</p>
<p className="text-muted">
Tato akce je nevratná.
</p>
</>
)}
</Modal.Body>
<Modal.Footer>
{!success && (
<>
<Button variant="secondary" onClick={handleClose} disabled={loading}>
Ne, zrušit
</Button>
<Button variant="danger" onClick={handleClear} disabled={loading}>
{loading ? 'Mažu...' : 'Ano, smazat'}
</Button>
</>
)}
{success && (
<Button variant="secondary" onClick={handleClose}>
Zavřít
</Button>
)}
</Modal.Footer>
</Modal>
);
}
@@ -0,0 +1,45 @@
import { Modal, Button, Form } from "react-bootstrap"
import { FeatureRequest } from "../../../../types";
type Props = {
isOpen: boolean,
onClose: () => void,
onChange: (option: FeatureRequest, active: boolean) => void,
initialValues?: FeatureRequest[],
}
/** Modální dialog pro hlasování o nových funkcích. */
export default function FeaturesVotingModal({ isOpen, onClose, onChange, initialValues }: Readonly<Props>) {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.currentTarget.value as FeatureRequest, e.currentTarget.checked);
}
return <Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title>
Hlasujte pro nové funkce
<p style={{ fontSize: '12px' }}>Je možno vybrat maximálně 4 možnosti</p>
</Modal.Title>
</Modal.Header>
<Modal.Body>
{(Object.keys(FeatureRequest) as Array<keyof typeof FeatureRequest>).map(key => {
return <Form.Check
key={key}
type='checkbox'
id={key}
label={FeatureRequest[key]}
onChange={handleChange}
value={key}
defaultChecked={initialValues?.includes(key as FeatureRequest)}
/>
})}
<p className="mt-3" style={{ fontSize: '12px' }}>Něco jiného? Dejte vědět.</p>
</Modal.Body>
<Modal.Footer>
<Button variant="primary" onClick={onClose}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
}
@@ -0,0 +1,140 @@
import { useState } from "react";
import { Modal, Button, Form, Alert } from "react-bootstrap";
import { generateMockData, DayIndex } from "../../../../types";
type Props = {
isOpen: boolean;
onClose: () => void;
currentDayIndex?: number;
};
const DAY_NAMES = ['Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek'];
/** Modální dialog pro generování mock dat (pouze DEV). */
export default function GenerateMockDataModal({ isOpen, onClose, currentDayIndex }: Readonly<Props>) {
const [dayIndex, setDayIndex] = useState<number | undefined>(currentDayIndex);
const [count, setCount] = useState<string>('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const handleGenerate = async () => {
setError(null);
setLoading(true);
try {
const body: any = {};
if (dayIndex !== undefined) {
body.dayIndex = dayIndex as DayIndex;
}
if (count && count.trim() !== '') {
const countNum = parseInt(count, 10);
if (isNaN(countNum) || countNum < 1 || countNum > 100) {
setError('Počet musí být číslo mezi 1 a 100');
setLoading(false);
return;
}
body.count = countNum;
}
const response = await generateMockData({ body });
if (response.error) {
setError((response.error as any).error || 'Nastala chyba při generování dat');
} else {
setSuccess(true);
setTimeout(() => {
onClose();
setSuccess(false);
setCount('');
}, 1500);
}
} catch (e: any) {
setError(e.message || 'Nastala chyba při generování dat');
} finally {
setLoading(false);
}
};
const handleClose = () => {
setError(null);
setSuccess(false);
setCount('');
onClose();
};
return (
<Modal show={isOpen} onHide={handleClose}>
<Modal.Header closeButton>
<Modal.Title><h2>Generovat mock data</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{success ? (
<Alert variant="success">
Mock data byla úspěšně vygenerována!
</Alert>
) : (
<>
<Alert variant="warning">
<strong>DEV režim</strong> - Tato funkce je dostupná pouze ve vývojovém prostředí.
</Alert>
{error && (
<Alert variant="danger" onClose={() => setError(null)} dismissible>
{error}
</Alert>
)}
<Form.Group className="mb-3">
<Form.Label>Den</Form.Label>
<Form.Select
value={dayIndex ?? ''}
onChange={e => setDayIndex(e.target.value === '' ? undefined : parseInt(e.target.value, 10))}
>
<option value="">Aktuální den</option>
{DAY_NAMES.map((name, index) => (
<option key={index} value={index}>{name}</option>
))}
</Form.Select>
<Form.Text className="text-muted">
Pokud není vybráno, použije se aktuální den.
</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Počet záznamů</Form.Label>
<Form.Control
type="number"
placeholder="Náhodný (5-20)"
value={count}
onChange={e => setCount(e.target.value)}
min={1}
max={100}
onKeyDown={e => e.stopPropagation()}
/>
<Form.Text className="text-muted">
Pokud není zadáno, vybere se náhodný počet 5-20.
</Form.Text>
</Form.Group>
</>
)}
</Modal.Body>
<Modal.Footer>
{!success && (
<>
<Button variant="secondary" onClick={handleClose} disabled={loading}>
Storno
</Button>
<Button variant="primary" onClick={handleGenerate} disabled={loading}>
{loading ? 'Generuji...' : 'Generovat'}
</Button>
</>
)}
{success && (
<Button variant="secondary" onClick={handleClose}>
Zavřít
</Button>
)}
</Modal.Footer>
</Modal>
);
}
@@ -0,0 +1,255 @@
import { useState, useEffect, useCallback } from "react";
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
import { generateQr, LunchChoices, QrRecipient } from "../../../../types";
type UserEntry = {
login: string;
selected: boolean;
purpose: string;
amount: string;
};
type Props = {
isOpen: boolean;
onClose: () => void;
choices: LunchChoices;
bankAccount: string;
bankAccountHolder: string;
};
/** Modální dialog pro generování QR kódů pro platbu. */
export default function GenerateQrModal({ isOpen, onClose, choices, bankAccount, bankAccountHolder }: Readonly<Props>) {
const [users, setUsers] = useState<UserEntry[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
// Při otevření modálu načteme seznam uživatelů z choices
useEffect(() => {
if (isOpen && choices) {
const userLogins = new Set<string>();
// Projdeme všechny lokace a získáme unikátní loginy
Object.values(choices).forEach(locationChoices => {
if (locationChoices) {
Object.keys(locationChoices).forEach(login => {
userLogins.add(login);
});
}
});
// Vytvoříme seznam uživatelů
const userList: UserEntry[] = Array.from(userLogins)
.sort((a, b) => a.localeCompare(b, 'cs'))
.map(login => ({
login,
selected: false,
purpose: '',
amount: '',
}));
setUsers(userList);
setError(null);
setSuccess(false);
}
}, [isOpen, choices]);
const handleCheckboxChange = useCallback((login: string, checked: boolean) => {
setUsers(prev => prev.map(u =>
u.login === login ? { ...u, selected: checked } : u
));
}, []);
const handlePurposeChange = useCallback((login: string, value: string) => {
setUsers(prev => prev.map(u =>
u.login === login ? { ...u, purpose: value } : u
));
}, []);
const handleAmountChange = useCallback((login: string, value: string) => {
// Povolíme pouze čísla, tečku a čárku
const sanitized = value.replace(/[^0-9.,]/g, '').replace(',', '.');
setUsers(prev => prev.map(u =>
u.login === login ? { ...u, amount: sanitized } : u
));
}, []);
const validateAmount = (amountStr: string): number | null => {
if (!amountStr || amountStr.trim().length === 0) {
return null;
}
const amount = parseFloat(amountStr);
if (isNaN(amount) || amount <= 0) {
return null;
}
// Max 2 desetinná místa
const parts = amountStr.split('.');
if (parts.length === 2 && parts[1].length > 2) {
return null;
}
return Math.round(amount * 100) / 100; // Zaokrouhlíme na 2 desetinná místa
};
const handleGenerate = async () => {
setError(null);
const selectedUsers = users.filter(u => u.selected);
if (selectedUsers.length === 0) {
setError("Nebyl vybrán žádný uživatel");
return;
}
// Validace
const recipients: QrRecipient[] = [];
for (const user of selectedUsers) {
if (!user.purpose || user.purpose.trim().length === 0) {
setError(`Uživatel ${user.login} nemá vyplněný účel platby`);
return;
}
const amount = validateAmount(user.amount);
if (amount === null) {
setError(`Uživatel ${user.login} má neplatnou částku (musí být kladné číslo s max. 2 desetinnými místy)`);
return;
}
recipients.push({
login: user.login,
purpose: user.purpose.trim(),
amount,
});
}
setLoading(true);
try {
const response = await generateQr({
body: {
recipients,
bankAccount,
bankAccountHolder,
}
});
if (response.error) {
setError((response.error as any).error || 'Nastala chyba při generování QR kódů');
} else {
setSuccess(true);
// Po 2 sekundách zavřeme modal
setTimeout(() => {
onClose();
}, 2000);
}
} catch (e: any) {
setError(e.message || 'Nastala chyba při generování QR kódů');
} finally {
setLoading(false);
}
};
const handleClose = () => {
setError(null);
setSuccess(false);
onClose();
};
const selectedCount = users.filter(u => u.selected).length;
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>
{success ? (
<Alert variant="success">
QR kódy byly úspěšně vygenerovány! Uživatelé je uvidí v sekci "Nevyřízené platby".
</Alert>
) : (
<>
<p>
Vyberte uživatele, kterým chcete vygenerovat QR kód pro platbu.
QR kódy se uživatelům zobrazí v sekci "Nevyřízené platby".
</p>
{error && (
<Alert variant="danger" onClose={() => setError(null)} dismissible>
{error}
</Alert>
)}
{users.length === 0 ? (
<Alert variant="info">
V tento den nemá žádný uživatel zvolenou možnost stravování.
</Alert>
) : (
<Table striped bordered hover responsive>
<thead>
<tr>
<th style={{ width: '50px' }}></th>
<th>Uživatel</th>
<th>Účel platby</th>
<th style={{ width: '120px' }}>Částka ()</th>
</tr>
</thead>
<tbody>
{users.map(user => (
<tr key={user.login} className={user.selected ? '' : 'text-muted'}>
<td className="text-center">
<Form.Check
type="checkbox"
checked={user.selected}
onChange={e => handleCheckboxChange(user.login, e.target.checked)}
/>
</td>
<td>{user.login}</td>
<td>
<Form.Control
type="text"
placeholder="např. Pizza prosciutto"
value={user.purpose}
onChange={e => handlePurposeChange(user.login, e.target.value)}
disabled={!user.selected}
size="sm"
onKeyDown={e => e.stopPropagation()}
/>
</td>
<td>
<Form.Control
type="text"
placeholder="0.00"
value={user.amount}
onChange={e => handleAmountChange(user.login, e.target.value)}
disabled={!user.selected}
size="sm"
onKeyDown={e => e.stopPropagation()}
/>
</td>
</tr>
))}
</tbody>
</Table>
)}
</>
)}
</Modal.Body>
<Modal.Footer>
{!success && (
<>
<span className="me-auto text-muted">
Vybráno: {selectedCount} / {users.length}
</span>
<Button variant="secondary" onClick={handleClose} disabled={loading}>
Storno
</Button>
<Button
variant="primary"
onClick={handleGenerate}
disabled={loading || selectedCount === 0}
>
{loading ? 'Generuji...' : 'Generovat'}
</Button>
</>
)}
{success && (
<Button variant="secondary" onClick={handleClose}>
Zavřít
</Button>
)}
</Modal.Footer>
</Modal>
);
}
@@ -0,0 +1,36 @@
import { useRef } from "react";
import { Modal, Button, Form } from "react-bootstrap"
type Props = {
isOpen: boolean,
onClose: () => void,
onSave: (note?: string) => void,
}
/** Modální dialog pro úpravu obecné poznámky. */
export default function NoteModal({ isOpen, onClose, onSave }: Readonly<Props>) {
const note = useRef<HTMLInputElement>(null);
const save = () => {
onSave(note?.current?.value);
}
return <Modal show={isOpen} onHide={onClose}>
<Modal.Header closeButton>
<Modal.Title>Úprava poznámky</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form.Control ref={note} autoFocus={true} type="text" id="note" onKeyDown={event => {
if (event.key === 'Enter') {
save();
}
event.stopPropagation();
}} />
</Modal.Body>
<Modal.Footer>
<Button variant="primary" onClick={save}>
Uložit
</Button>
</Modal.Footer>
</Modal>
}
@@ -0,0 +1,308 @@
import { useState, useEffect, useCallback } from "react";
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
import { generateQr, LunchChoice, LocationLunchChoicesMap, RestaurantDayMenu, QrRecipient } from "../../../../types";
import { parsePriceCzk } from "../../utils/parsePrice";
type DinerEntry = {
login: string;
selectedFoods: number[];
baseAmount: number;
baseAmountParseFailed: boolean;
surchargeText: string;
surchargeAmount: string;
included: boolean;
};
type Props = {
isOpen: boolean;
onClose: () => void;
locationKey: LunchChoice;
locationName: string;
locationChoices: LocationLunchChoicesMap;
menu: RestaurantDayMenu | undefined;
payerLogin: string;
bankAccount: string;
bankAccountHolder: string;
};
function sanitizeAmount(value: string): string {
return value.replace(/[^0-9.,]/g, '').replace(',', '.');
}
function parseAmount(s: string): number | null {
if (!s || s.trim().length === 0) return null;
const n = parseFloat(s);
if (isNaN(n) || n < 0) return null;
const parts = s.split('.');
if (parts.length === 2 && parts[1].length > 2) return null;
return Math.round(n * 100) / 100;
}
export default function PayForAllModal({ isOpen, onClose, locationName, locationChoices, menu, payerLogin, bankAccount, bankAccountHolder }: Readonly<Props>) {
const [diners, setDiners] = useState<DinerEntry[]>([]);
const [tipTotal, setTipTotal] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const hasMenu = !!menu;
useEffect(() => {
if (!isOpen) return;
const entries: DinerEntry[] = Object.entries(locationChoices).map(([login, choice]) => {
const selectedFoods = choice.selectedFoods ?? [];
let baseAmount = 0;
let baseAmountParseFailed = false;
if (menu) {
for (const idx of selectedFoods) {
const price = parsePriceCzk(menu.food?.[idx]?.price);
if (price === null) {
baseAmountParseFailed = true;
} else {
baseAmount += price;
}
}
}
return {
login,
selectedFoods,
baseAmount,
baseAmountParseFailed,
surchargeText: '',
surchargeAmount: '',
included: login !== payerLogin,
};
});
setDiners(entries);
setTipTotal('');
setError(null);
setSuccess(false);
}, [isOpen, locationChoices, menu, payerLogin]);
const includedDiners = diners.filter(d => d.included && d.login !== payerLogin);
const tipPerPerson = (() => {
if (includedDiners.length === 0) return 0;
const tip = parseAmount(tipTotal);
if (tip === null || tip === 0) return 0;
return Math.round((tip / includedDiners.length) * 100) / 100;
})();
const getTotal = (d: DinerEntry): number => {
const surcharge = parseAmount(d.surchargeAmount) ?? 0;
const tip = d.included && d.login !== payerLogin ? tipPerPerson : 0;
return Math.round((d.baseAmount + surcharge + tip) * 100) / 100;
};
const handleInclude = useCallback((login: string, checked: boolean) => {
setDiners(prev => prev.map(d => d.login === login ? { ...d, included: checked } : d));
}, []);
const handleSurchargeText = useCallback((login: string, value: string) => {
setDiners(prev => prev.map(d => d.login === login ? { ...d, surchargeText: value } : d));
}, []);
const handleSurchargeAmount = useCallback((login: string, value: string) => {
setDiners(prev => prev.map(d => d.login === login ? { ...d, surchargeAmount: sanitizeAmount(value) } : d));
}, []);
const handleGenerate = async () => {
setError(null);
const recipients: QrRecipient[] = [];
for (const d of diners) {
if (!d.included || d.login === payerLogin) continue;
const total = getTotal(d);
if (total <= 0) {
setError(`Celková částka pro ${d.login} musí být kladná`);
return;
}
const amountStr = total.toString();
if (amountStr.includes('.') && amountStr.split('.')[1].length > 2) {
setError(`Částka pro ${d.login} má více než 2 desetinná místa`);
return;
}
const foods = d.selectedFoods.map(i => menu?.food?.[i]?.name).filter(Boolean).join(', ');
const purposeBase = `Oběd ${locationName}${foods ? `${foods}` : ''}`;
recipients.push({
login: d.login,
purpose: purposeBase.substring(0, 60),
amount: total,
});
}
if (recipients.length === 0) {
setError("Nebyl vybrán žádný příjemce");
return;
}
setLoading(true);
try {
const response = await generateQr({
body: { recipients, bankAccount, bankAccountHolder },
});
if (response.error) {
setError((response.error as any).error || 'Nastala chyba při generování QR kódů');
} else {
setSuccess(true);
setTimeout(() => onClose(), 2000);
}
} catch (e: any) {
setError(e.message || 'Nastala chyba při generování QR kódů');
} finally {
setLoading(false);
}
};
const anyParseFailed = diners.some(d => d.baseAmountParseFailed && d.included);
return (
<Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Zaplatit za všechny {locationName}</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{success ? (
<Alert variant="success">
QR kódy byly úspěšně vygenerovány! Uživatelé je uvidí v sekci Nevyřízené platby".
</Alert>
) : (
<>
<p>Zaplatili jste za skupinu v restauraci. Nastavte příplatky a dýško, poté vygenerujte QR kódy pro ostatní.</p>
{!hasMenu && (
<Alert variant="info">
Pro tuto skupinu nejsou k dispozici ceny jídel — vyplňte příplatky ručně.
</Alert>
)}
{anyParseFailed && (
<Alert variant="warning">
U některých jídel se nepodařilo načíst cenu — doplňte ji ručně v sloupci Příplatek.
</Alert>
)}
{error && (
<Alert variant="danger" onClose={() => setError(null)} dismissible>
{error}
</Alert>
)}
<Table striped bordered hover responsive size="sm">
<thead>
<tr>
<th style={{ width: 40 }}></th>
<th>Strávník</th>
<th>Jídla</th>
<th style={{ width: 220 }}>Příplatek</th>
<th style={{ width: 90 }}>Dýško</th>
<th style={{ width: 90 }}>Celkem</th>
</tr>
</thead>
<tbody>
{diners.map(d => {
const isPayer = d.login === payerLogin;
const foodNames = d.selectedFoods.map(i => menu?.food?.[i]?.name).filter(Boolean).join(', ');
const total = getTotal(d);
return (
<tr key={d.login} className={!d.included && !isPayer ? 'text-muted' : ''}>
<td className="text-center">
{isPayer ? (
<small className="text-muted">plátce</small>
) : (
<Form.Check
type="checkbox"
checked={d.included}
onChange={e => handleInclude(d.login, e.target.checked)}
/>
)}
</td>
<td><strong>{d.login}</strong></td>
<td>
<small>
{foodNames || <span className="text-muted">—</span>}
{hasMenu && d.baseAmount > 0 && <span className="text-muted"> ({d.baseAmount} Kč)</span>}
{d.baseAmountParseFailed && <span className="text-warning"> ⚠</span>}
</small>
</td>
<td>
{!isPayer && (
<div className="d-flex gap-1">
<Form.Control
type="text"
placeholder="popis"
value={d.surchargeText}
onChange={e => handleSurchargeText(d.login, e.target.value)}
disabled={!d.included}
size="sm"
onKeyDown={e => e.stopPropagation()}
/>
<Form.Control
type="text"
placeholder=""
value={d.surchargeAmount}
onChange={e => handleSurchargeAmount(d.login, e.target.value)}
disabled={!d.included}
size="sm"
style={{ width: 70 }}
onKeyDown={e => e.stopPropagation()}
/>
</div>
)}
</td>
<td className="text-end">
{!isPayer && d.included ? `${tipPerPerson} Kč` : '—'}
</td>
<td className="text-end fw-bold">
{!isPayer ? `${total} Kč` : '—'}
</td>
</tr>
);
})}
</tbody>
</Table>
<div className="d-flex align-items-center gap-2 mt-2">
<label className="mb-0 text-nowrap">Dýško celkem (Kč):</label>
<Form.Control
type="text"
placeholder="0"
value={tipTotal}
onChange={e => setTipTotal(sanitizeAmount(e.target.value))}
size="sm"
style={{ width: 100 }}
onKeyDown={e => e.stopPropagation()}
/>
<small className="text-muted">
{includedDiners.length > 0 && tipPerPerson > 0
? `(${tipPerPerson} Kč / osoba)`
: ''}
</small>
</div>
</>
)}
</Modal.Body>
<Modal.Footer>
{!success && (
<>
<span className="me-auto text-muted">
Příjemci: {includedDiners.length}
</span>
<Button variant="secondary" onClick={onClose} disabled={loading}>
Storno
</Button>
<Button
variant="primary"
onClick={handleGenerate}
disabled={loading || includedDiners.length === 0}
>
{loading ? 'Generuji...' : 'Vygenerovat QR'}
</Button>
</>
)}
{success && (
<Button variant="secondary" onClick={onClose}>Zavřít</Button>
)}
</Modal.Footer>
</Modal>
);
}
@@ -0,0 +1,45 @@
import { useRef } from "react";
import { Modal, Button } from "react-bootstrap"
type Props = {
customerName: string,
isOpen: boolean,
onClose: () => void,
onSave: (customer: string, name?: string, price?: number) => void,
initialValues?: { text?: string, price?: string },
}
/** Modální dialog pro nastavení příplatků za pizzu. */
export default function PizzaAdditionalFeeModal({ customerName, isOpen, onClose, onSave, initialValues }: Readonly<Props>) {
const textRef = useRef<HTMLInputElement>(null);
const priceRef = useRef<HTMLInputElement>(null);
const doSubmit = () => {
onSave(customerName, textRef.current?.value, Number.parseInt(priceRef.current?.value ?? "0"));
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
onSave(customerName, textRef.current?.value, Number.parseInt(priceRef.current?.value ?? "0"));
}
}
return <Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title>Příplatky za objednávku pro {customerName}</Modal.Title>
</Modal.Header>
<Modal.Body>
Popis: <input className="mb-3" ref={textRef} type="text" placeholder="např. kuřecí maso" defaultValue={initialValues?.text} onKeyDown={handleKeyDown} /> <br />
Cena v : <input ref={priceRef} type="number" placeholder="0" defaultValue={initialValues?.price} onKeyDown={handleKeyDown} /> <br />
<div className="mt-3" style={{ fontSize: 'small' }}>Je možné zadávat i záporné částky (např. v případě slev)</div>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>
Storno
</Button>
<Button variant="primary" onClick={doSubmit}>
Uložit
</Button>
</Modal.Footer>
</Modal>
}
@@ -0,0 +1,137 @@
import { useRef, useState } from "react";
import { Modal, Button, Row, Col } from "react-bootstrap"
type Props = {
isOpen: boolean,
onClose: () => void,
}
type Result = {
pizza1?: {
diameter?: number,
area?: number,
pricePerM?: number,
},
pizza2?: {
diameter?: number,
area?: number,
pricePerM?: number,
}
choice?: number,
ratio?: number,
diameterDiff?: number,
}
/** Modální dialog pro výpočet výhodnosti pizzy. */
export default function PizzaCalculatorModal({ isOpen, onClose }: Readonly<Props>) {
const diameter1Ref = useRef<HTMLInputElement>(null);
const price1Ref = useRef<HTMLInputElement>(null);
const diameter2Ref = useRef<HTMLInputElement>(null);
const price2Ref = useRef<HTMLInputElement>(null);
const [result, setResult] = useState<Result | null>(null);
const recalculate = () => {
const r: Result = { ...result }
// 1. pizza
if (diameter1Ref.current?.value) {
const diameter1 = Number.parseInt(diameter1Ref.current?.value);
r.pizza1 ??= {};
if (diameter1 && diameter1 > 0) {
r.pizza1.diameter = diameter1;
r.pizza1.area = Math.PI * Math.pow(diameter1 / 2, 2);
if (price1Ref.current?.value) {
const price1 = Number.parseInt(price1Ref.current?.value);
if (price1) {
r.pizza1.pricePerM = price1 / r.pizza1.area;
} else {
r.pizza1.pricePerM = undefined;
}
}
} else {
r.pizza1.area = undefined;
}
}
// 2. pizza
if (diameter2Ref.current?.value) {
const diameter2 = Number.parseInt(diameter2Ref.current?.value);
r.pizza2 ??= {};
if (diameter2 && diameter2 > 0) {
r.pizza2.diameter = diameter2;
r.pizza2.area = Math.PI * Math.pow(diameter2 / 2, 2);
if (price2Ref.current?.value) {
const price2 = Number.parseInt(price2Ref.current?.value);
if (price2) {
r.pizza2.pricePerM = price2 / r.pizza2.area;
} else {
r.pizza2.pricePerM = undefined;
}
}
} else {
r.pizza2.area = undefined;
}
}
// Srovnání
if (r.pizza1?.pricePerM && r.pizza2?.pricePerM && r.pizza1.diameter && r.pizza2.diameter) {
r.choice = r.pizza1.pricePerM < r.pizza2.pricePerM ? 1 : 2;
const bigger = Math.max(r.pizza1.pricePerM, r.pizza2.pricePerM);
const smaller = Math.min(r.pizza1.pricePerM, r.pizza2.pricePerM);
r.ratio = (bigger / smaller) - 1;
r.diameterDiff = Math.abs(r.pizza1.diameter - r.pizza2.diameter);
} else {
r.choice = undefined;
r.ratio = undefined;
r.diameterDiff = undefined;
}
setResult(r);
}
const close = () => {
setResult(null);
onClose();
}
return <Modal show={isOpen} onHide={onClose}>
<Modal.Header closeButton>
<Modal.Title>Pizza kalkulačka</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>Zadejte parametry pizzy pro jejich srovnání.</p>
<Row>
<Col size="6">
<input className="mb-3" ref={diameter1Ref} type="number" step="1" min="1" placeholder="Průměr 1. pizzy (cm)" onChange={recalculate} onKeyDown={e => e.stopPropagation()} />
</Col>
<Col size="6">
<input className="mb-3" ref={diameter2Ref} type="number" step="1" min="1" placeholder="Průměr 2. pizzy (cm)" onChange={recalculate} onKeyDown={e => e.stopPropagation()} />
</Col>
</Row>
<Row>
<Col size="6">
<input className="mb-3" ref={price1Ref} type="number" min="1" placeholder="Cena 1. pizzy (Kč)" onChange={recalculate} onKeyDown={e => e.stopPropagation()} />
</Col>
<Col size="6">
<input className="mb-3" ref={price2Ref} type="number" min="1" placeholder="Cena 2. pizzy (Kč)" onChange={recalculate} onKeyDown={e => e.stopPropagation()} />
</Col>
</Row>
<Row>
<Col size="6">
{result?.pizza1?.area && <p>Plocha: <b>{Math.round(result.pizza1.area * 10) / 10}</b> cm²</p>}
{result?.pizza1?.pricePerM && <p>Cena za m²: <b>{Math.round(result.pizza1.pricePerM * 1000000) / 100}</b> </p>}
</Col>
<Col size="6">
{result?.pizza2?.area && <p>Plocha: <b>{Math.round(result.pizza2.area * 10) / 10}</b> cm²</p>}
{result?.pizza2?.pricePerM && <p>Cena za m²: <b>{Math.round(result.pizza2.pricePerM * 1000000) / 100}</b> </p>}
</Col>
</Row>
{(result?.choice && result?.ratio && result?.ratio > 0 && result?.diameterDiff != null && <p><b>{result.choice}. pizza</b> je zhruba o <b>{Math.round(result.ratio * 1000) / 10}%</b> výhodnější než {result.choice === 1 ? "2" : "1"}. pizza.</p>) || ''}
</Modal.Body>
<Modal.Footer>
<Button variant="primary" onClick={close}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
}
@@ -0,0 +1,97 @@
import { useRef, useState } from "react";
import { Modal, Button, Alert, Form } 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) {
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}>
<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>
)}
<Form.Group className="mb-3">
<Form.Label>Heslo</Form.Label>
<Form.Control
ref={refreshPassRef}
type="password"
placeholder="Zadejte heslo"
onKeyDown={e => e.stopPropagation()}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Typ refreshe</Form.Label>
<Form.Select ref={refreshTypeRef} defaultValue="week">
<option value="week">Týden</option>
<option value="day">Den</option>
</Form.Select>
</Form.Group>
<Button
onClick={handleRefresh}
disabled={refreshLoading}
>
{refreshLoading ? 'Načítám...' : 'Obnovit menu'}
</Button>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={handleClose}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
);
}
@@ -0,0 +1,238 @@
import { useEffect, useRef, useState } from "react";
import { Modal, Button, Form } from "react-bootstrap"
import { useSettings, ThemePreference } from "../../context/settings";
import { NotificationSettings, UdalostEnum, getNotificationSettings, updateNotificationSettings } from "../../../../types";
import { useAuth } from "../../context/auth";
import { subscribeToPush, unsubscribeFromPush } from "../../hooks/usePushReminder";
type Props = {
isOpen: boolean,
onClose: () => void,
onSave: (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean, themePreference?: ThemePreference) => void,
}
/** Modální dialog pro uživatelská nastavení. */
export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Props>) {
const auth = useAuth();
const settings = useSettings();
const bankAccountRef = useRef<HTMLInputElement>(null);
const nameRef = useRef<HTMLInputElement>(null);
const hideSoupsRef = useRef<HTMLInputElement>(null);
const themeRef = useRef<HTMLSelectElement>(null);
const reminderTimeRef = useRef<HTMLInputElement>(null);
const ntfyTopicRef = useRef<HTMLInputElement>(null);
const discordWebhookRef = useRef<HTMLInputElement>(null);
const teamsWebhookRef = useRef<HTMLInputElement>(null);
const [notifSettings, setNotifSettings] = useState<NotificationSettings>({});
const [enabledEvents, setEnabledEvents] = useState<UdalostEnum[]>([]);
useEffect(() => {
if (isOpen && auth?.login) {
getNotificationSettings().then(response => {
if (response.data) {
setNotifSettings(response.data);
setEnabledEvents(response.data.enabledEvents ?? []);
}
}).catch(() => {});
}
}, [isOpen, auth?.login]);
const toggleEvent = (event: UdalostEnum) => {
setEnabledEvents(prev =>
prev.includes(event) ? prev.filter(e => e !== event) : [...prev, event]
);
};
const handleSave = async () => {
const newReminderTime = reminderTimeRef.current?.value || undefined;
const oldReminderTime = notifSettings.reminderTime;
// Uložení notifikačních nastavení na server
await updateNotificationSettings({
body: {
ntfyTopic: ntfyTopicRef.current?.value || undefined,
discordWebhookUrl: discordWebhookRef.current?.value || undefined,
teamsWebhookUrl: teamsWebhookRef.current?.value || undefined,
enabledEvents,
reminderTime: newReminderTime,
}
}).catch(() => {});
// Správa push subscription pro připomínky
if (newReminderTime && newReminderTime !== oldReminderTime) {
subscribeToPush(newReminderTime);
} else if (!newReminderTime && oldReminderTime) {
unsubscribeFromPush();
}
// Uložení ostatních nastavení (localStorage)
onSave(
bankAccountRef.current?.value,
nameRef.current?.value,
hideSoupsRef.current?.checked,
themeRef.current?.value as ThemePreference,
);
};
return (
<Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Nastavení</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
<h4>Vzhled</h4>
<Form.Group className="mb-3">
<Form.Label>Barevný motiv</Form.Label>
<Form.Select ref={themeRef} defaultValue={settings?.themePreference}>
<option value="system">Podle systému</option>
<option value="light">Světlý</option>
<option value="dark">Tmavý</option>
</Form.Select>
</Form.Group>
<hr />
<h4>Obecné</h4>
<Form.Group className="mb-3">
<Form.Check
id="hideSoupsCheckbox"
ref={hideSoupsRef}
type="checkbox"
label="Skrýt polévky"
defaultChecked={settings?.hideSoups}
title="V nabídkách nebudou zobrazovány polévky. Tato funkce je experimentální."
/>
<Form.Text className="text-muted">
Experimentální funkce - zejména u TechTower bývá problém polévky spolehlivě rozeznat.
</Form.Text>
</Form.Group>
<hr />
<h4>Notifikace</h4>
<p>
Nastavením notifikací budete dostávat upozornění o událostech (např. "Jdeme na oběd") přímo do vámi zvoleného komunikačního kanálu.
</p>
<Form.Group className="mb-3">
<Form.Label>Připomínka výběru oběda</Form.Label>
<Form.Control
ref={reminderTimeRef}
type="time"
defaultValue={notifSettings.reminderTime ?? ''}
key={notifSettings.reminderTime ?? 'reminder-empty'}
/>
<Form.Text className="text-muted">
V zadaný čas vám přijde push notifikace, pokud nemáte zvolenou možnost stravování. Nechte prázdné pro vypnutí.
</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>ntfy téma (topic)</Form.Label>
<Form.Control
ref={ntfyTopicRef}
type="text"
placeholder="moje-tema"
defaultValue={notifSettings.ntfyTopic}
key={notifSettings.ntfyTopic ?? 'ntfy-empty'}
onKeyDown={e => e.stopPropagation()}
/>
<Form.Text className="text-muted">
Téma pro ntfy push notifikace. Nechte prázdné pro vypnutí.
</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Discord webhook URL</Form.Label>
<Form.Control
ref={discordWebhookRef}
type="text"
placeholder="https://discord.com/api/webhooks/..."
defaultValue={notifSettings.discordWebhookUrl}
key={notifSettings.discordWebhookUrl ?? 'discord-empty'}
onKeyDown={e => e.stopPropagation()}
/>
<Form.Text className="text-muted">
URL webhooku Discord kanálu. Nechte prázdné pro vypnutí.
</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>MS Teams webhook URL</Form.Label>
<Form.Control
ref={teamsWebhookRef}
type="text"
placeholder="https://outlook.office.com/webhook/..."
defaultValue={notifSettings.teamsWebhookUrl}
key={notifSettings.teamsWebhookUrl ?? 'teams-empty'}
onKeyDown={e => e.stopPropagation()}
/>
<Form.Text className="text-muted">
URL webhooku MS Teams kanálu. Nechte prázdné pro vypnutí.
</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Události k odběru</Form.Label>
{Object.values(UdalostEnum).map(event => (
<Form.Check
key={event}
id={`notif-event-${event}`}
type="checkbox"
label={event}
checked={enabledEvents.includes(event)}
onChange={() => toggleEvent(event)}
/>
))}
<Form.Text className="text-muted">
Zvolte události, o kterých chcete být notifikováni. Notifikace jsou odesílány pouze uživatelům se stejnou zvolenou lokalitou.
</Form.Text>
</Form.Group>
<hr />
<h4>Bankovní účet</h4>
<p>
Nastavením čísla účtu umožníte automatické generování QR kódů pro úhradu za vámi provedené objednávky v rámci Pizza day.
</p>
<Form.Group className="mb-3">
<Form.Label>Číslo účtu</Form.Label>
<Form.Control
ref={bankAccountRef}
type="text"
placeholder="123456-1234567890/1234"
defaultValue={settings?.bankAccount}
onKeyDown={e => e.stopPropagation()}
/>
<Form.Text className="text-muted">
Pokud vaše číslo účtu neobsahuje předčíslí, je možné ho zcela vynechat.
</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Název příjemce</Form.Label>
<Form.Control
ref={nameRef}
type="text"
placeholder="Jan Novák"
defaultValue={settings?.holderName}
onKeyDown={e => e.stopPropagation()}
/>
<Form.Text className="text-muted">
Jméno majitele účtu pro QR platbu.
</Form.Text>
</Form.Group>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>
Storno
</Button>
<Button onClick={handleSave}>
Uložit
</Button>
</Modal.Footer>
</Modal>
);
}
+16 -6
View File
@@ -1,10 +1,10 @@
import React, { ReactNode, useContext, useState } from "react"
import { useEffect } from "react"
import React, { ReactNode, useContext, useEffect, useState } from "react"
import { useJwt } from "react-jwt";
import { deleteToken, getToken, storeToken } from "../Utils";
export type AuthContextProps = {
login?: string,
trusted?: boolean,
setToken: (name: string) => void,
logout: () => void,
}
@@ -15,7 +15,7 @@ type ContextProps = {
const authContext = React.createContext<AuthContextProps | null>(null);
export function ProvideAuth(props: ContextProps) {
export function ProvideAuth(props: Readonly<ContextProps>) {
const auth = useProvideAuth();
return <authContext.Provider value={auth}>{props.children}</authContext.Provider>
}
@@ -26,8 +26,9 @@ export const useAuth = () => {
function useProvideAuth(): AuthContextProps {
const [loginName, setLoginName] = useState<string | undefined>();
const [token, setToken] = useState<string | null>(getToken());
const { decodedToken } = useJwt(token || '');
const [trusted, setTrusted] = useState<boolean | undefined>();
const [token, setToken] = useState<string | undefined>(getToken());
const { decodedToken } = useJwt(token ?? '');
useEffect(() => {
if (token && token.length > 0) {
@@ -40,18 +41,27 @@ function useProvideAuth(): AuthContextProps {
useEffect(() => {
if (decodedToken) {
setLoginName((decodedToken as any).login);
setTrusted((decodedToken as any).trusted);
} else {
setLoginName(undefined);
setTrusted(undefined);
}
}, [decodedToken]);
function logout() {
setToken(null);
const trusted = (decodedToken as any).trusted;
const logoutUrl = (decodedToken as any).logoutUrl;
setToken(undefined);
setLoginName(undefined);
setTrusted(undefined);
if (trusted && logoutUrl?.length) {
globalThis.location.replace(logoutUrl);
}
}
return {
login: loginName,
trusted,
setToken,
logout,
}
-74
View File
@@ -1,74 +0,0 @@
import React, { ReactNode, useContext, useState } from "react"
import { useEffect } from "react"
const BANK_ACCOUNT_NUMBER_KEY = 'bank_account_number';
const BANK_ACCOUNT_HOLDER_KEY = 'bank_account_holder_name';
export type BankContextProps = {
bankAccount?: string,
holderName?: string,
setBankAccountNumber: (accountNumber?: string) => void,
setBankAccountHolderName: (holderName?: string) => void,
}
type ContextProps = {
children: ReactNode
}
const bankContext = React.createContext<BankContextProps | null>(null);
export function ProvideBank(props: ContextProps) {
const bank = useProvideBank();
return <bankContext.Provider value={bank}>{props.children}</bankContext.Provider>
}
export const useBank = () => {
return useContext(bankContext);
}
function useProvideBank(): BankContextProps {
const [bankAccount, setBankAccount] = useState<string | undefined>();
const [holderName, setHolderName] = useState<string | undefined>();
useEffect(() => {
const accountNumber = localStorage.getItem(BANK_ACCOUNT_NUMBER_KEY);
if (accountNumber) {
setBankAccount(accountNumber);
}
const holderName = localStorage.getItem(BANK_ACCOUNT_HOLDER_KEY);
if (holderName) {
setHolderName(holderName);
}
}, [])
useEffect(() => {
if (bankAccount) {
localStorage.setItem(BANK_ACCOUNT_NUMBER_KEY, bankAccount)
} else {
localStorage.removeItem(BANK_ACCOUNT_NUMBER_KEY);
}
}, [bankAccount]);
useEffect(() => {
if (holderName) {
localStorage.setItem(BANK_ACCOUNT_HOLDER_KEY, holderName);
} else {
localStorage.removeItem(BANK_ACCOUNT_HOLDER_KEY);
}
}, [holderName]);
function setBankAccountNumber(bankAccount?: string) {
setBankAccount(bankAccount);
}
function setBankAccountHolderName(holderName?: string) {
setHolderName(holderName);
}
return {
bankAccount,
holderName,
setBankAccountNumber,
setBankAccountHolderName,
}
}
+23
View File
@@ -0,0 +1,23 @@
import { useEffect, useState } from "react";
import { AuthContextProps } from "./auth";
import { EasterEgg, getEasterEgg } from "../../../types";
export const useEasterEgg = (auth?: AuthContextProps | null): [EasterEgg | undefined, boolean] => {
const [result, setResult] = useState<EasterEgg | undefined>();
const [loading, setLoading] = useState(false);
useEffect(() => {
async function fetchEasterEgg() {
if (auth?.login) {
setLoading(true);
const egg = (await getEasterEgg())?.data;
setResult(egg);
setLoading(false);
}
}
fetchEasterEgg();
}, [auth?.login]);
return [result, loading];
}
+142
View File
@@ -0,0 +1,142 @@
import React, { ReactNode, useContext, useEffect, useState } from "react"
const BANK_ACCOUNT_NUMBER_KEY = 'bank_account_number';
const BANK_ACCOUNT_HOLDER_KEY = 'bank_account_holder_name';
const HIDE_SOUPS_KEY = 'hide_soups';
const THEME_KEY = 'theme_preference';
export type ThemePreference = 'system' | 'light' | 'dark';
export type SettingsContextProps = {
bankAccount?: string,
holderName?: string,
hideSoups?: boolean,
themePreference: ThemePreference,
setBankAccountNumber: (accountNumber?: string) => void,
setBankAccountHolderName: (holderName?: string) => void,
setHideSoupsOption: (hideSoups?: boolean) => void,
setThemePreference: (theme: ThemePreference) => void,
}
type ContextProps = {
children: ReactNode
}
const settingsContext = React.createContext<SettingsContextProps | null>(null);
export function ProvideSettings(props: Readonly<ContextProps>) {
const settings = useProvideSettings();
return <settingsContext.Provider value={settings}>{props.children}</settingsContext.Provider>
}
export const useSettings = () => {
return useContext(settingsContext);
}
function getInitialTheme(): ThemePreference {
try {
const saved = localStorage.getItem(THEME_KEY) as ThemePreference | null;
if (saved && ['system', 'light', 'dark'].includes(saved)) {
return saved;
}
} catch (e) {
// localStorage nedostupný
}
return 'system';
}
function useProvideSettings(): SettingsContextProps {
const [bankAccount, setBankAccount] = useState<string | undefined>();
const [holderName, setHolderName] = useState<string | undefined>();
const [hideSoups, setHideSoups] = useState<boolean | undefined>();
const [themePreference, setTheme] = useState<ThemePreference>(getInitialTheme);
useEffect(() => {
const accountNumber = localStorage.getItem(BANK_ACCOUNT_NUMBER_KEY);
if (accountNumber) {
setBankAccount(accountNumber);
}
const holderName = localStorage.getItem(BANK_ACCOUNT_HOLDER_KEY);
if (holderName) {
setHolderName(holderName);
}
const hideSoups = localStorage.getItem(HIDE_SOUPS_KEY);
if (hideSoups !== null) {
setHideSoups(hideSoups === 'true');
}
}, [])
useEffect(() => {
if (bankAccount) {
localStorage.setItem(BANK_ACCOUNT_NUMBER_KEY, bankAccount)
} else {
localStorage.removeItem(BANK_ACCOUNT_NUMBER_KEY);
}
}, [bankAccount]);
useEffect(() => {
if (holderName) {
localStorage.setItem(BANK_ACCOUNT_HOLDER_KEY, holderName);
} else {
localStorage.removeItem(BANK_ACCOUNT_HOLDER_KEY);
}
}, [holderName]);
useEffect(() => {
if (hideSoups) {
localStorage.setItem(HIDE_SOUPS_KEY, hideSoups ? 'true' : 'false');
} else {
localStorage.removeItem(HIDE_SOUPS_KEY);
}
}, [hideSoups]);
useEffect(() => {
localStorage.setItem(THEME_KEY, themePreference);
}, [themePreference]);
useEffect(() => {
const applyTheme = (theme: 'light' | 'dark') => {
document.documentElement.setAttribute('data-bs-theme', theme);
};
if (themePreference === 'system') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
applyTheme(mediaQuery.matches ? 'dark' : 'light');
const handler = (e: MediaQueryListEvent) => {
applyTheme(e.matches ? 'dark' : 'light');
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
} else {
applyTheme(themePreference);
}
}, [themePreference]);
function setBankAccountNumber(bankAccount?: string) {
setBankAccount(bankAccount);
}
function setBankAccountHolderName(holderName?: string) {
setHolderName(holderName);
}
function setHideSoupsOption(hideSoups?: boolean) {
setHideSoups(hideSoups);
}
function setThemePreference(theme: ThemePreference) {
setTheme(theme);
}
return {
bankAccount,
holderName,
hideSoups,
themePreference,
setBankAccountNumber,
setBankAccountHolderName,
setHideSoupsOption,
setThemePreference,
}
}
+11 -8
View File
@@ -1,17 +1,20 @@
import React from 'react';
import socketio from "socket.io-client";
import { getBaseUrl } from "../Utils";
// Záměrně omezeno jen na websocket, aby se případně odhalilo chybné nastavení proxy serveru
export const socket = socketio.connect(getBaseUrl(), { transports: ["websocket"] });
let socketUrl;
let socketPath;
if (process.env.NODE_ENV === 'development') {
socketUrl = `http://localhost:3001`;
socketPath = undefined;
} else {
socketUrl = `${globalThis.location.host}`;
socketPath = `${globalThis.location.pathname}socket.io`;
}
export const socket = socketio.connect(socketUrl, { path: socketPath, transports: ["websocket"] });
export const SocketContext = React.createContext();
// Konstanty websocket eventů, musí odpovídat těm na serveru!
export const EVENT_CONNECT = 'connect';
export const EVENT_DISCONNECT = 'disconnect';
export const EVENT_MESSAGE = 'message';
// export const EVENT_CONFIG = 'config';
// export const EVENT_TOASTER = 'toaster';
// export const EVENT_VOTING = 'voting';
// export const EVENT_VOTE_CONFIG = 'voteSettings';
// export const EVENT_ADMIN = 'admin';
+41
View File
@@ -0,0 +1,41 @@
import { LunchChoice, Restaurant } from "../../types";
export function getRestaurantName(restaurant: Restaurant) {
switch (restaurant) {
case Restaurant.SLADOVNICKA:
return "Sladovnická";
case Restaurant.TECHTOWER:
return "TechTower";
case Restaurant.ZASTAVKAUMICHALA:
return "Zastávka u Michala";
case Restaurant.SENKSERIKOVA:
return "Šenk Šeříková";
default:
return restaurant;
}
}
export function getLunchChoiceName(location: LunchChoice) {
switch (location) {
case Restaurant.SLADOVNICKA:
return "Sladovnická";
case Restaurant.TECHTOWER:
return "TechTower";
case Restaurant.ZASTAVKAUMICHALA:
return "Zastávka u Michala";
case Restaurant.SENKSERIKOVA:
return "Šenk Šeříková";
case LunchChoice.SPSE:
return "SPŠE";
case LunchChoice.PIZZA:
return "Pizza day";
case LunchChoice.OBJEDNAVAM:
return "Budu objednávat";
case LunchChoice.NEOBEDVAM:
return "Mám vlastní/neobědvám";
case LunchChoice.ROZHODUJI:
return "Rozhoduji se";
default:
return location;
}
}
+108
View File
@@ -0,0 +1,108 @@
import { getToken } from '../Utils';
/** Převede base64url VAPID klíč na Uint8Array pro PushManager. */
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
/** Helper pro autorizované API volání na push endpointy. */
async function pushApiFetch(path: string, options: RequestInit = {}): Promise<Response> {
const token = getToken();
return fetch(`/api/notifications/push${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
}
/**
* Zaregistruje service worker, přihlásí se k push notifikacím
* a odešle subscription na server.
*/
export async function subscribeToPush(reminderTime: string): Promise<boolean> {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
console.warn('Push notifikace nejsou v tomto prohlížeči podporovány');
return false;
}
try {
// Registrace service workeru
const registration = await navigator.serviceWorker.register('/sw.js');
await navigator.serviceWorker.ready;
// Vyžádání oprávnění
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
console.warn('Push notifikace: oprávnění zamítnuto');
return false;
}
// Získání VAPID veřejného klíče ze serveru
const vapidResponse = await pushApiFetch('/vapidKey');
if (!vapidResponse.ok) {
console.error('Push notifikace: nepodařilo se získat VAPID klíč');
return false;
}
const { key: vapidPublicKey } = await vapidResponse.json();
// Přihlášení k push
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey) as BufferSource,
});
// Odeslání subscription na server
const response = await pushApiFetch('/subscribe', {
method: 'POST',
body: JSON.stringify({
subscription: subscription.toJSON(),
reminderTime,
}),
});
if (!response.ok) {
console.error('Push notifikace: nepodařilo se odeslat subscription na server');
return false;
}
console.log('Push notifikace: úspěšně přihlášeno k připomínkám v', reminderTime);
return true;
} catch (error) {
console.error('Push notifikace: chyba při registraci', error);
return false;
}
}
/**
* Odhlásí se z push notifikací a informuje server.
*/
export async function unsubscribeFromPush(): Promise<void> {
if (!('serviceWorker' in navigator)) {
return;
}
try {
const registration = await navigator.serviceWorker.getRegistration('/sw.js');
if (registration) {
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await subscription.unsubscribe();
}
}
await pushApiFetch('/unsubscribe', { method: 'POST' });
console.log('Push notifikace: úspěšně odhlášeno z připomínek');
} catch (error) {
console.error('Push notifikace: chyba při odhlášení', error);
}
}
+19 -1
View File
@@ -7,14 +7,32 @@ body,
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: 1.5;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
/* Smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Better focus styles */
:focus-visible {
outline: 2px solid var(--luncher-primary);
outline-offset: 2px;
}
/* Selection color */
::selection {
background: var(--luncher-primary-light);
color: var(--luncher-primary);
}
+23 -10
View File
@@ -1,25 +1,38 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { SocketContext, socket } from './context/socket';
import { ProvideAuth } from './context/auth';
import { ToastContainer } from 'react-toastify';
import { ProvideBank } from './context/bank';
import 'react-toastify/dist/ReactToastify.css';
import './index.css';
import AppRoutes from './AppRoutes';
import { BrowserRouter } from 'react-router';
import { client } from '../../types/gen/client.gen';
import { getToken } from './Utils';
import { toast } from 'react-toastify';
client.setConfig({
auth: () => getToken(),
baseUrl: '/api', // openapi-ts si to z nějakého důvodu neumí převzít z api.yml
});
// Interceptor na vyhození toasteru při chybě
client.interceptors.response.use(async response => {
// TODO opravit - login je zatím výjimka, voláme ho "naprázdno" abychom zjistili, zda nás nepřihlásily trusted headers
if (!response.ok && !response.url.includes("/login")) {
const json = await response.json();
toast.error(json.error, { theme: "colored" });
}
return response;
});
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<BrowserRouter>
<ProvideAuth>
<ProvideBank>
<SocketContext.Provider value={socket}>
<App />
<ToastContainer />
</SocketContext.Provider>
</ProvideBank>
<AppRoutes />
</ProvideAuth>
</BrowserRouter>
</React.StrictMode>
);
+155
View File
@@ -0,0 +1,155 @@
.stats-page {
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 24px;
min-height: calc(100vh - 140px);
background: var(--luncher-bg);
h1 {
font-size: 2rem;
font-weight: 700;
color: var(--luncher-text);
margin-bottom: 24px;
}
.week-navigator {
display: flex;
align-items: center;
gap: 24px;
margin-bottom: 32px;
svg {
font-size: 1.5rem;
color: var(--luncher-text-secondary);
cursor: pointer;
padding: 12px;
border-radius: 50%;
background: var(--luncher-bg-card);
box-shadow: var(--luncher-shadow-sm);
transition: var(--luncher-transition);
&:hover {
color: var(--luncher-primary);
background: var(--luncher-primary-light);
transform: scale(1.05);
}
}
.date-range {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--luncher-text);
min-width: 280px;
text-align: center;
}
}
// Chart container
.recharts-wrapper {
background: var(--luncher-bg-card);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
padding: 24px;
border: 1px solid var(--luncher-border-light);
}
// Chart text styling
.recharts-cartesian-axis-tick-value {
fill: var(--luncher-text-secondary);
font-size: 0.85rem;
}
.recharts-legend-item-text {
color: var(--luncher-text) !important;
font-weight: 500;
}
.recharts-tooltip-wrapper {
.recharts-default-tooltip {
background: var(--luncher-bg-card) !important;
border: 1px solid var(--luncher-border) !important;
border-radius: var(--luncher-radius-sm) !important;
box-shadow: var(--luncher-shadow-lg) !important;
.recharts-tooltip-label {
color: var(--luncher-text) !important;
font-weight: 600;
margin-bottom: 8px;
}
.recharts-tooltip-item {
color: var(--luncher-text-secondary) !important;
}
}
}
.recharts-cartesian-grid-horizontal line,
.recharts-cartesian-grid-vertical line {
stroke: var(--luncher-border);
}
.voting-stats-section {
margin-top: 48px;
width: 100%;
max-width: 800px;
h2 {
font-size: 1.5rem;
font-weight: 700;
color: var(--luncher-text);
margin-bottom: 16px;
text-align: center;
}
}
.voting-stats-table {
width: 100%;
background: var(--luncher-bg-card);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
border: 1px solid var(--luncher-border-light);
overflow: hidden;
border-collapse: collapse;
th {
background: var(--luncher-primary);
color: #ffffff;
padding: 12px 20px;
text-align: left;
font-weight: 600;
font-size: 0.9rem;
&:last-child {
text-align: center;
width: 120px;
}
}
td {
padding: 12px 20px;
border-bottom: 1px solid var(--luncher-border-light);
color: var(--luncher-text);
font-size: 0.9rem;
&:last-child {
text-align: center;
font-weight: 600;
color: var(--luncher-primary);
}
}
tbody tr {
transition: var(--luncher-transition);
&:hover {
background: var(--luncher-bg-hover);
}
&:last-child td {
border-bottom: none;
}
}
}
}
+170
View File
@@ -0,0 +1,170 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import Footer from "../components/Footer";
import Header from "../components/Header";
import { useAuth } from "../context/auth";
import Login from "../Login";
import { formatDate, getFirstWorkDayOfWeek, getHumanDate, getLastWorkDayOfWeek } from "../Utils";
import { WeeklyStats, LunchChoice, VotingStats, FeatureRequest, getStats, getVotingStats } from "../../../types";
import Loader from "../components/Loader";
import { faChevronLeft, faChevronRight, faGear } from "@fortawesome/free-solid-svg-icons";
import { Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { getLunchChoiceName } from "../enums";
import './StatsPage.scss';
const CHART_WIDTH = 1400;
const CHART_HEIGHT = 700;
const STROKE_WIDTH = 2.5;
const COLORS = [
'#ff1493',
'#1e90ff',
'#c5a700',
'#006400',
'#b300ff',
'#ff4500',
'#bc8f8f',
'#00ff00',
'#7c7c7c',
]
export default function StatsPage() {
const auth = useAuth();
const [dateRange, setDateRange] = useState<Date[]>();
const [data, setData] = useState<WeeklyStats>();
const [votingStats, setVotingStats] = useState<VotingStats>();
// Prvotní nastavení aktuálního týdne
useEffect(() => {
const today = new Date();
setDateRange([getFirstWorkDayOfWeek(today), getLastWorkDayOfWeek(today)]);
}, []);
// Přenačtení pro zvolený týden
useEffect(() => {
if (dateRange) {
getStats({ query: { startDate: formatDate(dateRange[0]), endDate: formatDate(dateRange[1]) } }).then(response => {
setData(response.data);
});
}
}, [dateRange]);
// Načtení statistik hlasování
useEffect(() => {
getVotingStats().then(response => {
setVotingStats(response.data);
});
}, []);
const sortedVotingStats = useMemo(() => {
if (!votingStats) return [];
return Object.entries(votingStats)
.sort((a, b) => (b[1] as number) - (a[1] as number));
}, [votingStats]);
const renderLine = (location: LunchChoice) => {
const index = Object.values(LunchChoice).indexOf(location);
return <Line key={location} name={getLunchChoiceName(location)} type="monotone" dataKey={data => data.locations[location] ?? 0} stroke={COLORS[index]} strokeWidth={STROKE_WIDTH} />
}
const handlePreviousWeek = () => {
if (dateRange) {
const previousStartDate = new Date(dateRange[0]);
previousStartDate.setDate(previousStartDate.getDate() - 7);
const previousEndDate = new Date(previousStartDate);
previousEndDate.setDate(previousEndDate.getDate() + 4);
setDateRange([previousStartDate, previousEndDate]);
}
}
const handleNextWeek = () => {
if (dateRange) {
const nextStartDate = new Date(dateRange[0]);
nextStartDate.setDate(nextStartDate.getDate() + 7);
const nextEndDate = new Date(nextStartDate);
nextEndDate.setDate(nextEndDate.getDate() + 4);
setDateRange([nextStartDate, nextEndDate]);
}
}
const isCurrentOrFutureWeek = useMemo(() => {
if (!dateRange) return true;
const currentWeekEnd = getLastWorkDayOfWeek(new Date());
currentWeekEnd.setHours(23, 59, 59, 999);
return dateRange[1] >= currentWeekEnd;
}, [dateRange]);
const handleKeyDown = useCallback((e: any) => {
if (e.keyCode === 37) {
handlePreviousWeek();
} else if (e.keyCode === 39 && !isCurrentOrFutureWeek) {
handleNextWeek()
}
}, [dateRange, isCurrentOrFutureWeek]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
}
}, [handleKeyDown]);
if (!auth?.login) {
return <Login />;
}
if (!dateRange) {
return <Loader
icon={faGear}
description={'Načítám data...'}
animation={'fa-bounce'}
/>
}
return (
<>
<Header />
<div className="stats-page">
<h1>Statistiky</h1>
<div className="week-navigator">
<span title="Předchozí týden">
<FontAwesomeIcon icon={faChevronLeft} style={{ cursor: "pointer" }} onClick={handlePreviousWeek} />
</span>
<h2 className="date-range">{getHumanDate(dateRange[0])} - {getHumanDate(dateRange[1])}</h2>
<span title="Následující týden">
<FontAwesomeIcon icon={faChevronRight} style={{ cursor: "pointer", visibility: isCurrentOrFutureWeek ? "hidden" : "visible" }} onClick={handleNextWeek} />
</span>
</div>
<LineChart width={CHART_WIDTH} height={CHART_HEIGHT} data={data}>
{Object.values(LunchChoice).map(location => renderLine(location))}
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Legend />
</LineChart>
{sortedVotingStats.length > 0 && (
<div className="voting-stats-section">
<h2>Hlasování o funkcích</h2>
<table className="voting-stats-table">
<thead>
<tr>
<th>Funkce</th>
<th>Počet hlasů</th>
</tr>
</thead>
<tbody>
{sortedVotingStats.map(([feature, count]) => (
<tr key={feature}>
<td>{FeatureRequest[feature as keyof typeof FeatureRequest] ?? feature}</td>
<td>{count as number}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<Footer />
</>
);
}
-1
View File
@@ -1 +0,0 @@
/// <reference types="react-scripts" />
+11
View File
@@ -0,0 +1,11 @@
/**
* Parsuje cenu ve formátu "135 Kč", "135,50 Kč" nebo "135.50 Kč" na číslo.
* Vrátí null při selhání.
*/
export function parsePriceCzk(raw: string | undefined): number | null {
if (!raw) return null;
const m = raw.replace(',', '.').match(/(\d+(?:\.\d+)?)/);
if (!m) return null;
const n = parseFloat(m[1]);
return Number.isFinite(n) ? n : null;
}
+8 -7
View File
@@ -1,11 +1,13 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"types": [
"vite/client"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
@@ -13,14 +15,13 @@
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"moduleResolution": "bundler",
"module": "ESNext",
"target": "ESNext",
"resolveJsonModule": true,
"isolatedModules": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"client/src"
]
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import viteTsconfigPaths from 'vite-tsconfig-paths'
export default defineConfig({
// depending on your application, base can also be "/"
base: '',
plugins: [react(), viteTsconfigPaths()],
server: {
open: true,
port: 3000,
proxy: {
'/api': 'http://localhost:3001',
}
},
})
+1488 -9201
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
services:
redis:
image: redis/redis-stack-server:7.2.0-RC3
restart: always
ports:
- '6379:6379'
#expose:
# - 6379
environment:
- REDIS_ARGS=--save 3600 1 --loglevel warning
volumes:
- redis:/data
luncher:
depends_on:
- redis
restart: always
build:
context: ./
ports:
- 3001:3001
environment:
- TZ=Europe/Prague
volumes:
- "/etc/timezone:/etc/timezone:ro"
volumes:
redis:
driver: local
-18
View File
@@ -1,18 +0,0 @@
version: '3.8'
services:
server:
build:
context: ./server
client:
build:
context: ./client
nginx:
depends_on:
- server
- client
restart: always
build:
context: ./nginx
ports:
- 3005:80
+3
View File
@@ -0,0 +1,3 @@
node_modules/
playwright-report/
test-results/
+16
View File
@@ -0,0 +1,16 @@
{
"name": "@luncher/e2e",
"version": "1.0.0",
"private": true,
"scripts": {
"test": "playwright test",
"test:ui": "playwright test --ui",
"test:debug": "playwright test --debug",
"report": "playwright show-report"
},
"devDependencies": {
"@playwright/test": "^1.50.0",
"@types/node": "^22.0.0",
"typescript": "^5.9.3"
}
}
+60
View File
@@ -0,0 +1,60 @@
import { defineConfig, devices } from '@playwright/test';
import path from 'path';
// Use 127.0.0.1 explicitly — on Node.js 18+/Windows, `localhost` may resolve to ::1
// (IPv6) while the HTTP server only binds to 0.0.0.0 (IPv4), causing the webServer
// readiness poll to time out even though the server is listening.
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3001';
// Server env vars injected for local runs. In CI these are set at the step level.
const serverEnv: Record<string, string> = {
NODE_ENV: 'test',
MOCK_DATA: 'true',
STORAGE: process.env.STORAGE ?? 'json',
JWT_SECRET: process.env.JWT_SECRET ?? 'e2e-test-secret-min-32-chars-aaaa',
HTTP_REMOTE_USER_ENABLED: 'true',
HTTP_REMOTE_USER_HEADER_NAME: 'remote-user',
HTTP_REMOTE_TRUSTED_IPS: process.env.HTTP_REMOTE_TRUSTED_IPS ?? '127.0.0.1,::1,::ffff:127.0.0.1',
};
if (process.env.REDIS_HOST) {
serverEnv.REDIS_HOST = process.env.REDIS_HOST;
serverEnv.REDIS_PORT = process.env.REDIS_PORT ?? '6379';
}
export default defineConfig({
testDir: './tests',
timeout: 30_000,
retries: process.env.CI ? 1 : 0,
workers: 1,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: BASE_URL,
// Default: every test authenticates as e2e-user via trusted header.
// Tests that need the real login form should override this in their own context.
extraHTTPHeaders: {
'remote-user': 'e2e-user',
},
trace: 'retain-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
],
// Pre-built server must be started before tests. In CI the step does this
// explicitly. Locally: build types+server+client, cp -r client/dist server/public,
// then `cd e2e && yarn test` OR let webServer below do it if reuseExistingServer=true
// is set and the server is already running.
webServer: {
command: 'node dist/server/src/index.js',
cwd: path.resolve(__dirname, '../server'),
// Poll a dedicated health endpoint — polling '/' can stall in Express 5 when
// server/public/ doesn't exist in the working directory (no finalhandler match).
url: `http://127.0.0.1:3001/api/health`,
timeout: 15_000,
reuseExistingServer: !process.env.CI,
env: serverEnv,
stdout: 'pipe',
stderr: 'pipe',
},
});
+21
View File
@@ -0,0 +1,21 @@
import { Page, APIRequestContext } from '@playwright/test';
/** Přihlásí uživatele přes POST /api/login a uloží JWT do localStorage. */
export async function loginViaApi(page: Page, login: string): Promise<void> {
const response = await page.request.post('/api/login', {
headers: { 'Content-Type': 'application/json', 'remote-user': login },
data: {},
});
const token = await response.json() as string;
await page.goto('/');
await page.evaluate((t) => localStorage.setItem('token', t), token);
}
/** Vyčistí stav pizza dne pro zadaný dayIndex (0=pondělí…4=pátek) přes dev API. */
export async function clearPizzaDay(request: APIRequestContext): Promise<void> {
const today = new Date('2025-01-10'); // MOCK_DATA pins to Friday = dayIndex 4
await request.post('/api/dev/clear', {
headers: { 'Content-Type': 'application/json', 'remote-user': 'e2e-user' },
data: { dayIndex: 4 },
});
}
+50
View File
@@ -0,0 +1,50 @@
import { test, expect } from '@playwright/test';
// Tento test záměrně NEPOUŽÍVÁ trusted-header testuje reálný login formulář.
test.use({ extraHTTPHeaders: {} });
test('uživatel se přihlásí formulářem a uvidí obsah aplikace', async ({ page }) => {
// Server běží s HTTP_REMOTE_USER_ENABLED=true, takže POST /api/login vždy vyžaduje
// hlavičku remote-user. Zachytíme požadavky z formuláře (mají tělo s polem login)
// a přidáme hlavičku; požadavek auto-loginu (bez těla) projde bez hlavičky a selže,
// čímž formulář zůstane viditelný.
await page.route('**/api/login', async (route) => {
const body = route.request().postData();
let login: string | undefined;
try { login = body ? JSON.parse(body)?.login : undefined; } catch {}
await route.continue({
headers: login
? { ...route.request().headers(), 'remote-user': login }
: route.request().headers(),
});
});
await page.goto('/');
// Formulář musí být viditelný auto-login selhal (nepřišla hlavička)
const loginInput = page.locator('#login-input');
await expect(loginInput).toBeVisible({ timeout: 10_000 });
// Vyplnění loginu a odeslání Enterem
await loginInput.fill('testuser');
await loginInput.press('Enter');
// Po přihlášení musí zmizet login formulář
await expect(loginInput).not.toBeVisible({ timeout: 10_000 });
// JWT musí být uloženo v localStorage jako 3-dílný token
const token = await page.evaluate(() => localStorage.getItem('token'));
expect(token).toBeTruthy();
expect((token as string).split('.')).toHaveLength(3);
});
test('trusted-header přihlášení proběhne automaticky bez formuláře', async ({ page, context }) => {
// Obnoví trusted header (přepíše prázdný extraHTTPHeaders z test.use výše)
await context.setExtraHTTPHeaders({ 'remote-user': 'e2e-auto-user' });
await page.goto('/');
// Login formulář by se neměl nikdy zobrazit, nebo se ihned schová
await page.waitForLoadState('networkidle');
const loginInput = page.locator('#login-input');
await expect(loginInput).not.toBeVisible({ timeout: 5_000 });
});
+70
View File
@@ -0,0 +1,70 @@
import { test, expect } from '@playwright/test';
import { clearPizzaDay } from './helpers';
test.beforeEach(async ({ page, request }) => {
// Vyčistíme volby dne, aby testy neovlivnily navzájem
await request.post('/api/dev/clear', {
data: { dayIndex: 4 },
});
await page.goto('/');
await page.waitForLoadState('networkidle');
// Počkáme, až se zobrazí volba stravování
await expect(page.locator('.choice-section select').first()).toBeVisible({ timeout: 10_000 });
});
test('výběr restaurace zobrazí seznam jídel', async ({ page }) => {
const locationSelect = page.locator('.choice-section select').first();
// Vybereme Sladovnickou mock menu existuje
await locationSelect.selectOption('SLADOVNICKA');
// Po výběru restaurace se zobrazí druhý select s jídly
const foodSelect = page.locator('.choice-section select').nth(1);
await expect(foodSelect).toBeVisible({ timeout: 5_000 });
// Select musí obsahovat alespoň 2 možnosti (empty + ≥1 jídlo)
const options = foodSelect.locator('option');
expect(await options.count()).toBeGreaterThan(1);
});
test('výběr jídla se uloží a přetrvá po reload', async ({ page }) => {
const locationSelect = page.locator('.choice-section select').first();
await locationSelect.selectOption('SLADOVNICKA');
const foodSelect = page.locator('.choice-section select').nth(1);
await expect(foodSelect).toBeVisible({ timeout: 5_000 });
// Vybereme první nenulovou možnost
const options = await foodSelect.locator('option:not([value=""])').all();
if (options.length === 0) {
test.skip(); // Mock data nejsou dostupná pro tuto restauraci
}
const firstValue = await options[0].getAttribute('value');
await foodSelect.selectOption({ value: firstValue! });
// Počkáme, až se volba přenese na server
await page.waitForLoadState('networkidle');
// Po reload musí volba přetrvat v tabulce choices
await page.reload();
await page.waitForLoadState('networkidle');
const choicesTable = page.locator('.choices-table');
await expect(choicesTable).toBeVisible({ timeout: 5_000 });
await expect(choicesTable.locator('text=Sladovnická')).toBeVisible();
});
test('přepnutí na NEOBEDVAM odstraní výběr restaurace', async ({ page }) => {
// Nejprve zvolíme restauraci
const locationSelect = page.locator('.choice-section select').first();
await locationSelect.selectOption('SLADOVNICKA');
await page.waitForLoadState('networkidle');
// Přepneme na "Neobědvám"
await locationSelect.selectOption('NEOBEDVAM');
await page.waitForLoadState('networkidle');
// Tabulka choices musí zobrazovat "Neobědvám"
const choicesTable = page.locator('.choices-table');
await expect(choicesTable).toBeVisible({ timeout: 5_000 });
await expect(choicesTable.locator('text=Neobědvám')).toBeVisible();
});
+65
View File
@@ -0,0 +1,65 @@
import { test, expect } from '@playwright/test';
// Pizza day testy musí běžet sekvenčně (sdílejí stav mock dne)
test.describe.serial('pizza day životní cyklus', () => {
test.beforeEach(async ({ request }) => {
// Vyčistíme data mock dne před každým testem
await request.post('/api/dev/clear', { data: { dayIndex: 4 } });
});
test('zobrazí sekci Pizza Day bez aktivního dne', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
const pizzaSection = page.locator('.pizza-section');
await expect(pizzaSection).toBeVisible({ timeout: 10_000 });
await expect(pizzaSection.locator('text=není aktuálně založen')).toBeVisible();
});
test('vytvoří, uzamkne a dokončí pizza day', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
// --- CREATED ---
const createBtn = page.locator('.pizza-section button', { hasText: 'Založit Pizza day' });
await expect(createBtn).toBeVisible({ timeout: 10_000 });
await createBtn.click();
await page.waitForLoadState('networkidle');
await expect(page.locator('.pizza-section')).toContainText('spravován uživatelem', { timeout: 5_000 });
// Přidáme pizzu přes API (obejde komplex SelectSearch)
const token = await page.evaluate(() => localStorage.getItem('token'));
const addResp = await page.request.post('/api/pizza/add', {
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { pizzaIndex: 0, pizzaSizeIndex: 0 },
});
expect(addResp.ok()).toBeTruthy();
// Reload server aktualizoval data přes WebSocket, ale reload je jistější
await page.reload();
await page.waitForLoadState('networkidle');
// --- LOCK ---
const lockBtn = page.locator('.pizza-section button', { hasText: 'Uzamknout' });
await expect(lockBtn).toBeEnabled({ timeout: 5_000 });
await lockBtn.click();
await page.waitForLoadState('networkidle');
await expect(page.locator('.pizza-section')).toContainText('uzamčeny', { timeout: 5_000 });
// --- ORDERED ---
const orderBtn = page.locator('.pizza-section button', { hasText: 'Objednáno' });
await expect(orderBtn).toBeEnabled({ timeout: 5_000 });
await orderBtn.click();
await page.waitForLoadState('networkidle');
await expect(page.locator('.pizza-section')).toContainText('objednány', { timeout: 5_000 });
// --- DELIVERED ---
const deliverBtn = page.locator('.pizza-section button', { hasText: 'Doručeno' });
await expect(deliverBtn).toBeVisible({ timeout: 5_000 });
// window.confirm dialog Playwright automaticky potvrdí
page.on('dialog', dialog => dialog.accept());
await deliverBtn.click();
await page.waitForLoadState('networkidle');
await expect(page.locator('.pizza-section')).toContainText('doručeny', { timeout: 5_000 });
});
});
+77
View File
@@ -0,0 +1,77 @@
import { test, expect } from '@playwright/test';
test.beforeEach(async ({ page, request }) => {
// Naseedujeme 5 uživatelů pro dnešní den GenerateQrModal pracuje se stávajícími choices
await request.post('/api/dev/generate', { data: { dayIndex: 4, count: 5 } });
// Přednastavíme bankovní účet v localStorage (SettingsContext čte z LS při inicializaci)
await page.goto('/');
await page.evaluate(() => {
localStorage.setItem('bank_account_number', '2400000000/2010');
localStorage.setItem('bank_account_holder_name', 'Test User');
});
// Reload tak, aby SettingsContext načetl nové hodnoty z localStorage
await page.reload();
await page.waitForLoadState('networkidle');
});
test('Nastavení ukládají číslo účtu a jméno do localStorage', async ({ page }) => {
// Otevření nastavení
await page.locator('#basic-nav-dropdown').click();
await page.locator('text=Nastavení').click();
// Modal musí být viditelný
await expect(page.locator('.modal-title')).toContainText('Nastavení', { timeout: 5_000 });
// Změníme číslo účtu
const accountInput = page.getByPlaceholder('123456-1234567890/1234');
await accountInput.clear();
await accountInput.fill('1234567890/5500');
// Změníme jméno
const nameInput = page.getByPlaceholder('Jan Novák');
await nameInput.clear();
await nameInput.fill('Nové Jméno');
// Uložíme
await page.locator('.modal-footer button', { hasText: 'Uložit' }).click();
// Ověříme v localStorage
const bankAccount = await page.evaluate(() => localStorage.getItem('bank_account_number'));
const holderName = await page.evaluate(() => localStorage.getItem('bank_account_holder_name'));
expect(bankAccount).toBe('1234567890/5500');
expect(holderName).toBe('Nové Jméno');
});
test('otevře modal Generování QR kódů pokud je nastaven účet', async ({ page }) => {
// Otevření dropdown menu
await page.locator('#basic-nav-dropdown').click();
await page.locator('text=Generování QR kódů').click();
// Modal se otevře
await expect(page.locator('.modal')).toBeVisible({ timeout: 5_000 });
// Modal musí obsahovat seznam uživatelů nebo prázdný stav
await expect(page.locator('.modal-body')).toBeVisible();
});
test('upozorní pokud není nastaven bankovní účet', async ({ page }) => {
// Odebereme nastavení účtu
await page.evaluate(() => {
localStorage.removeItem('bank_account_number');
localStorage.removeItem('bank_account_holder_name');
});
await page.reload();
await page.waitForLoadState('networkidle');
// Dialog místo modalu
page.on('dialog', async dialog => {
expect(dialog.message()).toContain('číslo účtu');
await dialog.accept();
});
await page.locator('#basic-nav-dropdown').click();
await page.locator('text=Generování QR kódů').click();
// Modal se NESMÍ otevřít
await expect(page.locator('.modal')).not.toBeVisible({ timeout: 3_000 });
});
+39
View File
@@ -0,0 +1,39 @@
import { test, expect } from '@playwright/test';
test.beforeEach(async ({ page }) => {
// Trusted-header login runs automatically when Login mounts.
// networkidle zaručí, že fetch('/api/data') byl dokončen.
await page.goto('/');
await page.waitForLoadState('networkidle');
});
test('zobrazí mock datum 10.01.2025', async ({ page }) => {
// MOCK_DATA=true pins today to 2025-01-10
await expect(page.locator('text=10.01')).toBeVisible({ timeout: 10_000 });
});
test('zobrazí čtyři restaurační karty z mock dat', async ({ page }) => {
// Každá restaurace je obalena v .restaurant-card
const cards = page.locator('.restaurant-card');
await expect(cards).toHaveCount(4, { timeout: 10_000 });
});
test('zobrazí alespoň jedno jídlo v menu každé restaurace', async ({ page }) => {
await expect(page.locator('.restaurant-card').first()).toBeVisible({ timeout: 10_000 });
// Každá karta musí mít aspoň jeden řádek v .food-table
const cards = page.locator('.restaurant-card');
const count = await cards.count();
for (let i = 0; i < count; i++) {
const card = cards.nth(i);
const rows = card.locator('.food-table tr');
expect(await rows.count()).toBeGreaterThan(0);
}
});
test('zobrazí volbu stravování před menu', async ({ page }) => {
// Sekce .choice-section obsahuje select pro výběr stravování
const choiceSection = page.locator('.choice-section');
await expect(choiceSection).toBeVisible({ timeout: 10_000 });
await expect(choiceSection.locator('select').first()).toBeVisible();
});
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true
},
"include": ["**/*.ts"]
}
+46
View File
@@ -0,0 +1,46 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@playwright/test@^1.50.0":
version "1.59.1"
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.59.1.tgz#5c4d38eac84a61527af466602ae20277685a02d6"
integrity sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==
dependencies:
playwright "1.59.1"
"@types/node@^22.0.0":
version "22.19.17"
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.19.17.tgz#09c71fb34ba2510f8ac865361b1fcb9552b8a581"
integrity sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==
dependencies:
undici-types "~6.21.0"
fsevents@2.3.2:
version "2.3.2"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
playwright-core@1.59.1:
version "1.59.1"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.59.1.tgz#d8a2b28bcb8f2bd08ef3df93b02ae83c813244b2"
integrity sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==
playwright@1.59.1:
version "1.59.1"
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.59.1.tgz#f7b0ca61637ae25264cec370df671bbe1f368a4a"
integrity sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==
dependencies:
playwright-core "1.59.1"
optionalDependencies:
fsevents "2.3.2"
typescript@^5.9.3:
version "5.9.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f"
integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
undici-types@~6.21.0:
version "6.21.0"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb"
integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==
-2
View File
@@ -1,2 +0,0 @@
FROM nginx
COPY ./default.conf /etc/nginx/conf.d/default.conf
-37
View File
@@ -1,37 +0,0 @@
upstream client {
server client:3000;
}
upstream server {
server server:3001;
}
server {
listen 80;
location / {
proxy_pass http://client;
}
location /static {
proxy_pass http://client;
}
location /sockjs-node {
proxy_pass http://client;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}
location /socket.io {
proxy_pass http://server;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}
location /api {
proxy_pass http://server;
}
}
+18 -4
View File
@@ -1,4 +1,18 @@
export NODE_ENV=development
cd server && yarn install && yarn start &
cd client && yarn install && yarn start &
wait
#!/bin/bash
# Spustí server a klienta v samostatných panelech uvnitř stejného tmux okna.
# Pokud už daná tmux session existuje, pouze se k ní připojí.
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
SESSION="luncher"
if ! tmux has-session -t $SESSION 2>/dev/null; then
cd types && yarn openapi-ts && cd ..
tmux new-session -d -s $SESSION
tmux send-keys -t $SESSION:0 "cd $SCRIPT_DIR" Enter
tmux split-window -v
tmux send-keys -t $SESSION:0.0 "cd server && export NODE_ENV=development && yarn startReload" Enter
tmux send-keys -t $SESSION:0.1 "cd client && yarn start" Enter
fi
tmux attach-session -t $SESSION
+37
View File
@@ -1,6 +1,19 @@
# Secret pro podepisování JWT tokenů. Minimální délka 32 znaků.
# JWT_SECRET='CHANGE_ME'
# URL pro externí odhlášení, kam bude uživatel při odhlášení přesměrován pokud byl přihlášen pomocí Trusted Headers.
# LOGOUT_URL='https://auth.example.com/logout'
# Datové úložiště. Musí být 'json' nebo 'redis' (není case sensitive).
# json - Data jsou ukládána do JSON souboru. Pomalé (práce se souborem), ale vhodné pro vývoj (snadnější prohlížení dat).
# redis - Data jsou ukládána v Redis serveru. Dle potřeby může být nutné upravit REDIS_ proměnné viz dále.
# STORAGE='json'
# Hostname/IP Redis serveru, pokud je použit STORAGE='redis'. Výchozí hodnota je 'localhost'.
# REDIS_HOST='localhost'
# Port Redis serveru, pokud je použit STORAGE='redis', výchozí hodnota je 6379.
# REDIS_PORT=6379
# Zapne režim mockování obědových menu.
# Vhodné pro vývoj o víkendech, svátcích a dalších dnech, pro které podniky nenabízejí obědové menu.
# V tomto režimu vrací server vždy falešné datum (pracovní den) a pevně nadefinovanou, smyšlenou nabídku jídel.
@@ -11,3 +24,27 @@
# To je užitečné pro odesílání upozornění na různé servery Gotify s různými klíči API.
# Struktura dat je ve formátu JSON a je uložena jako řetězec.
# GOTIFY_SERVERS_AND_KEYS='[{"server":"https://notification.server.eu", "api_keys":["key1", "key2"]},{"server":"https://notification.server2.eu", "api_keys":["key3", "key4"]}]'
#NTFY_HOST = "http://192.168.0.113:80"
#NTFY_USERNAME="username"
#NTFY_PASSWD="password"
# Zapne přihlašování pomocí důvěryhodných hlaviček (trusted headers). Výchozí hodnota je false.
# V případě zapnutí je nutno vyplnit také HTTP_REMOTE_TRUSTED_IPS.
# HTTP_REMOTE_USER_ENABLED=true
# Seznam IP adres nebo rozsahů oddělených čárkou, ze kterých budou akceptovány důvěryhodné hlavičky.
# HTTP_REMOTE_TRUSTED_IPS=127.0.0.1,192.168.1.0/24
# Název důvěryhodné hlavičky obsahující login uživatele. Výchozí hodnota je 'remote-user'.
# HTTP_REMOTE_USER_HEADER_NAME=remote-user
# VAPID klíče pro Web Push notifikace (připomínka výběru oběda).
# Vygenerovat pomocí: npx web-push generate-vapid-keys
# VAPID_PUBLIC_KEY=
# VAPID_PRIVATE_KEY=
# VAPID_SUBJECT=mailto:admin@example.com
# Heslo pro bypass rate limitu na endpointu /api/food/refresh (pro skripty/admin).
# Bez hesla může refresh volat každý přihlášený uživatel (podléhá rate limitu).
# REFRESH_BYPASS_PASSWORD=
+4 -2
View File
@@ -1,5 +1,7 @@
/node_modules
/data
/dist
data.json
/resources/easterEggs
/src/gen
.env.production
.env.development
.easter-eggs.json
-18
View File
@@ -1,18 +0,0 @@
FROM node:18-alpine3.18
ENV LANG cs_CZ.UTF-8
WORKDIR /app
COPY package.json .
COPY yarn.lock .
COPY .env.production .
COPY tsconfig.json .
COPY src ./src
RUN yarn install --frozen-lockfile
RUN yarn build
EXPOSE 3001
CMD [ "node", "/app/dist/index.js" ]
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
'@babel/preset-typescript',
],
};
-3
View File
@@ -1,3 +0,0 @@
#!/bin/bash
yarn install --frozen-lockfile && yarn build
docker build -t luncher-server .
+4
View File
@@ -0,0 +1,4 @@
[
"Zimní atmosféra",
"Skrytí podniku U Motlíků"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Přidání restaurace Zastávka u Michala"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Přidání restaurace Pivovarský šenk Šeříková"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Možnost výběru podniku/jídla kliknutím"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Stránka se statistikami nejoblíbenějších voleb"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Zobrazení počtu osob u každé volby"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Migrace na generované OpenApi"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Odebrání zimní atmosféry"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Možnost ručního přenačtení menu"
]

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