Compare commits

Author SHA1 Message Date
mates 35cf2ca9d7 feat: vytržení miniher do samostatného projektu
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 28s
CI / Playwright E2E tests (push) Successful in 2m24s
CI / Build and push Docker image (push) Successful in 44s
CI / Server unit tests (push) Successful in 26s
CI / Build client (push) Successful in 41s
CI / Notify (push) Successful in 2s
2026-10-22 12:42:49 +02:00
mates 8a7bbef729 build: neodesílat notifikace na nenakonfigurované kanály
CI / Generate TypeScript types (push) Successful in 14s
CI / Playwright E2E tests (push) Successful in 1m55s
CI / Build and push Docker image (push) Successful in 45s
CI / Server unit tests (push) Successful in 27s
CI / Build server (push) Successful in 32s
CI / Build client (push) Successful in 44s
CI / Notify (push) Successful in 2s
2026-10-22 11:30:02 +02:00
batmanisko 667f3c1ef1 chore: ignorování yarn install-state.gz
CI / Generate TypeScript types (push) Successful in 10s
CI / Playwright E2E tests (push) Successful in 2m34s
CI / Build and push Docker image (push) Successful in 48s
CI / Server unit tests (push) Successful in 26s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 42s
CI / Notify (push) Successful in 2s
2026-10-22 10:29:21 +02:00
hrubyjarandClaude Opus 5.5 5d13efb685 feat: směrové ovládání tanků, větší mapy, zvýraznění vlastního tanku a legenda bonusů
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 10:29:21 +02:00
hrubyjarandClaude Opus 5.5 d4485db0b6 fix: chybějící čárka v changelogu 2026-10-20
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 10:29:20 +02:00
Vít Kilián 6beb15afe3 feat: chat v lobby 2026-10-22 10:29:20 +02:00
Ondřej Anděl 9a3987a2fa feat: zadávání cen
CI / Generate TypeScript types (push) Successful in 10s
CI / Playwright E2E tests (push) Successful in 2m15s
CI / Build and push Docker image (push) Successful in 45s
CI / Server unit tests (push) Successful in 28s
CI / Build server (push) Successful in 37s
CI / Build client (push) Successful in 45s
CI / Notify (push) Successful in 2s
2026-10-21 09:42:04 +02:00
hrubyjarandClaude Opus 5.5 96dc3f0053 feat: minihra Tanky
CI / Generate TypeScript types (push) Successful in 10s
CI / Playwright E2E tests (push) Successful in 1m57s
CI / Build and push Docker image (push) Successful in 46s
CI / Server unit tests (push) Successful in 26s
CI / Build server (push) Successful in 30s
CI / Build client (push) Successful in 46s
CI / Notify (push) Successful in 2s
Nová skrytá minihra (kód "tanky") pro více hráčů, simulaci drží server:
- jízda šipkami/WSAD, otočná věž míří za myší, střelba klikem nebo mezerníkem
- režimy poslední přeživší (výchozí) a na čas, volitelně ve dvou týmech
- volitelné odrážení střel od zdí (výchozí vypnuto)
- hardcore režim: tma, vidět je jen světlo kolem tanku a reflektor ve směru věže
- bonusy: štít, trojitá střela, rakety, turbo, neviditelnost, naváděné střely, miny, život
- 7 map v pěti prostředích (město, les, poušť, zima, bažina) s vodou,
  zpomalujícím terénem a zničitelnými bednami
- boti se třemi obtížnostmi (hledání cesty, rozstřelování beden, v hardcore vidí jen světlo)
- efekty: záblesk výstřelu, výbuchy, hořící vraky

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 15:58:47 +02:00
batmanisko d2125a050b feat: dotykové ovládání miniher na mobilu a tabletu
CI / Generate TypeScript types (push) Successful in 13s
CI / Playwright E2E tests (push) Successful in 1m54s
CI / Build and push Docker image (push) Successful in 46s
CI / Server unit tests (push) Successful in 27s
CI / Build server (push) Successful in 33s
CI / Build client (push) Successful in 42s
CI / Notify (push) Successful in 2s
2026-10-20 14:43:49 +02:00
Stánek PavelandClaude Opus 5.5 a8b67fd76e fix: rotace kostek v Tetrisu dle osy a proti směru hodinových ručiček, zrození kostky nad plochou
CI / Generate TypeScript types (push) Successful in 14s
CI / Playwright E2E tests (push) Successful in 1m54s
CI / Build and push Docker image (push) Successful in 44s
CI / Server unit tests (push) Successful in 28s
CI / Build server (push) Successful in 34s
CI / Build client (push) Successful in 52s
CI / Notify (push) Successful in 2s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 10:26:47 +02:00
mates 304c7656e9 fix: znemožnění spuštění Tetrisu v jednom hráči
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 29s
CI / Playwright E2E tests (push) Successful in 2m2s
CI / Build and push Docker image (push) Successful in 52s
CI / Server unit tests (push) Successful in 26s
CI / Build client (push) Successful in 43s
CI / Notify (push) Successful in 4s
2026-10-20 09:47:00 +02:00
mates bc2cc75d7d fix: možnost připojit se ke hře Tetris během odpočtu před startem 2026-10-20 09:43:21 +02:00
mates fe12098ed0 fix: nepřepínání dnů/týdnů při otevřené minihře
CI / Generate TypeScript types (push) Successful in 11s
CI / Playwright E2E tests (push) Successful in 2m38s
CI / Build and push Docker image (push) Successful in 45s
CI / Server unit tests (push) Successful in 27s
CI / Build server (push) Successful in 32s
CI / Build client (push) Successful in 42s
CI / Notify (push) Successful in 2s
2026-10-19 21:09:39 +02:00
mates 95a35df6ab feat: umožnění automatického přepnutí na volbu "Budu objednávat" při přidání se k objednávce
CI / Generate TypeScript types (push) Successful in 16s
CI / Playwright E2E tests (push) Successful in 2m16s
CI / Build and push Docker image (push) Successful in 1m11s
CI / Server unit tests (push) Successful in 29s
CI / Build server (push) Successful in 31s
CI / Build client (push) Successful in 42s
CI / Notify (push) Successful in 3s
2026-10-19 16:57:09 +02:00
mates 297cb80662 feat: přepínání režimů a systému vítězství v Tetrisu
CI / Generate TypeScript types (push) Canceled after 0s
CI / Build server (push) Canceled after 0s
CI / Playwright E2E tests (push) Canceled after 0s
CI / Build and push Docker image (push) Canceled after 0s
CI / Server unit tests (push) Canceled after 0s
CI / Build client (push) Canceled after 0s
CI / Notify (push) Canceled after 0s
2026-10-19 16:49:56 +02:00
mates ed64fc60e4 fix. férovější výběr cíle u Tetrisu, odrážení odpadu 2026-10-19 16:37:27 +02:00
Ondřej Anděl 8a7dbd94c8 fix: export nedává "prázdné" záznamy
CI / Generate TypeScript types (push) Successful in 11s
CI / Playwright E2E tests (push) Successful in 1m58s
CI / Build and push Docker image (push) Successful in 45s
CI / Server unit tests (push) Successful in 27s
CI / Build server (push) Successful in 31s
CI / Build client (push) Successful in 43s
CI / Notify (push) Successful in 1s
2026-10-14 13:35:25 +02:00
mates 9fa00fa9c5 feat: rozdělení nastavení do 2 sloupců
CI / Generate TypeScript types (push) Successful in 10s
CI / Playwright E2E tests (push) Successful in 2m24s
CI / Build and push Docker image (push) Successful in 47s
CI / Server unit tests (push) Successful in 27s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 41s
CI / Notify (push) Successful in 2s
2026-10-13 11:25:52 +02:00
mates dc17fa0f0f feat: přidání Bocchi the Rock! theme
CI / Generate TypeScript types (push) Successful in 10s
CI / Playwright E2E tests (push) Successful in 2m4s
CI / Build and push Docker image (push) Successful in 46s
CI / Server unit tests (push) Successful in 27s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 44s
CI / Notify (push) Successful in 2s
2026-10-13 10:59:00 +02:00
mates 4bc2151cc4 feat: přidání K-ON! theme
CI / Generate TypeScript types (push) Successful in 11s
CI / Build server (push) Successful in 29s
CI / Playwright E2E tests (push) Successful in 1m52s
CI / Build and push Docker image (push) Successful in 46s
CI / Server unit tests (push) Successful in 27s
CI / Build client (push) Successful in 42s
CI / Notify (push) Successful in 2s
2026-10-13 10:49:14 +02:00
mates e570f308cd fix: oprava zobrazování motýlů
CI / Generate TypeScript types (push) Successful in 11s
CI / Playwright E2E tests (push) Successful in 2m22s
CI / Build and push Docker image (push) Successful in 46s
CI / Server unit tests (push) Successful in 27s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 42s
CI / Notify (push) Successful in 1s
2026-10-12 18:49:29 +02:00
mates 0cefaa62a0 fix: oprava chování Hell Girl theme
CI / Generate TypeScript types (push) Successful in 11s
CI / Build server (push) Successful in 31s
CI / Playwright E2E tests (push) Successful in 1m58s
CI / Build and push Docker image (push) Successful in 49s
CI / Server unit tests (push) Successful in 27s
CI / Build client (push) Successful in 49s
CI / Notify (push) Successful in 3s
2026-10-12 18:30:45 +02:00
Ondřej Anděl 2a54a1d41f feat: HG mode - změna obrázků, animace v modálech
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 29s
CI / Playwright E2E tests (push) Successful in 2m2s
CI / Build and push Docker image (push) Successful in 45s
CI / Server unit tests (push) Successful in 27s
CI / Build client (push) Successful in 43s
CI / Notify (push) Successful in 2s
2026-10-12 14:34:55 +02:00
batmanisko 5147651e7c fix: novinky jen naznačí přidání miniher
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 31s
CI / Playwright E2E tests (push) Successful in 1m59s
CI / Build and push Docker image (push) Successful in 47s
CI / Server unit tests (push) Successful in 27s
CI / Build client (push) Successful in 44s
CI / Notify (push) Successful in 2s
2026-10-12 14:26:00 +02:00
batmanisko 6cb1214f08 feat: skryté multiplayerové minihry přes websocket
CI / Generate TypeScript types (push) Canceled after 7s
CI / Playwright E2E tests (push) Canceled after 0s
CI / Build and push Docker image (push) Canceled after 0s
CI / Server unit tests (push) Canceled after 0s
CI / Build server (push) Canceled after 0s
CI / Build client (push) Canceled after 0s
CI / Notify (push) Canceled after 0s
Minihry se otevírají trojklikem na logo a napsáním kódu hry
("help" vypíše seznam do konzole prohlížeče):
- reakce: reakční hra, 3 kola, pořadí podle součtu časů
- snake: had pro více hráčů, simulaci drží server
- tetris: battle Tetris s odpadními řádky a lokálním tréninkem
- pong: Pong 1v1, ovládání myší i klávesami, bot se 4 obtížnostmi
- kresleni: kreslení a hádání jídel z dnešních menu

Ikony her se řídí zvoleným vzhledem. Stav her je v paměti serveru,
předpokládá se jedna replika.
2026-10-12 14:25:45 +02:00
batmanisko abeee19259 feat: možnost vypnout motýlky v nastavení
CI / Generate TypeScript types (push) Successful in 10s
CI / Playwright E2E tests (push) Successful in 1m53s
CI / Build and push Docker image (push) Successful in 46s
CI / Server unit tests (push) Successful in 26s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 42s
CI / Notify (push) Successful in 2s
2026-10-12 13:14:53 +02:00
mates f91193c7de feat: přidání vzhledu Hell Girl
CI / Generate TypeScript types (push) Successful in 10s
CI / Playwright E2E tests (push) Successful in 1m51s
CI / Build and push Docker image (push) Successful in 46s
CI / Server unit tests (push) Successful in 27s
CI / Build server (push) Successful in 30s
CI / Build client (push) Successful in 42s
CI / Notify (push) Successful in 1s
2026-10-12 13:05:34 +02:00
mates 93d86fe0a5 feat: přidání vzhledu My Little Pony
CI / Generate TypeScript types (push) Successful in 11s
CI / Playwright E2E tests (push) Successful in 1m53s
CI / Build and push Docker image (push) Successful in 57s
CI / Build server (push) Successful in 30s
CI / Build client (push) Successful in 41s
CI / Server unit tests (push) Successful in 27s
CI / Notify (push) Successful in 2s
2026-10-12 11:04:54 +02:00
mates bf631eb913 test: oprava Playwright testů po odstranění podniku Šenk Šeříková
CI / Generate TypeScript types (push) Successful in 10s
CI / Playwright E2E tests (push) Successful in 1m50s
CI / Build and push Docker image (push) Successful in 1m1s
CI / Server unit tests (push) Successful in 27s
CI / Build server (push) Successful in 31s
CI / Build client (push) Successful in 41s
CI / Notify (push) Successful in 2s
2026-10-09 15:46:41 +02:00
mates 49508dacd0 fix: dočasné skrytí podniku Šenk Šeříková
CI / Generate TypeScript types (push) Successful in 11s
CI / Build server (push) Successful in 29s
CI / Playwright E2E tests (push) Failing after 2m44s
CI / Build and push Docker image (push) Skipped
CI / Server unit tests (push) Successful in 27s
CI / Build client (push) Successful in 40s
CI / Notify (push) Successful in 2s
2026-10-09 14:45:00 +02:00
mates 9fee4a1538 fix: oprava parsování TechTower po změně jejich webu
CI / Generate TypeScript types (push) Successful in 11s
CI / Playwright E2E tests (push) Successful in 2m0s
CI / Build and push Docker image (push) Successful in 47s
CI / Server unit tests (push) Successful in 30s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 41s
CI / Notify (push) Successful in 2s
2026-10-09 14:37:09 +02:00
batmanisko 7cda7cfdb6 fix: hlavičky s identitou přijímat jen z důvěryhodných adres
CI / Generate TypeScript types (push) Successful in 11s
CI / Playwright E2E tests (push) Successful in 1m39s
CI / Build and push Docker image (push) Successful in 45s
CI / Server unit tests (push) Successful in 31s
CI / Build server (push) Successful in 27s
CI / Build client (push) Successful in 47s
CI / Notify (push) Successful in 2s
2026-09-24 16:12:46 +02:00
batmanisko 89d2cc2bc3 fix: delší zobrazení konfet kola štěstí
CI / Generate TypeScript types (push) Successful in 11s
CI / Playwright E2E tests (push) Successful in 1m48s
CI / Build and push Docker image (push) Successful in 53s
CI / Server unit tests (push) Successful in 26s
CI / Build server (push) Successful in 36s
CI / Build client (push) Successful in 40s
CI / Notify (push) Successful in 4s
2026-09-24 16:03:29 +02:00
mates a29e329c11 feat: přidání detekce polévek pro TechTower
CI / Generate TypeScript types (push) Successful in 10s
CI / Playwright E2E tests (push) Successful in 2m26s
CI / Build and push Docker image (push) Successful in 3m39s
CI / Server unit tests (push) Successful in 25s
CI / Build client (push) Successful in 42s
CI / Build server (push) Successful in 1m48s
CI / Notify (push) Successful in 1s
2026-09-24 10:18:43 +02:00
batmanisko 5f54925808 feat: způsob přihlášení a správa obchodů podle konfigurace serveru
CI / Generate TypeScript types (push) Successful in 12s
CI / Playwright E2E tests (push) Successful in 1m47s
CI / Build and push Docker image (push) Successful in 49s
CI / Server unit tests (push) Successful in 26s
CI / Build server (push) Successful in 32s
CI / Build client (push) Successful in 41s
CI / Notify (push) Successful in 2s
2026-09-24 09:30:04 +02:00
batmanisko 103445dba8 feat: limity skupinových objednávek nastavitelné přes env
CI / Generate TypeScript types (push) Successful in 12s
CI / Build server (push) Successful in 30s
CI / Playwright E2E tests (push) Successful in 3m55s
CI / Build and push Docker image (push) Successful in 49s
CI / Server unit tests (push) Successful in 25s
CI / Build client (push) Successful in 39s
CI / Notify (push) Successful in 2s
2026-09-24 09:18:56 +02:00
batmanisko 076b8db179 feat: opakovaný výběr jídla zvýší počet kusů (2x)
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 27s
CI / Playwright E2E tests (push) Successful in 1m50s
CI / Build and push Docker image (push) Successful in 44s
CI / Server unit tests (push) Successful in 25s
CI / Build client (push) Successful in 40s
CI / Notify (push) Successful in 1s
2026-09-23 16:37:01 +02:00
batmanisko f9a7b791a0 feat: odebírání položek objednávky ve sloupci Z nabídky
CI / Generate TypeScript types (push) Successful in 10s
CI / Playwright E2E tests (push) Successful in 1m47s
CI / Build and push Docker image (push) Successful in 45s
CI / Server unit tests (push) Successful in 26s
CI / Build server (push) Successful in 58s
CI / Build client (push) Successful in 1m4s
CI / Notify (push) Successful in 2s
2026-09-23 16:26:10 +02:00
batmanisko bd52ae071e fix: kolo štěstí točí až od dvou možností
CI / Generate TypeScript types (push) Successful in 10s
CI / Playwright E2E tests (push) Successful in 1m50s
CI / Build and push Docker image (push) Successful in 46s
CI / Server unit tests (push) Successful in 25s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 41s
CI / Notify (push) Successful in 2s
2026-09-23 16:18:11 +02:00
batmanisko 0a59f97c5c feat: nápověda k admin heslu u obnovení nabídky
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 32s
CI / Playwright E2E tests (push) Successful in 1m53s
CI / Build and push Docker image (push) Successful in 46s
CI / Server unit tests (push) Successful in 25s
CI / Build client (push) Successful in 42s
CI / Notify (push) Successful in 1s
2026-09-23 16:10:55 +02:00
batmanisko e7c4ba09ff feat: souhrn historie cen od začátku
CI / Generate TypeScript types (push) Successful in 10s
CI / Playwright E2E tests (push) Successful in 1m49s
CI / Build and push Docker image (push) Successful in 48s
CI / Server unit tests (push) Successful in 26s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 41s
CI / Notify (push) Successful in 1s
2026-09-23 15:55:43 +02:00
batmanisko e281615cc3 feat: spin the wheel, objednavani scrape menu, historie cen
CI / Generate TypeScript types (push) Successful in 11s
CI / Playwright E2E tests (push) Successful in 1m52s
CI / Build and push Docker image (push) Successful in 51s
CI / Server unit tests (push) Successful in 25s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 39s
CI / Notify (push) Successful in 2s
2026-09-23 15:41:15 +02:00
mates 682deaf20b feat: přidání detekce polévek pro TechTower
CI / Generate TypeScript types (push) Successful in 10s
CI / Playwright E2E tests (push) Successful in 1m43s
CI / Build and push Docker image (push) Successful in 1m15s
CI / Server unit tests (push) Successful in 24s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 41s
CI / Notify (push) Successful in 2s
2026-09-15 08:12:07 +02:00
mates 92ea78a870 test: oprava testu poražení zloděje
CI / Generate TypeScript types (push) Successful in 10s
CI / Playwright E2E tests (push) Successful in 1m44s
CI / Build and push Docker image (push) Successful in 3m2s
CI / Server unit tests (push) Successful in 24s
CI / Build server (push) Successful in 27s
CI / Build client (push) Successful in 39s
CI / Notify (push) Successful in 2s
2026-09-09 09:03:40 +02:00
mates 51df4c812e chore: povýšení na node 26
CI / Generate TypeScript types (push) Successful in 1m1s
CI / Playwright E2E tests (push) Successful in 1m43s
CI / Build and push Docker image (push) Skipped
CI / Server unit tests (push) Failing after 36s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 37s
CI / Notify (push) Successful in 1s
2026-09-09 08:44:17 +02:00
mates ed53a1477e chore: povýšení verzí závislostí 2026-09-09 08:40:47 +02:00
mates 6a12bdb179 fix: oprava spojení při použítí tlačítka "Zpět"
CI / Generate TypeScript types (push) Successful in 11s
CI / Playwright E2E tests (push) Successful in 1m44s
CI / Build and push Docker image (push) Successful in 45s
CI / Server unit tests (push) Successful in 25s
CI / Build server (push) Successful in 27s
CI / Build client (push) Successful in 40s
CI / Notify (push) Successful in 2s
2026-09-07 13:02:45 +02:00
mates 31b398a1d6 fix: přidání detekce polévek pro TechTower
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 30s
CI / Playwright E2E tests (push) Successful in 1m53s
CI / Build and push Docker image (push) Successful in 46s
CI / Server unit tests (push) Successful in 25s
CI / Build client (push) Successful in 44s
CI / Notify (push) Successful in 2s
2026-09-07 11:35:52 +02:00
mates b6834cb7aa feat: nový stav "Doručeno" pro objednávky
CI / Generate TypeScript types (push) Successful in 10s
CI / Playwright E2E tests (push) Successful in 1m43s
CI / Build and push Docker image (push) Successful in 48s
CI / Server unit tests (push) Successful in 26s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 42s
CI / Notify (push) Successful in 1s
2026-09-07 11:30:40 +02:00
mates ecc28ee82d feat: generování mock dat ve vývoji pro objednávky
CI / Generate TypeScript types (push) Successful in 11s
CI / Playwright E2E tests (push) Successful in 1m43s
CI / Build and push Docker image (push) Successful in 46s
CI / Server unit tests (push) Successful in 27s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 42s
CI / Notify (push) Successful in 2s
2026-09-04 16:57:10 +02:00
mates 1af40bd572 fix: oprava varování "Data jsou z minulého týdne"
CI / Generate TypeScript types (push) Successful in 17s
CI / Build server (push) Successful in 32s
CI / Playwright E2E tests (push) Successful in 1m47s
CI / Build and push Docker image (push) Successful in 1m9s
CI / Server unit tests (push) Successful in 25s
CI / Build client (push) Successful in 41s
CI / Notify (push) Successful in 2s
2026-09-02 09:43:21 +02:00
mates e5fc358c0a fix: odstranění matoucích chybových hlášek z dialogu Správa obchodů
CI / Generate TypeScript types (push) Successful in 10s
CI / Playwright E2E tests (push) Successful in 1m47s
CI / Build and push Docker image (push) Successful in 45s
CI / Server unit tests (push) Successful in 26s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 41s
CI / Notify (push) Successful in 2s
2026-08-25 11:49:18 +02:00
mates 6f6567a2a9 feat: možnost editace existujících podniků u objednávání 2026-08-25 11:43:35 +02:00
mates ce051447e6 feat: podpora více odkazů na podnik při objednávání 2026-08-25 11:28:57 +02:00
mates 93242b7c7e feat: lepší generování mock dat při vývoji
CI / Generate TypeScript types (push) Successful in 11s
CI / Playwright E2E tests (push) Successful in 1m45s
CI / Build and push Docker image (push) Successful in 46s
CI / Server unit tests (push) Successful in 25s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 41s
CI / Notify (push) Successful in 2s
2026-08-25 10:31:32 +02:00
mates 9027de5f8b fix: oprava zobrazení statistik pro aktuální týden
CI / Generate TypeScript types (push) Successful in 16s
CI / Playwright E2E tests (push) Successful in 1m46s
CI / Build and push Docker image (push) Successful in 48s
CI / Server unit tests (push) Successful in 25s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 41s
CI / Notify (push) Successful in 2s
2026-08-25 10:04:55 +02:00
batmanisko 936ce2a9e4 fix: přesnější stav kurýra u sledování Wolt
CI / Generate TypeScript types (push) Successful in 10s
CI / Playwright E2E tests (push) Successful in 1m46s
CI / Build and push Docker image (push) Successful in 48s
CI / Server unit tests (push) Successful in 24s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 40s
CI / Notify (push) Successful in 3s
Wolt hlásí stav podniku, ne rozvozu — status zůstane na "ready" i celou dobu,
co kurýr objednávku veze. Vyzvednutí proto poznáme podle pickup_eta: dokud je
v budoucnosti, jídlo ještě čeká v podniku, po vyzvednutí se ustálí na skutečném
čase. Dřív se stav odvozoval jen z příznaků kurýra, takže objednávka zůstala
viset na kroku "Vyzvedávání", i když už byla na cestě.

Kurýr má nově tři odvozené stavy: assigned (ještě nevyzvedl), delivering (veze
ji k nám) a delivering_other (veze ji, ale nejdřív doručuje cizí objednávku —
Wolt objednávky spojuje a kurýr se pak na mapě vzdaluje). Oba stavy delivering*
znamenají krok "Na cestě", tooltip u nich vysvětlí objížďku.
2026-08-24 12:06:43 +02:00
batmanisko 5c048fe1f1 feat: sledování objednávek přes Wolt
CI / Generate TypeScript types (push) Successful in 13s
CI / Playwright E2E tests (push) Successful in 1m47s
CI / Build and push Docker image (push) Successful in 53s
CI / Server unit tests (push) Successful in 25s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 41s
CI / Notify (push) Successful in 2s
Vedle Bolt Food jde nově sledovat i objednávka z Woltu — stačí u objednané
skupiny vložit odkaz z track.wolt.com. Logika sledování je zobecněná do
registru rozvozových služeb (trackingProviders.ts): každá služba umí vytáhnout
kód ze sdílecího odkazu a dotázat se svého API, scheduler i stepper jsou společné.

- pole skupiny bolt* přejmenována na tracking* + nové trackingProvider
- endpoint /groups/setBoltTracking → /groups/setTracking
- Wolt: čas doručení z delivery_eta v časové zóně objednávky, 404 ukončí
  sledování, stav kurýra odvozen z is_delivering/is_delivering_other_order
- DEV simulace zůstává jen pro Bolt Food
2026-08-24 11:27:56 +02:00
Ondřej Anděl eeb1630391 feat: export historie jídel uživatele
CI / Generate TypeScript types (push) Successful in 10s
CI / Playwright E2E tests (push) Successful in 1m53s
CI / Build and push Docker image (push) Successful in 1m1s
CI / Server unit tests (push) Successful in 25s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 41s
CI / Notify (push) Successful in 3s
2026-08-12 13:53:36 +02:00
batmanisko 065ccaf38a feat: push upozornění na doručení objednávky Bolt Food
CI / Generate TypeScript types (push) Successful in 12s
CI / Build server (push) Successful in 28s
CI / Playwright E2E tests (push) Successful in 1m48s
CI / Build and push Docker image (push) Successful in 53s
CI / Server unit tests (push) Successful in 22s
CI / Build client (push) Successful in 41s
CI / Notify (push) Successful in 2s
Nové zaškrtávátko v Nastavení → Notifikace pod výběrem času připomínky.
Když skupinová objednávka sledovaná přes Bolt Food přejde do stavu
delivered/finished, dostanou push členové skupiny, kteří si to zapnuli.
Zakladatel skupiny notifikaci nedostává — ten je informován aplikací Bolt.

Push subscription byla dosud svázaná s časem připomínky (subscribe endpoint
bez reminderTime vracel 400), takže by nový přepínač samostatně nefungoval.
Čas připomínky je nově v registru volitelný a scheduler připomínek záznamy
bez času přeskakuje.

Service worker nemá natvrdo tag a akci "Mám vlastní/neobědvám" — obojí se
řídí payloadem, aby je doručovací notifikace nedědila.
2026-08-10 12:36:07 +02:00
Stánek PavelandClaude Opus 4.8 377a350211 feat: anti-bot inspekce a vězení + přepínač hraní/objednávání
CI / Generate TypeScript types (push) Successful in 15s
CI / Build server (push) Successful in 31s
CI / Playwright E2E tests (push) Successful in 1m30s
CI / Build and push Docker image (push) Successful in 1m6s
CI / Server unit tests (push) Successful in 24s
CI / Build client (push) Successful in 1m0s
CI / Notify (push) Successful in 5s
- přepínač Hraní/Objednávání; v objednávání hra nic neruší, v hraní herní vrstva odstíní kliknutí
- scéna žije trvale (přepnutí režimu neobejde pavučinu, omráčení, zabavenou síťku ani ban)
- anti-bot: ovládání jen reálnou myší (isTrusted/webdriver), občasná motýlí inspekce (chyť korunovaného)
- neúspěch inspekce = zabavená síťka 45 s (opticky skrytá); opakovaně = serverový ban s mříží a odpočtem
- během banu server nekredituje úlovky (F5/přepnutí-proof)
- větší síťka se teď viditelně zvětší hned po koupi
- changelog route přeskočí vadný JSON (nespadne celý endpoint) + oprava changelogu

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 12:56:20 +02:00
Stánek PavelandClaude Opus 4.8 252d105408 feat: obchod, černé můry, netopýr, pavouk a denní úkoly u chytání motýlků
CI / Generate TypeScript types (push) Successful in 15s
CI / Build server (push) Successful in 29s
CI / Playwright E2E tests (push) Successful in 1m33s
CI / Build and push Docker image (push) Successful in 49s
CI / Server unit tests (push) Successful in 23s
CI / Build client (push) Successful in 1m2s
CI / Notify (push) Successful in 1s
- obchod s pomůckami a trvalými vylepšeními (větší síťka, strašák, zpevněná síťka) – mince mají trvalý smysl
- černé můry se míchají mezi motýly; chycení (i magnetem) ubere hodně úlovků
- ptáci silnější, plašič dražší a kratší, zloději chodí častěji podle bohatství
- denní úkol s odměnou + achievementy/odznaky
- netopýr v noci loví motýly, pavouk zamotá síťku pavučinou – oba na zaklikání
- nákupní hlášky rozlišují nedostatek mincí od jiné chyby; herní smyčka se pozastaví při skryté záložce

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 10:23:09 +02:00
Stánek PavelandClaude Opus 4.8 e3f439bde0 feat: obtížnější chytání motýlků + zloděj, housenka a agresivní škůdci
CI / Generate TypeScript types (push) Successful in 12s
CI / Playwright E2E tests (push) Successful in 1m25s
CI / Build and push Docker image (push) Successful in 45s
CI / Server unit tests (push) Successful in 22s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 41s
CI / Notify (push) Successful in 2s
- 60 úrovní se strmou křivkou, dražší vylepšení, vzácnější zlatý motýl
- ochrana proti podvádění (rate cap + cooldown chytání)
- perzistentní škůdci přibývají rychleji; vosy nalétávají do síťky, ptáci ji protrhnou vždy
- protržená síťka se sama zašije za minutu (nebo okamžitě za mince)
- zloděj krade mince a housenka žere úlovky – oba se plíží k počítadlu a jdou zaklikat
- plašič ptáků s odpočtem, level progress bar, nová sezóna (reset statistik)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 14:10:29 +02:00
Stánek PavelandClaude Opus 4.8 c5ca6c9d41 feat: rozšíření chytání motýlků o progres, mince a škůdce
CI / Generate TypeScript types (push) Successful in 10s
CI / Playwright E2E tests (push) Successful in 1m25s
CI / Build and push Docker image (push) Successful in 44s
CI / Server unit tests (push) Successful in 23s
CI / Build server (push) Successful in 27s
CI / Build client (push) Successful in 46s
CI / Notify (push) Successful in 2s
- serverová perzistence statistik (mince, úrovně, zlatí motýli) + týmový žebříček
- prémiová zlatá síťka za milník (větší, magnetická, odolná proti ptákům)
- perzistentní škůdci: vosy na zaklikání a ptáci trhající síťku (F5-proof)
- placený plašič ptáků s odpočtem, denní bonus, kombo
- WOW zlatý motýl + osobní statistiky v modalu žebříčku
- oprava zobrazení ikonek (root-absolutní cesty k public assetům)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 11:59:17 +02:00
Stánek PavelandClaude Opus 4.8 c8c5ecc60c feat: chytání motýlků síťkou s počítadlem
CI / Generate TypeScript types (push) Successful in 10s
CI / Playwright E2E tests (push) Successful in 1m27s
CI / Build and push Docker image (push) Successful in 42s
CI / Server unit tests (push) Successful in 23s
CI / Build server (push) Successful in 35s
CI / Build client (push) Successful in 42s
CI / Notify (push) Successful in 2s
Přidána síťka, kterou lze uchopit myší a lovit poletující motýly.
Po chycení přiletí nový (počet létajících se nemění), počet ulovených
se zobrazuje jako odznak a ukládá do local storage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 10:08:47 +02:00
mates 146c31b775 fix: oprava pádu při zdvojeném menu
CI / Generate TypeScript types (push) Successful in 13s
CI / Build server (push) Successful in 26s
CI / Playwright E2E tests (push) Successful in 1m29s
CI / Build and push Docker image (push) Successful in 43s
CI / Server unit tests (push) Successful in 23s
CI / Build client (push) Successful in 38s
CI / Notify (push) Successful in 2s
2026-07-20 11:33:14 +02:00
mates 0232e6ea26 chore: povýšení verzí závislostí
CI / Build and push Docker image (push) Successful in 53s
CI / Server unit tests (push) Successful in 22s
CI / Playwright E2E tests (push) Successful in 1m27s
CI / Notify (push) Successful in 2s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 27s
CI / Build client (push) Successful in 42s
2026-07-16 20:18:21 +02:00
mates 87c12501bb fix: zarovnání ikon v hlavičce
CI / Build and push Docker image (push) Has been cancelled
CI / Server unit tests (push) Successful in 22s
CI / Playwright E2E tests (push) Has been cancelled
CI / Notify (push) Has been cancelled
CI / Generate TypeScript types (push) Successful in 19s
CI / Build server (push) Has been cancelled
CI / Build client (push) Has been cancelled
2026-07-16 20:17:15 +02:00
mates 56ceb6e3cb chore: povýšení verzí závislostí
CI / Build and push Docker image (push) Successful in 41s
CI / Server unit tests (push) Successful in 26s
CI / Playwright E2E tests (push) Successful in 1m40s
CI / Notify (push) Successful in 1s
CI / Generate TypeScript types (push) Successful in 14s
CI / Build server (push) Successful in 54s
CI / Build client (push) Successful in 41s
2026-07-14 15:58:15 +02:00
batmanisko e1ada97184 feat: SENTRY_DEBUG pro zapnutí interních logů Sentry SDK
CI / Build and push Docker image (push) Successful in 44s
CI / Server unit tests (push) Successful in 23s
CI / Playwright E2E tests (push) Successful in 1m34s
CI / Notify (push) Successful in 2s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 27s
CI / Build client (push) Successful in 41s
2026-07-14 11:58:30 +02:00
batmanisko 9ea2916c7a fix: redigování citlivých dat před odesláním do Sentry
CI / Build and push Docker image (push) Successful in 44s
CI / Server unit tests (push) Successful in 22s
CI / Playwright E2E tests (push) Successful in 1m28s
CI / Notify (push) Successful in 2s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 27s
CI / Build client (push) Successful in 39s
Console eventy nesou celé argumenty console.error — u axios chyb tedy
i config s hlavičkami a request body (Bolt token, Gotify/NTFY klíče).
beforeSend nově rekurzivně rediguje citlivé klíče a tokeny v řetězcích
napříč extra/breadcrumbs/contexts/request/exception.
2026-07-14 11:30:03 +02:00
batmanisko f8c5d28f47 fix: Sentry jen s error monitoringem (bez Sentry Logs)
CI / Build and push Docker image (push) Has been cancelled
CI / Server unit tests (push) Successful in 23s
CI / Playwright E2E tests (push) Successful in 1m32s
CI / Notify (push) Has been cancelled
CI / Generate TypeScript types (push) Successful in 11s
CI / Build server (push) Successful in 27s
CI / Build client (push) Successful in 40s
Naše self-hosted instance nemá zapnuté Sentry Logs — console.error/warn
se nově hlásí jako eventy (captureConsoleIntegration), console.log se
k chybám přikládá jako breadcrumbs.
2026-07-14 11:25:49 +02:00
batmanisko 12fa87f3ad feat: Sentry monitoring a kompletnější sledování stavů Bolt Food
- integrace Sentry na serveru (@sentry/node) i klientu (@sentry/react):
  chyby 5xx, pády a console logy se hlásí do self-hosted Sentry
- nový veřejný endpoint GET /api/config — klient si z něj za běhu
  vyzvedne Sentry DSN (SENTRY_DSN / SENTRY_CLIENT_DSN v env)
- ErrorBoundary na klientu a sourcemapy pro čitelné stack traces
- oprava mapování stavu waiting_acceptance (zobrazoval se jako Příprava)
- mapování stavů kurýra matched/accepted, logování přechodů stavů
  Bolt objednávek pro postupné zmapování nedokumentovaného API
- simulátor Bolt ve vývoji nově prochází celou reálnou sekvenci stavů
- vypnuté logování connect/disconnect websocket klientů
2026-07-14 11:25:49 +02:00
mates a708eda421 feat: letní vzhled
CI / Build and push Docker image (push) Successful in 40s
CI / Server unit tests (push) Successful in 21s
CI / Playwright E2E tests (push) Successful in 1m20s
CI / Notify (push) Successful in 2s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 25s
CI / Build client (push) Successful in 37s
2026-07-07 13:29:28 +02:00
mates 464b14d63a fix: oprava přeskoku týdne rychlým klikáním na šipky
CI / Build and push Docker image (push) Successful in 53s
CI / Server unit tests (push) Successful in 20s
CI / Playwright E2E tests (push) Successful in 1m18s
CI / Notify (push) Successful in 1s
CI / Generate TypeScript types (push) Successful in 18s
CI / Build server (push) Successful in 24s
CI / Build client (push) Successful in 36s
2026-07-07 10:51:53 +02:00
mates 9a03dd1e5e fix: oprava parsování TechTower po změně HTML struktury
CI / Build and push Docker image (push) Has been skipped
CI / Server unit tests (push) Has been skipped
CI / Playwright E2E tests (push) Has been skipped
CI / Notify (push) Successful in 2s
CI / Generate TypeScript types (push) Failing after 13m15s
CI / Build server (push) Has been skipped
CI / Build client (push) Has been skipped
2026-07-07 10:15:10 +02:00
mates c3a8900283 fix: oprava výběru preferovaného času odchodu u podniku bez načteného menu 2026-07-07 10:02:03 +02:00
198 changed files with 24433 additions and 3403 deletions

No files matched your search

+13 -10
View File
@@ -22,7 +22,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: "22" node-version: "26"
- run: corepack enable - run: corepack enable
@@ -49,7 +49,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: "22" node-version: "26"
- run: corepack enable - run: corepack enable
@@ -71,7 +71,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: "22" node-version: "26"
- run: corepack enable - run: corepack enable
@@ -100,7 +100,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: "22" node-version: "26"
- run: corepack enable - run: corepack enable
@@ -187,7 +187,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: "22" node-version: "26"
- run: corepack enable - run: corepack enable
@@ -231,8 +231,7 @@ jobs:
steps: steps:
- name: Send webhook - name: Send webhook
env: env:
DISCORD_WEBHOOK_ID: ${{ secrets.DISCORD_WEBHOOK_ID }} DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
DISCORD_WEBHOOK_TOKEN: ${{ secrets.DISCORD_WEBHOOK_TOKEN }}
NTFY_URL: ${{ secrets.NTFY_URL }} NTFY_URL: ${{ secrets.NTFY_URL }}
BUILD_RESULT: ${{ needs.docker-build.result }} BUILD_RESULT: ${{ needs.docker-build.result }}
RUN_NUMBER: ${{ github.run_number }} RUN_NUMBER: ${{ github.run_number }}
@@ -250,12 +249,16 @@ jobs:
fi fi
FULL_MSG="$(printf '%s\n\nPipeline: %s\nPoslední commit: %sAutor: %s' \ FULL_MSG="$(printf '%s\n\nPipeline: %s\nPoslední commit: %sAutor: %s' \
"$MSG" "$RUN_URL" "$COMMIT_MESSAGE" "$COMMIT_AUTHOR")" "$MSG" "$RUN_URL" "$COMMIT_MESSAGE" "$COMMIT_AUTHOR")"
curl -s -X POST \ # Nevyplněné URL = daný kanál notifikací se přeskočí
"https://discord.com/api/webhooks/${DISCORD_WEBHOOK_ID}/${DISCORD_WEBHOOK_TOKEN}" \ if [ -n "$DISCORD_WEBHOOK_URL" ]; then
curl -sS -f -X POST "${DISCORD_WEBHOOK_URL}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
--data "$(jq -n --arg content "$FULL_MSG" '{content: $content}')" --data "$(jq -n --arg content "$FULL_MSG" '{content: $content}')"
curl -s -X POST "${NTFY_URL}" \ fi
if [ -n "$NTFY_URL" ]; then
curl -sS -f -X POST "${NTFY_URL}" \
-H "Title: Luncher CI #${RUN_NUMBER}" \ -H "Title: Luncher CI #${RUN_NUMBER}" \
-H "Tags: ${NTFY_TAGS}" \ -H "Tags: ${NTFY_TAGS}" \
-H "Click: ${RUN_URL}" \ -H "Click: ${RUN_URL}" \
-d "${FULL_MSG}" -d "${FULL_MSG}"
fi
+2
View File
@@ -7,3 +7,5 @@ server/public/
.claude/*.lock .claude/*.lock
.claude/worktrees .claude/worktrees
.playwright-mcp .playwright-mcp
.idea/
**/.yarn/install-state.gz
+1 -1
View File
@@ -1,4 +1,4 @@
ARG NODE_VERSION="node:22-alpine" ARG NODE_VERSION="node:26-alpine"
# ─── Builder ────────────────────────────────────────────────────────────────── # ─── Builder ──────────────────────────────────────────────────────────────────
FROM ${NODE_VERSION} AS builder FROM ${NODE_VERSION} AS builder
+28 -28
View File
@@ -4,41 +4,41 @@
- [ ] `foodRoutes.ts` per-pod rate limiter (`rateLimits` map) — s více replikami může uživatel překročit limit ~N× rychleji; přesunout do Redis (např. `INCR` + `EXPIRE`) - [ ] `foodRoutes.ts` per-pod rate limiter (`rateLimits` map) — s více replikami může uživatel překročit limit ~N× rychleji; přesunout do Redis (např. `INCR` + `EXPIRE`)
- [ ] `easterEggRoutes.ts` — náhodně generované URL easter eggů jsou per-pod; URL funguje pouze na podu, který ji vygeneroval; zvážit deterministické seedy nebo sdílení přes Redis - [ ] `easterEggRoutes.ts` — náhodně generované URL easter eggů jsou per-pod; URL funguje pouze na podu, který ji vygeneroval; zvážit deterministické seedy nebo sdílení přes Redis
- [ ] `service.ts` — komplexní víceúrovňové funkce (`addChoice`, `removeChoiceIfPresent`) provádějí více po sobě jdoucích zápisů do stejného Redis klíče; pro plnou atomicitu je potřeba per-klíčový distribuovaný zámek (Redlock nebo `SET NX EX`) nebo sloučení logiky do jednoho `updateData` volání - [ ] `service.ts` — komplexní víceúrovňové funkce (`addChoice`, `removeChoiceIfPresent`) provádějí více po sobě jdoucích zápisů do stejného Redis klíče; pro plnou atomicitu je potřeba per-klíčový distribuovaný zámek (Redlock nebo `SET NX EX`) nebo sloučení logiky do jednoho `updateData` volání
- [ ] HTTP_REMOTE_TRUSTED_IPS se nikde nevalidují, hlavičky jsou přijímány odkudkoli - [X] HTTP_REMOTE_TRUSTED_IPS se nikde nevalidují, hlavičky jsou přijímány odkudkoli — kontroluje se skutečná adresa spojení (trustedHeaders.ts)
- [ ] V případě zapnutí přihlašování přes trusted headers nefunguje standardní přihlášení (nevrátí žádnou odpověď) - [X] 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í) - [X] 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 - [X] 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 - 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.) - 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 - [X] Tlačítko "Uhradit" u každého řádku podniku - platí ten, kdo kliknul
- [ ] Zobrazeno bude pouze, pokud má daný uživatel nastaveno číslo účtu - [X] 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 - [X] 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 - [X] 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í) - [X] 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ášť) - [X] 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) - [X] Podpora pro notifikace v externích systémech (Gotify, Discord, MS Teams)
- [ ] Umožnit zadat URL/tokeny uživatelem - [X] Umožnit zadat URL/tokeny uživatelem — ntfy, Discord, MS Teams (Gotify jen globálně na serveru)
- [ ] Umožnit uživatelsky konfigurovat typy notifikací, které se budou odesílat - [X] Umožnit uživatelsky konfigurovat typy notifikací, které se budou odesílat
- [ ] Zavést notifikace typu "Jdeme na oběd" - [X] Zavést notifikace typu "Jdeme na oběd"
- [ ] Notifikaci dostanou pouze uživatelé, kteří mají vybranou stejnou lokalitu - [X] Notifikaci dostanou pouze uživatelé, kteří mají vybranou stejnou lokalitu
- [ ] Vylepšit parsery restaurací - [X] Vylepšit parsery restaurací
- [ ] Sladovnická - [X] Sladovnická
- [ ] Zbytečná prvotní validace indexu, datum konkrétního dne je i v samotné tabulce s jídly, viz TODO v parseru - [X] Zbytečná prvotní validace indexu, datum konkrétního dne je i v samotné tabulce s jídly, viz TODO v parseru — parser mapuje dny přímo, TODO v parseru už není
- [ ] U Motlíků - [X] U Motlíků — odpadá, podnik už v aplikaci není
- [ ] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (např. '12.6.-16.6.') - [X] 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í) - [X] 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 - [X] 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)') - [X] 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)') — finalizeTechTower, StaleWeekError
- [ ] 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í - [X] 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í — zastaralé menu se po TTL stahuje znovu
- Stránka neposílá hlavičku o času poslední modifikace, takže o to se nelze opřít - 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 - 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 ( , ) - [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) - [X] Nasazení nové verze v Docker smaže veškerá data (protože data.json není vystrčený ven z kontejneru) — data jsou v Redis s volume /data
- [ ] Zavést složku /data - [X] Zavést složku /data
- [ ] Mazat z databáze data z minulosti, aktuálně je to k ničemu - [ ] 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) - [X] Skripty pro snadné spuštění vývoje na Windows (ekvivalent ./run_dev.sh) — run_dev.ps1
- [ ] Implementovat Pizza day - [ ] Implementovat Pizza day
- [ ] Zobrazit upozornění před smazáním/zamknutím/odemknutím pizza day - [X] 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 - [ ] 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() - [ ] 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í - [ ] Ceny krabic za pizzu jsou napevno v kódu - problém, pokud se někdy změní
+6
View File
@@ -9,6 +9,9 @@
<meta name="description" content="Moderní webová aplikace pro lepší správu obědových preferencí" /> <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="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=M+PLUS+Rounded+1c:wght@500;800&family=Nunito:wght@600;800;900&family=Fredoka:wght@500;600;700&family=Shippori+Mincho:wght@600;700;800&family=Zen+Maru+Gothic:wght@500;700;900&family=Yusei+Magic&family=Dela+Gothic+One&display=swap" rel="stylesheet" />
<title>Luncher</title> <title>Luncher</title>
<script> <script>
(function() { (function() {
@@ -24,6 +27,9 @@
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
} }
document.documentElement.setAttribute('data-bs-theme', theme); document.documentElement.setAttribute('data-bs-theme', theme);
var style = localStorage.getItem('style_theme');
var styles = ['umamusume', 'mlp', 'hellgirl', 'kon', 'bocchi'];
document.documentElement.setAttribute('data-luncher-theme', styles.indexOf(style) >= 0 ? style : 'simple');
} catch (e) { } catch (e) {
// Fallback pokud localStorage není dostupný // Fallback pokud localStorage není dostupný
document.documentElement.setAttribute('data-bs-theme', 'light'); document.documentElement.setAttribute('data-bs-theme', 'light');
+1
View File
@@ -10,6 +10,7 @@
"@fortawesome/free-regular-svg-icons": "^7.1.0", "@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0", "@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.1.0", "@fortawesome/react-fontawesome": "^3.1.0",
"@sentry/react": "^10.65.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"@types/react": "^19.2.2", "@types/react": "^19.2.2",
+18
View File
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<!-- Netopýr (čelem doprava; JS natáčí dle směru letu) -->
<g fill="#2B2733" stroke="#15121A" stroke-width="1.5" stroke-linejoin="round">
<!-- křídla -->
<path d="M50,52 C34,30 18,30 6,40 C16,42 18,52 14,62 C24,54 34,52 44,58 Z"/>
<path d="M50,52 C66,30 82,30 94,40 C84,42 82,52 86,62 C76,54 66,52 56,58 Z"/>
<!-- tělo -->
<ellipse cx="50" cy="56" rx="9" ry="13"/>
<!-- uši -->
<path d="M44,44 L40,32 L48,42 Z"/>
<path d="M56,44 L60,32 L52,42 Z"/>
</g>
<!-- oči -->
<g fill="#F5C518">
<circle cx="46" cy="50" r="1.8"/>
<circle cx="54" cy="50" r="1.8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 716 B

+26
View File
@@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<!-- Vosa letící doprava (JS překlápí podle směru) -->
<!-- křídla -->
<g fill="#DDEEFF" stroke="#9BB6CC" stroke-width="1" opacity="0.85">
<ellipse cx="46" cy="30" rx="14" ry="8" transform="rotate(-18 46 30)"/>
<ellipse cx="58" cy="32" rx="11" ry="6" transform="rotate(-8 58 32)"/>
</g>
<!-- žihadlo -->
<path fill="#3A2E00" d="M18,50 L6,50 L18,46 Z"/>
<!-- tělo -->
<g stroke="#3A2E00" stroke-width="1.4" stroke-linejoin="round">
<ellipse cx="46" cy="50" rx="30" ry="16" fill="#F5C518"/>
<!-- pruhy -->
<path fill="#2B2200" stroke="none" d="M34,36 q6,14 0,28 q-8,-2 -10,-14 q2,-12 10,-14 z"/>
<path fill="#2B2200" stroke="none" d="M50,35 q6,15 0,30 q-8,-3 -8,-15 q0,-12 8,-15 z"/>
<!-- hlava -->
<circle cx="72" cy="50" r="10" fill="#2B2200"/>
</g>
<!-- oko a tykadla -->
<circle fill="#FFFFFF" cx="75" cy="47" r="2.4"/>
<circle fill="#20313F" cx="75.6" cy="47" r="1.2"/>
<g stroke="#2B2200" stroke-width="1.2" fill="none" stroke-linecap="round">
<path d="M78,42 C84,36 88,36 90,38"/>
<path d="M80,44 C86,40 90,41 92,43"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+18
View File
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<!-- Pták letící doprava (JS překlápí podle směru) -->
<g stroke="#3A4A5A" stroke-width="1.5" stroke-linejoin="round">
<!-- ocas -->
<path fill="#5C6B7A" d="M18,52 L4,44 L6,56 L2,64 L18,60 Z"/>
<!-- tělo -->
<path fill="#6E7F90" d="M16,58 C24,44 44,40 64,46 C74,49 82,54 84,56 C78,62 66,66 52,66 C36,66 22,64 16,58 Z"/>
<!-- křídlo (nahoru) -->
<path fill="#55636F" d="M40,52 C46,30 60,24 70,26 C64,38 58,48 54,56 C50,58 44,57 40,52 Z"/>
<!-- hlava -->
<circle fill="#6E7F90" cx="78" cy="50" r="12"/>
</g>
<!-- zobák -->
<path fill="#F6A21E" stroke="#B9740A" stroke-width="1" stroke-linejoin="round" d="M88,48 L98,50 L88,54 Z"/>
<!-- oko -->
<circle fill="#20313F" cx="82" cy="47" r="2.2"/>
<circle fill="#FFFFFF" cx="82.8" cy="46.3" r="0.7"/>
</svg>

After

Width:  |  Height:  |  Size: 898 B

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<g stroke="#1B3F73" stroke-width="1.5" stroke-linejoin="round">
<path fill="#4A8FE7" d="M48,32 C30,10 8,14 8,34 C8,50 30,52 48,50 Z"/>
<path fill="#4A8FE7" d="M52,32 C70,10 92,14 92,34 C92,50 70,52 52,50 Z"/>
<path fill="#4A8FE7" d="M47,50 C34,54 16,58 18,74 C20,86 40,84 47,66 Z"/>
<path fill="#4A8FE7" d="M53,50 C66,54 84,58 82,74 C80,86 60,84 53,66 Z"/>
</g>
<g fill="#BBD7FF" stroke="none">
<circle cx="20" cy="30" r="4"/>
<circle cx="80" cy="30" r="4"/>
<circle cx="30" cy="70" r="3"/>
<circle cx="70" cy="70" r="3"/>
</g>
<g stroke="#17233B" stroke-width="1.6" fill="none" stroke-linecap="round">
<path d="M49,28 C44,18 40,14 36,11"/>
<path d="M51,28 C56,18 60,14 64,11"/>
</g>
<g fill="#17233B" stroke="none">
<circle cx="36" cy="11" r="1.8"/>
<circle cx="64" cy="11" r="1.8"/>
<rect x="47.5" y="24" width="5" height="52" rx="2.5"/>
<circle cx="50" cy="24" r="4.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+66
View File
@@ -0,0 +1,66 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<defs>
<radialGradient id="goldGlow" cx="0.5" cy="0.5" r="0.55">
<stop offset="0%" stop-color="#FFF6C8" stop-opacity="0.9"/>
<stop offset="55%" stop-color="#FFD24A" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#FFD24A" stop-opacity="0"/>
</radialGradient>
<linearGradient id="goldWing" x1="0" y1="0" x2="0.7" y2="1">
<stop offset="0%" stop-color="#FFFBE6"/>
<stop offset="35%" stop-color="#FFDE6A"/>
<stop offset="70%" stop-color="#F5B617"/>
<stop offset="100%" stop-color="#C97B00"/>
</linearGradient>
<linearGradient id="goldSheen" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.9"/>
<stop offset="100%" stop-color="#FFFFFF" stop-opacity="0"/>
</linearGradient>
</defs>
<!-- Zářivá aura -->
<circle cx="50" cy="48" r="48" fill="url(#goldGlow)"/>
<!-- Křídla -->
<g stroke="#8A5A00" stroke-width="1.6" stroke-linejoin="round">
<path fill="url(#goldWing)" d="M48,32 C30,8 6,12 6,34 C6,52 30,54 48,50 Z"/>
<path fill="url(#goldWing)" d="M52,32 C70,8 94,12 94,34 C94,52 70,54 52,50 Z"/>
<path fill="url(#goldWing)" d="M47,50 C33,54 14,58 16,76 C18,89 41,86 47,66 Z"/>
<path fill="url(#goldWing)" d="M53,50 C67,54 86,58 84,76 C82,89 59,86 53,66 Z"/>
</g>
<!-- Drahokamové body na křídlech -->
<g fill="#FFF8D8" stroke="#E8B23A" stroke-width="0.6">
<circle cx="20" cy="30" r="4.5"/>
<circle cx="80" cy="30" r="4.5"/>
<circle cx="29" cy="72" r="3.5"/>
<circle cx="71" cy="72" r="3.5"/>
</g>
<g fill="#B8860B">
<circle cx="20" cy="30" r="1.6"/>
<circle cx="80" cy="30" r="1.6"/>
</g>
<!-- Lesklý přeliv přes horní křídla -->
<path fill="url(#goldSheen)" opacity="0.55" d="M48,33 C34,15 16,17 12,31 C24,26 40,27 48,40 Z"/>
<!-- Tykadla -->
<g stroke="#6A4A00" stroke-width="1.6" fill="none" stroke-linecap="round">
<path d="M49,28 C44,17 40,13 36,10"/>
<path d="M51,28 C56,17 60,13 64,10"/>
</g>
<!-- Tělo -->
<g fill="#5A3E00" stroke="none">
<circle cx="36" cy="10" r="2"/>
<circle cx="64" cy="10" r="2"/>
<rect x="47" y="24" width="6" height="52" rx="3"/>
<circle cx="50" cy="24" r="5"/>
</g>
<!-- Jiskry -->
<g fill="#FFFFFF">
<path d="M50 2 l1.4 3.4 l3.4 1.4 l-3.4 1.4 l-1.4 3.4 l-1.4 -3.4 l-3.4 -1.4 l3.4 -1.4 z"/>
<path d="M90 20 l1 2.6 l2.6 1 l-2.6 1 l-1 2.6 l-1 -2.6 l-2.6 -1 l2.6 -1 z" opacity="0.85"/>
<path d="M12 66 l0.9 2.3 l2.3 0.9 l-2.3 0.9 l-0.9 2.3 l-0.9 -2.3 l-2.3 -0.9 l2.3 -0.9 z" opacity="0.8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+34
View File
@@ -0,0 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<defs>
<linearGradient id="mothWing" x1="0" y1="0" x2="0.6" y2="1">
<stop offset="0%" stop-color="#5A5560"/>
<stop offset="60%" stop-color="#3A3540"/>
<stop offset="100%" stop-color="#211E26"/>
</linearGradient>
</defs>
<g stroke="#15121A" stroke-width="1.5" stroke-linejoin="round">
<path fill="url(#mothWing)" d="M48,34 C32,16 10,20 10,36 C10,50 30,50 48,48 Z"/>
<path fill="url(#mothWing)" d="M52,34 C68,16 90,20 90,36 C90,50 70,50 52,48 Z"/>
<path fill="url(#mothWing)" d="M47,49 C35,52 20,56 22,70 C24,80 42,78 47,62 Z"/>
<path fill="url(#mothWing)" d="M53,49 C65,52 80,56 78,70 C76,80 58,78 53,62 Z"/>
</g>
<!-- zlověstné oční skvrny -->
<g fill="#C9302C" stroke="#3a0d0b" stroke-width="1">
<circle cx="24" cy="34" r="4"/>
<circle cx="76" cy="34" r="4"/>
</g>
<g fill="#1a0605">
<circle cx="24" cy="34" r="1.6"/>
<circle cx="76" cy="34" r="1.6"/>
</g>
<!-- huňatá tykadla -->
<g stroke="#15121A" stroke-width="1.8" fill="none" stroke-linecap="round">
<path d="M49,30 C44,20 39,16 34,14"/>
<path d="M51,30 C56,20 61,16 66,14"/>
</g>
<!-- tělo -->
<g fill="#15121A" stroke="none">
<rect x="47" y="26" width="6" height="46" rx="3"/>
<circle cx="50" cy="26" r="5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+79
View File
@@ -0,0 +1,79 @@
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
width="100"
height="100">
<defs>
<linearGradient id="netFillGold" x1="0" y1="0" x2="0.4" y2="1">
<stop offset="0%" stop-color="#FFF3C4" stop-opacity="0.28"/>
<stop offset="100%" stop-color="#F0C24A" stop-opacity="0.12"/>
</linearGradient>
<clipPath id="netBagClipGold">
<path d="
M 36 8
C 21 8, 9 20, 9 35
C 9 51, 13 70, 19 84
C 22 92, 27 94, 31 87
C 40 72, 54 51, 63 35
C 63 20, 51 8, 36 8
Z"/>
</clipPath>
</defs>
<!-- Síťový vak za obručí -->
<path d="
M 36 8
C 21 8, 9 20, 9 35
C 9 51, 13 70, 19 84
C 22 92, 27 94, 31 87
C 40 72, 54 51, 63 35
C 63 20, 51 8, 36 8
Z"
fill="url(#netFillGold)"
stroke="#E8B23A"
stroke-width="1"
opacity="0.85"/>
<!-- Výplet síťky -->
<g clip-path="url(#netBagClipGold)"
fill="none"
stroke="#E8B23A"
stroke-width="0.8"
opacity="0.7"
stroke-linecap="round">
<path d="M 16 17 C 15 42, 19 69, 25 90"/>
<path d="M 25 10 C 23 39, 24 69, 25 90"/>
<path d="M 36 8 C 34 39, 30 69, 25 90"/>
<path d="M 47 10 C 44 40, 36 70, 25 90"/>
<path d="M 56 18 C 51 44, 41 72, 25 90"/>
<path d="M 62 30 C 55 51, 43 75, 25 90"/>
<path d="M 9 29 C 13 49, 18 72, 25 90"/>
<path d="M 10 42 C 14 60, 19 78, 25 90"/>
<path d="M 16 17 C 28 22, 45 22, 56 18"/>
<path d="M 10 28 C 25 34, 47 34, 62 29"/>
<path d="M 9 40 C 25 46, 45 47, 59 42"/>
<path d="M 11 52 C 24 58, 41 59, 53 54"/>
<path d="M 13 64 C 24 69, 36 70, 45 66"/>
<path d="M 16 75 C 24 79, 31 80, 38 77"/>
<path d="M 20 84 C 24 87, 28 87, 32 84"/>
</g>
<!-- Rukojeť -->
<line x1="57" y1="57" x2="93" y2="93" stroke="#8B5A2B" stroke-width="6" stroke-linecap="round"/>
<line x1="57" y1="57" x2="93" y2="93" stroke="#A9713A" stroke-width="3" stroke-linecap="round"/>
<!-- Kovové napojení rukojeti -->
<line x1="56" y1="55" x2="61" y2="61" stroke="#B8860B" stroke-width="6" stroke-linecap="round"/>
<line x1="56" y1="55" x2="61" y2="61" stroke="#FFE79A" stroke-width="2.5" stroke-linecap="round"/>
<!-- Zlatá obruč -->
<circle cx="36" cy="35" r="27" fill="none" stroke="#C8891A" stroke-width="4.5"/>
<circle cx="36" cy="35" r="27" fill="none" stroke="#FFD24A" stroke-width="2"/>
<!-- Třpytky -->
<g fill="#FFF7D6" stroke="none">
<path d="M 33 6 l 1.2 3 l 3 1.2 l -3 1.2 l -1.2 3 l -1.2 -3 l -3 -1.2 l 3 -1.2 z"/>
<path d="M 60 40 l 1 2.4 l 2.4 1 l -2.4 1 l -1 2.4 l -1 -2.4 l -2.4 -1 l 2.4 -1 z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

+74
View File
@@ -0,0 +1,74 @@
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
width="100"
height="100">
<defs>
<linearGradient id="netFillTorn" x1="0" y1="0" x2="0.4" y2="1">
<stop offset="0%" stop-color="#E4EDF5" stop-opacity="0.14"/>
<stop offset="100%" stop-color="#B9C4CF" stop-opacity="0.06"/>
</linearGradient>
<!-- Vak s vyříznutou dírou uprostřed -->
<clipPath id="netBagClipTorn">
<path d="
M 36 8 C 21 8, 9 20, 9 35 C 9 51, 13 70, 19 84
C 22 92, 27 94, 31 87 C 40 72, 54 51, 63 35 C 63 20, 51 8, 36 8 Z
M 27 40 C 20 44, 22 55, 30 58 C 38 61, 46 54, 43 46 C 41 40, 33 37, 27 40 Z"
clip-rule="evenodd"/>
</clipPath>
</defs>
<!-- Síťový vak (s dírou) -->
<path d="
M 36 8 C 21 8, 9 20, 9 35 C 9 51, 13 70, 19 84
C 22 92, 27 94, 31 87 C 40 72, 54 51, 63 35 C 63 20, 51 8, 36 8 Z
M 27 40 C 20 44, 22 55, 30 58 C 38 61, 46 54, 43 46 C 41 40, 33 37, 27 40 Z"
fill="url(#netFillTorn)"
stroke="#AEBBC8"
stroke-width="1"
opacity="0.7"
fill-rule="evenodd"/>
<!-- Výplet síťky (oříznutý dírou) -->
<g clip-path="url(#netBagClipTorn)"
fill="none"
stroke="#AEBBC8"
stroke-width="0.8"
opacity="0.55"
stroke-linecap="round">
<path d="M 16 17 C 15 42, 19 69, 25 90"/>
<path d="M 25 10 C 23 39, 24 69, 25 90"/>
<path d="M 36 8 C 34 39, 30 69, 25 90"/>
<path d="M 47 10 C 44 40, 36 70, 25 90"/>
<path d="M 56 18 C 51 44, 41 72, 25 90"/>
<path d="M 62 30 C 55 51, 43 75, 25 90"/>
<path d="M 9 29 C 13 49, 18 72, 25 90"/>
<path d="M 10 42 C 14 60, 19 78, 25 90"/>
<path d="M 16 17 C 28 22, 45 22, 56 18"/>
<path d="M 13 64 C 24 69, 36 70, 45 66"/>
<path d="M 16 75 C 24 79, 31 80, 38 77"/>
<path d="M 20 84 C 24 87, 28 87, 32 84"/>
</g>
<!-- Roztřepená vlákna kolem díry -->
<g stroke="#9AA7B4" stroke-width="0.8" fill="none" stroke-linecap="round" opacity="0.8">
<path d="M 27 40 l -3 -2"/>
<path d="M 43 46 l 3 -1"/>
<path d="M 30 58 l -1 3"/>
<path d="M 38 52 l 3 2"/>
<path d="M 24 49 l -3 1"/>
</g>
<!-- Rukojeť -->
<line x1="57" y1="57" x2="93" y2="93" stroke="#8B5A2B" stroke-width="6" stroke-linecap="round"/>
<line x1="57" y1="57" x2="93" y2="93" stroke="#A9713A" stroke-width="3" stroke-linecap="round"/>
<!-- Kovové napojení rukojeti -->
<line x1="56" y1="55" x2="61" y2="61" stroke="#71808E" stroke-width="6" stroke-linecap="round"/>
<line x1="56" y1="55" x2="61" y2="61" stroke="#B8C4CF" stroke-width="2.5" stroke-linecap="round"/>
<!-- Obruč -->
<circle cx="36" cy="35" r="27" fill="none" stroke="#7A8896" stroke-width="4.5"/>
<circle cx="36" cy="35" r="27" fill="none" stroke="#AEBBC8" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

+106
View File
@@ -0,0 +1,106 @@
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
width="100"
height="100">
<defs>
<linearGradient id="netFill" x1="0" y1="0" x2="0.4" y2="1">
<stop offset="0%" stop-color="#E4EDF5" stop-opacity="0.18"/>
<stop offset="100%" stop-color="#B9C4CF" stop-opacity="0.08"/>
</linearGradient>
<!-- Celý tvar síťového vaku -->
<clipPath id="netBagClip">
<path d="
M 36 8
C 21 8, 9 20, 9 35
C 9 51, 13 70, 19 84
C 22 92, 27 94, 31 87
C 40 72, 54 51, 63 35
C 63 20, 51 8, 36 8
Z"/>
</clipPath>
</defs>
<!-- Síťový vak za obručí -->
<path d="
M 36 8
C 21 8, 9 20, 9 35
C 9 51, 13 70, 19 84
C 22 92, 27 94, 31 87
C 40 72, 54 51, 63 35
C 63 20, 51 8, 36 8
Z"
fill="url(#netFill)"
stroke="#AEBBC8"
stroke-width="1"
opacity="0.7"/>
<!-- Výplet síťky -->
<g clip-path="url(#netBagClip)"
fill="none"
stroke="#AEBBC8"
stroke-width="0.8"
opacity="0.6"
stroke-linecap="round">
<!-- Vlákna začínající na horní a boční obruči -->
<path d="M 16 17 C 15 42, 19 69, 25 90"/>
<path d="M 25 10 C 23 39, 24 69, 25 90"/>
<path d="M 36 8 C 34 39, 30 69, 25 90"/>
<path d="M 47 10 C 44 40, 36 70, 25 90"/>
<path d="M 56 18 C 51 44, 41 72, 25 90"/>
<path d="M 62 30 C 55 51, 43 75, 25 90"/>
<!-- Vlákna od levého okraje obruče -->
<path d="M 9 29 C 13 49, 18 72, 25 90"/>
<path d="M 10 42 C 14 60, 19 78, 25 90"/>
<!-- Příčná oka -->
<path d="M 16 17 C 28 22, 45 22, 56 18"/>
<path d="M 10 28 C 25 34, 47 34, 62 29"/>
<path d="M 9 40 C 25 46, 45 47, 59 42"/>
<path d="M 11 52 C 24 58, 41 59, 53 54"/>
<path d="M 13 64 C 24 69, 36 70, 45 66"/>
<path d="M 16 75 C 24 79, 31 80, 38 77"/>
<path d="M 20 84 C 24 87, 28 87, 32 84"/>
</g>
<!-- Rukojeť -->
<line x1="57" y1="57"
x2="93" y2="93"
stroke="#8B5A2B"
stroke-width="6"
stroke-linecap="round"/>
<line x1="57" y1="57"
x2="93" y2="93"
stroke="#A9713A"
stroke-width="3"
stroke-linecap="round"/>
<!-- Kovové napojení rukojeti -->
<line x1="56" y1="55"
x2="61" y2="61"
stroke="#71808E"
stroke-width="6"
stroke-linecap="round"/>
<line x1="56" y1="55"
x2="61" y2="61"
stroke="#B8C4CF"
stroke-width="2.5"
stroke-linecap="round"/>
<!-- Hlavní obruč – nezměněná -->
<circle cx="36" cy="35" r="27"
fill="none"
stroke="#7A8896"
stroke-width="4.5"/>
<circle cx="36" cy="35" r="27"
fill="none"
stroke="#AEBBC8"
stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<g stroke="#7A3B00" stroke-width="1.5" stroke-linejoin="round">
<path fill="#F59E1E" d="M48,32 C30,10 8,14 8,34 C8,50 30,52 48,50 Z"/>
<path fill="#F59E1E" d="M52,32 C70,10 92,14 92,34 C92,50 70,52 52,50 Z"/>
<path fill="#F59E1E" d="M47,50 C34,54 16,58 18,74 C20,86 40,84 47,66 Z"/>
<path fill="#F59E1E" d="M53,50 C66,54 84,58 82,74 C80,86 60,84 53,66 Z"/>
</g>
<g fill="#FFE0A3" stroke="none">
<circle cx="20" cy="30" r="4"/>
<circle cx="80" cy="30" r="4"/>
<circle cx="30" cy="70" r="3"/>
<circle cx="70" cy="70" r="3"/>
</g>
<g stroke="#2B1A0B" stroke-width="1.6" fill="none" stroke-linecap="round">
<path d="M49,28 C44,18 40,14 36,11"/>
<path d="M51,28 C56,18 60,14 64,11"/>
</g>
<g fill="#2B1A0B" stroke="none">
<circle cx="36" cy="11" r="1.8"/>
<circle cx="64" cy="11" r="1.8"/>
<rect x="47.5" y="24" width="5" height="52" rx="2.5"/>
<circle cx="50" cy="24" r="4.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<g stroke="#8A2255" stroke-width="1.5" stroke-linejoin="round">
<path fill="#F06B9B" d="M48,32 C30,10 8,14 8,34 C8,50 30,52 48,50 Z"/>
<path fill="#F06B9B" d="M52,32 C70,10 92,14 92,34 C92,50 70,52 52,50 Z"/>
<path fill="#F06B9B" d="M47,50 C34,54 16,58 18,74 C20,86 40,84 47,66 Z"/>
<path fill="#F06B9B" d="M53,50 C66,54 84,58 82,74 C80,86 60,84 53,66 Z"/>
</g>
<g fill="#FFD1E3" stroke="none">
<circle cx="20" cy="30" r="4"/>
<circle cx="80" cy="30" r="4"/>
<circle cx="30" cy="70" r="3"/>
<circle cx="70" cy="70" r="3"/>
</g>
<g stroke="#3B0E24" stroke-width="1.6" fill="none" stroke-linecap="round">
<path d="M49,28 C44,18 40,14 36,11"/>
<path d="M51,28 C56,18 60,14 64,11"/>
</g>
<g fill="#3B0E24" stroke="none">
<circle cx="36" cy="11" r="1.8"/>
<circle cx="64" cy="11" r="1.8"/>
<rect x="47.5" y="24" width="5" height="52" rx="2.5"/>
<circle cx="50" cy="24" r="4.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<g stroke="#8A6D00" stroke-width="1.5" stroke-linejoin="round">
<path fill="#FFD23F" d="M48,32 C30,10 8,14 8,34 C8,50 30,52 48,50 Z"/>
<path fill="#FFD23F" d="M52,32 C70,10 92,14 92,34 C92,50 70,52 52,50 Z"/>
<path fill="#FFD23F" d="M47,50 C34,54 16,58 18,74 C20,86 40,84 47,66 Z"/>
<path fill="#FFD23F" d="M53,50 C66,54 84,58 82,74 C80,86 60,84 53,66 Z"/>
</g>
<g fill="#FFF3C4" stroke="none">
<circle cx="20" cy="30" r="4"/>
<circle cx="80" cy="30" r="4"/>
<circle cx="30" cy="70" r="3"/>
<circle cx="70" cy="70" r="3"/>
</g>
<g stroke="#3A2E00" stroke-width="1.6" fill="none" stroke-linecap="round">
<path d="M49,28 C44,18 40,14 36,11"/>
<path d="M51,28 C56,18 60,14 64,11"/>
</g>
<g fill="#3A2E00" stroke="none">
<circle cx="36" cy="11" r="1.8"/>
<circle cx="64" cy="11" r="1.8"/>
<rect x="47.5" y="24" width="5" height="52" rx="2.5"/>
<circle cx="50" cy="24" r="4.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+22
View File
@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<defs>
<radialGradient id="coinFace" cx="0.4" cy="0.35" r="0.75">
<stop offset="0%" stop-color="#FFE9A3"/>
<stop offset="60%" stop-color="#FFCB3D"/>
<stop offset="100%" stop-color="#E0A200"/>
</radialGradient>
</defs>
<circle cx="50" cy="50" r="42" fill="#C88A12"/>
<circle cx="50" cy="50" r="38" fill="url(#coinFace)" stroke="#F2D774" stroke-width="2"/>
<circle cx="50" cy="50" r="30" fill="none" stroke="#B8860B" stroke-width="2" opacity="0.6"/>
<!-- stylizovaný motýlek na minci -->
<g fill="#B8860B" opacity="0.85">
<path d="M50,42 C42,32 30,34 30,44 C30,52 42,52 50,50 Z"/>
<path d="M50,42 C58,32 70,34 70,44 C70,52 58,52 50,50 Z"/>
<path d="M49,50 C42,52 34,56 36,64 C38,70 48,66 49,58 Z"/>
<path d="M51,50 C58,52 66,56 64,64 C62,70 52,66 51,58 Z"/>
<rect x="48.5" y="40" width="3" height="26" rx="1.5"/>
</g>
<!-- lesk -->
<path d="M 30 30 C 38 24, 50 24, 58 28" fill="none" stroke="#FFF7DC" stroke-width="3" stroke-linecap="round" opacity="0.7"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<!-- vlákno -->
<line x1="50" y1="0" x2="50" y2="30" stroke="#8A8A8A" stroke-width="1.5"/>
<g stroke="#15121A" stroke-width="3" fill="none" stroke-linecap="round">
<!-- nohy -->
<path d="M40,52 C24,44 18,52 12,46"/>
<path d="M40,58 C24,58 16,66 10,64"/>
<path d="M40,64 C26,70 20,80 12,82"/>
<path d="M40,70 C30,80 28,90 22,94"/>
<path d="M60,52 C76,44 82,52 88,46"/>
<path d="M60,58 C76,58 84,66 90,64"/>
<path d="M60,64 C74,70 80,80 88,82"/>
<path d="M60,70 C70,80 72,90 78,94"/>
</g>
<!-- tělo -->
<g stroke="#15121A" stroke-width="1.5">
<ellipse cx="50" cy="46" rx="11" ry="9" fill="#2B2733"/>
<ellipse cx="50" cy="64" rx="15" ry="16" fill="#211E26"/>
<!-- přesýpací hodiny (varovná značka) -->
<path d="M50,58 l5,6 l-5,6 l-5,-6 z" fill="#C9302C" stroke="none"/>
</g>
<!-- oči -->
<g fill="#F5C518">
<circle cx="46" cy="44" r="1.6"/>
<circle cx="54" cy="44" r="1.6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+10 -7
View File
@@ -1,4 +1,4 @@
// Service Worker pro Web Push notifikace (připomínka výběru oběda) // Service Worker pro Web Push notifikace (připomínka výběru oběda, doručení objednávky)
self.addEventListener('push', (event) => { self.addEventListener('push', (event) => {
const data = event.data?.json() ?? { title: 'Luncher', body: 'Ještě nemáte zvolený oběd!' }; const data = event.data?.json() ?? { title: 'Luncher', body: 'Ještě nemáte zvolený oběd!' };
@@ -6,11 +6,12 @@ self.addEventListener('push', (event) => {
self.registration.showNotification(data.title, { self.registration.showNotification(data.title, {
body: data.body, body: data.body,
icon: '/favicon.ico', icon: '/favicon.ico',
tag: 'lunch-reminder', tag: data.tag ?? 'lunch-reminder',
data: { login: data.login, token: data.token }, data: { login: data.login, token: data.token, url: data.url },
actions: [ // Token posílá jen připomínka oběda — ostatní notifikace tlačítko nemají.
{ action: 'neobedvam', title: 'Mám vlastní/neobědvám' }, actions: data.token
], ? [{ action: 'neobedvam', title: 'Mám vlastní/neobědvám' }]
: [],
}) })
); );
}); });
@@ -32,14 +33,16 @@ self.addEventListener('notificationclick', (event) => {
return; return;
} }
const url = event.notification.data?.url ?? '/';
event.waitUntil( event.waitUntil(
self.clients.matchAll({ type: 'window' }).then((clientList) => { self.clients.matchAll({ type: 'window' }).then((clientList) => {
for (const client of clientList) { for (const client of clientList) {
if (client.url.includes(self.location.origin) && 'focus' in client) { if (client.url.includes(self.location.origin) && 'focus' in client) {
if ('navigate' in client) client.navigate(url).catch(() => {});
return client.focus(); return client.focus();
} }
} }
return self.clients.openWindow('/'); return self.clients.openWindow(url);
}) })
); );
}); });
+86 -12
View File
@@ -10,19 +10,29 @@ import PizzaOrderList from './components/PizzaOrderList';
import SelectSearch, { SelectedOptionValue, SelectSearchOption } from 'react-select-search'; import SelectSearch, { SelectedOptionValue, SelectSearchOption } from 'react-select-search';
import 'react-select-search/style.css'; import 'react-select-search/style.css';
import './App.scss'; import './App.scss';
import './themes/umamusume.scss';
import './themes/mlp.scss';
import './themes/hellgirl.scss';
import './themes/kon.scss';
import './themes/bocchi.scss';
import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons'; import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons';
import { useSettings } from './context/settings'; import { useSettings } from './context/settings';
import Footer from './components/Footer'; import Footer from './components/Footer';
import { faArrowUpRightFromSquare, faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faMoneyBillTransfer, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'; import { faArrowUpRightFromSquare, faBasketShopping, faTag, faChainBroken, faChevronLeft, faChevronRight, faDharmachakra, faGear, faMoneyBillTransfer, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import Loader from './components/Loader'; import Loader from './components/Loader';
import { getHumanDateTime, isInTheFuture } from './Utils'; import { getHumanDateTime, isInTheFuture } from './Utils';
import NoteModal from './components/modals/NoteModal'; import NoteModal from './components/modals/NoteModal';
import PriceModal from './components/modals/PriceModal';
import { parsePriceCzk } from './utils/parsePrice';
import PayForAllModal from './components/modals/PayForAllModal'; import PayForAllModal from './components/modals/PayForAllModal';
import SpinWheelModal, { WheelPick } from './components/modals/SpinWheelModal';
import EaterBadge from './components/EaterBadge';
import PendingPayments from './components/PendingPayments'; import PendingPayments from './components/PendingPayments';
import { useEasterEgg } from './context/eggs'; import { useEasterEgg } from './context/eggs';
import { ClientData, Food, MealSlot, PendingQr, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, LocationLunchChoicesMap, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime, setBuyer, generateQr } from '../../types'; import { ClientData, Food, MealSlot, PendingQr, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, LocationLunchChoicesMap, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, updatePrice, changeDepartureTime, setBuyer, generateQr } from '../../types';
import { getLunchChoiceName } from './enums'; import { CHOICES_WITHOUT_PRICE, getLunchChoiceName } from './enums';
import { isArrowNavigationBlocked } from './utils/arrowNavigation';
// import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves'; // import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves';
// import './FallingLeaves.scss'; // import './FallingLeaves.scss';
@@ -63,13 +73,15 @@ function App() {
const settings = useSettings(); const settings = useSettings();
const navigate = useNavigate(); const navigate = useNavigate();
const [easterEgg, _] = useEasterEgg(auth); const [easterEgg, _] = useEasterEgg(auth);
const [isConnected, setIsConnected] = useState<boolean>(false); const socket = useContext(SocketContext);
// Socket je singleton mimo React, takže při návratu v historii prohlížeče (remount
// komponenty) už žádný "connect" event nepřijde - stav proto inicializujeme z něj
const [isConnected, setIsConnected] = useState<boolean>(() => socket.connected);
const [data, setData] = useState<ClientData>(); const [data, setData] = useState<ClientData>();
const [food, setFood] = useState<RestaurantDayMenuMap>(); const [food, setFood] = useState<RestaurantDayMenuMap>();
const [myOrder, setMyOrder] = useState<PizzaOrder>(); const [myOrder, setMyOrder] = useState<PizzaOrder>();
const [foodChoiceList, setFoodChoiceList] = useState<Food[]>(); const [foodChoiceList, setFoodChoiceList] = useState<Food[]>();
const [closed, setClosed] = useState<boolean>(false); const [closed, setClosed] = useState<boolean>(false);
const socket = useContext(SocketContext);
const choiceRef = useRef<HTMLSelectElement>(null); const choiceRef = useRef<HTMLSelectElement>(null);
const foodChoiceRef = useRef<HTMLSelectElement>(null); const foodChoiceRef = useRef<HTMLSelectElement>(null);
const departureChoiceRef = useRef<HTMLSelectElement>(null); const departureChoiceRef = useRef<HTMLSelectElement>(null);
@@ -78,7 +90,9 @@ function App() {
const [dayIndex, setDayIndex] = useState<number>(); const [dayIndex, setDayIndex] = useState<number>();
const [loadingPizzaDay, setLoadingPizzaDay] = useState<boolean>(false); const [loadingPizzaDay, setLoadingPizzaDay] = useState<boolean>(false);
const [noteModalOpen, setNoteModalOpen] = useState<boolean>(false); const [noteModalOpen, setNoteModalOpen] = useState<boolean>(false);
const [priceModalOpen, setPriceModalOpen] = useState<boolean>(false);
const [payForAllLocationKey, setPayForAllLocationKey] = useState<LunchChoice | null>(null); const [payForAllLocationKey, setPayForAllLocationKey] = useState<LunchChoice | null>(null);
const [spinWheelOpen, setSpinWheelOpen] = useState<boolean>(false);
const [eggImage, setEggImage] = useState<Blob>(); const [eggImage, setEggImage] = useState<Blob>();
const eggRef = useRef<HTMLImageElement>(null); const eggRef = useRef<HTMLImageElement>(null);
// Prazvláštní workaround, aby socket.io listener viděl aktuální hodnotu // Prazvláštní workaround, aby socket.io listener viděl aktuální hodnotu
@@ -121,6 +135,8 @@ function App() {
// Registrace socket eventů // Registrace socket eventů
useEffect(() => { useEffect(() => {
// Srovnání se skutečným stavem socketu (mohl se změnit mezi renderem a efektem)
setIsConnected(socket.connected);
socket.on(EVENT_CONNECT, () => { socket.on(EVENT_CONNECT, () => {
setIsConnected(true); setIsConnected(true);
}); });
@@ -232,6 +248,7 @@ function App() {
// Navigace mezi dny pomocí klávesových šípek // Navigace mezi dny pomocí klávesových šípek
const handleKeyDown = useCallback((e: any) => { const handleKeyDown = useCallback((e: any) => {
if (isArrowNavigationBlocked(e)) return;
if (e.keyCode === 37 && dayIndex != null && dayIndex > 0) { if (e.keyCode === 37 && dayIndex != null && dayIndex > 0) {
handleDayChange(dayIndex - 1); handleDayChange(dayIndex - 1);
} else if (e.keyCode === 39 && dayIndex != null && dayIndex < 4) { } else if (e.keyCode === 39 && dayIndex != null && dayIndex < 4) {
@@ -404,6 +421,24 @@ function App() {
} }
} }
// Převzetí výsledku kola štěstí — podnik s menu vybere i jídlo, rozvoz se zapíše do poznámky
const handleWheelPick = async (pick: WheelPick) => {
if (!auth?.login) return;
try {
if (pick.kind === 'menu') {
await addChoice({ body: { locationKey: pick.restaurant, foodIndex: pick.foodIndex, dayIndex } });
} else {
await addChoice({ body: { locationKey: LunchChoice.OBJEDNAVAM, dayIndex } });
// Server přijímá poznámku max. 70 znaků
const note = pick.dish ? `${pick.store}: ${pick.dish}` : pick.store;
await updateNote({ body: { note: note.length > 70 ? note.slice(0, 69) + '…' : note, dayIndex } });
}
await tryAutoSelectDepartureTime();
} catch (error: any) {
alert(`Chyba při změně volby: ${error.message || error}`);
}
}
const saveNote = async (note?: string) => { const saveNote = async (note?: string) => {
if (auth?.login) { if (auth?.login) {
await updateNote({ body: { note, dayIndex } }); await updateNote({ body: { note, dayIndex } });
@@ -411,6 +446,13 @@ function App() {
} }
} }
const savePrice = async (price?: number) => {
if (auth?.login) {
await updatePrice({ body: { price, dayIndex } });
setPriceModalOpen(false);
}
}
const copyNote = async (note: string) => { const copyNote = async (note: string) => {
if (auth?.login && note) { if (auth?.login && note) {
await updateNote({ body: { note, dayIndex } }); await updateNote({ body: { note, dayIndex } });
@@ -513,7 +555,7 @@ function App() {
} }
const handleChangeDepartureTime = async (event: React.ChangeEvent<HTMLSelectElement>) => { const handleChangeDepartureTime = async (event: React.ChangeEvent<HTMLSelectElement>) => {
if (foodChoiceList?.length && choiceRef.current?.value) { if (choiceRef.current?.value) {
await changeDepartureTime({ body: { time: event.target.value as DepartureTime, dayIndex } }); await changeDepartureTime({ body: { time: event.target.value as DepartureTime, dayIndex } });
} }
} }
@@ -528,7 +570,8 @@ function App() {
} }
} }
const handleDayChange = async (dayIndex: number) => { const handleDayChange = async (requestedIndex: number) => {
const dayIndex = Math.max(0, Math.min(4, requestedIndex));
setDayIndex(dayIndex); setDayIndex(dayIndex);
dayIndexRef.current = dayIndex; dayIndexRef.current = dayIndex;
if (choiceRef?.current?.value) { if (choiceRef?.current?.value) {
@@ -582,7 +625,7 @@ function App() {
} else { } else {
content = <div className="restaurant-error">Chyba načtení dat</div> content = <div className="restaurant-error">Chyba načtení dat</div>
} }
return <Col md={6} lg={3} className='mt-3'> return <Col md={6} lg className='mt-3'>
<div className="restaurant-card"> <div className="restaurant-card">
<div className="restaurant-header" style={{ cursor: canChangeChoice ? 'pointer' : 'default' }} onClick={() => doAddClickFoodChoice(location)}> <div className="restaurant-header" style={{ cursor: canChangeChoice ? 'pointer' : 'default' }} onClick={() => doAddClickFoodChoice(location)}>
<div className="restaurant-header-content"> <div className="restaurant-header-content">
@@ -633,6 +676,15 @@ function App() {
const noOrders = data?.pizzaDay?.orders?.length === 0; const noOrders = data?.pizzaDay?.orders?.length === 0;
const canChangeChoice = dayIndex == null || data.todayDayIndex == null || dayIndex >= data.todayDayIndex; const canChangeChoice = dayIndex == null || data.todayDayIndex == null || dayIndex >= data.todayDayIndex;
const ownChoice = auth?.login ? Object.entries(data.choices)
.map(([locationKey, loginMap]) => ({ locationKey: locationKey as LunchChoice, choice: loginMap?.[auth.login!] }))
.find(entry => entry.choice != null) as { locationKey: LunchChoice, choice: UserLunchChoice } | undefined : undefined;
// Součet cen vybraných jídel z menu jako návrh ceny; pokud některou cenu nelze přečíst, nenavrhujeme nic
const ownFoodPrices = ownChoice?.choice.selectedFoods?.map(index => parsePriceCzk(food[ownChoice.locationKey as Restaurant]?.food?.[index]?.price)) ?? [];
const ownSuggestedPrice = ownFoodPrices.length && !ownFoodPrices.includes(null)
? Math.round((ownFoodPrices as number[]).reduce((sum, price) => sum + price, 0) * 100)
: undefined;
const { path, url, startOffset, endOffset, duration, ...style } = easterEgg || {}; const { path, url, startOffset, endOffset, duration, ...style } = easterEgg || {};
return ( return (
@@ -657,7 +709,7 @@ function App() {
</span> </span>
</div> </div>
} }
<Row className='food-tables'> <Row className='food-tables justify-content-center'>
{Object.keys(Restaurant).map(key => { {Object.keys(Restaurant).map(key => {
const locationKey = key as Restaurant; const locationKey = key as Restaurant;
return food[locationKey] && renderFoodTable(locationKey, food[locationKey]); return food[locationKey] && renderFoodTable(locationKey, food[locationKey]);
@@ -672,7 +724,11 @@ function App() {
{Object.entries(LunchChoice) {Object.entries(LunchChoice)
.filter(entry => { .filter(entry => {
const locationKey = entry[0] as Restaurant; const locationKey = entry[0] as Restaurant;
return !food[locationKey]?.closed; // Podniky, pro které server nevrátil menu (dočasně uzavřené), nenabízíme
if (Object.keys(Restaurant).includes(locationKey) && !food?.[locationKey]) {
return false;
}
return !food?.[locationKey]?.closed;
}) })
.map(entry => <option key={entry[0]} value={entry[0]}>{getLunchChoiceName(entry[1])}</option>)} .map(entry => <option key={entry[0]} value={entry[0]}>{getLunchChoiceName(entry[1])}</option>)}
</Form.Select> </Form.Select>
@@ -740,9 +796,11 @@ function App() {
{trusted && <span className='trusted-icon' title='Uživatel ověřený doménovým přihlášením'> {trusted && <span className='trusted-icon' title='Uživatel ověřený doménovým přihlášením'>
<FontAwesomeIcon icon={faCircleCheck} style={{ cursor: "help" }} /> <FontAwesomeIcon icon={faCircleCheck} style={{ cursor: "help" }} />
</span>} </span>}
<EaterBadge items={userChoices?.length ?? 0} />
<strong>{login}</strong> <strong>{login}</strong>
{userPayload.departureTime && <small className="ms-2" style={{ color: 'var(--luncher-text-muted)' }}>({userPayload.departureTime})</small>} {userPayload.departureTime && <small className="ms-2" style={{ color: 'var(--luncher-text-muted)' }}>({userPayload.departureTime})</small>}
{userPayload.note && <span className="ms-2" style={{ fontSize: 'small', color: 'var(--luncher-text-secondary)' }}>({userPayload.note})</span>} {userPayload.note && <span className="ms-2" style={{ fontSize: 'small', color: 'var(--luncher-text-secondary)' }}>({userPayload.note})</span>}
{login === auth.login && userPayload.price != null && <small className="ms-2" style={{ color: 'var(--luncher-text-muted)' }}>{userPayload.price / 100} Kč</small>}
</div> </div>
<div className="user-actions"> <div className="user-actions">
{login === auth.login && canChangeChoice && locationKey === LunchChoice.OBJEDNAVAM && <span title='Označit/odznačit se jako objednávající'> {login === auth.login && canChangeChoice && locationKey === LunchChoice.OBJEDNAVAM && <span title='Označit/odznačit se jako objednávající'>
@@ -763,6 +821,12 @@ function App() {
copyNote(userPayload.note!); copyNote(userPayload.note!);
}} className='action-icon' icon={faComment} /> }} className='action-icon' icon={faComment} />
</span>} </span>}
{login === auth.login && canChangeChoice && locationKey === LunchChoice.ROZHODUJI && <span title='Roztočit kolo štěstí'>
<FontAwesomeIcon onClick={() => setSpinWheelOpen(true)} className='action-icon spin-wheel-icon' icon={faDharmachakra} />
</span>}
{login === auth.login && canChangeChoice && !CHOICES_WITHOUT_PRICE.includes(locationKey) && <span title='Zadat cenu oběda do přehledu stravování'>
<FontAwesomeIcon onClick={() => setPriceModalOpen(true)} className='action-icon' icon={faTag} />
</span>}
{login === auth.login && canChangeChoice && <span title='Upravit poznámku'> {login === auth.login && canChangeChoice && <span title='Upravit poznámku'>
<FontAwesomeIcon onClick={() => { <FontAwesomeIcon onClick={() => {
setNoteModalOpen(true); setNoteModalOpen(true);
@@ -777,9 +841,11 @@ function App() {
</div> </div>
{userChoices && userChoices.length > 0 && food && ( {userChoices && userChoices.length > 0 && food && (
<div className="food-choices"> <div className="food-choices">
{userChoices.map(foodIndex => { {userChoices
.filter(foodIndex => food[key as Restaurant]?.food?.[foodIndex] != null)
.map(foodIndex => {
const restaurantKey = key as Restaurant; const restaurantKey = key as Restaurant;
const foodName = food[restaurantKey]?.food?.[foodIndex].name; const foodName = food[restaurantKey]?.food?.[foodIndex]?.name;
return <div key={foodIndex} className="food-choice-item"> return <div key={foodIndex} className="food-choice-item">
<span className="food-choice-name">{foodName}</span> <span className="food-choice-name">{foodName}</span>
{login === auth.login && canChangeChoice && {login === auth.login && canChangeChoice &&
@@ -924,7 +990,15 @@ function App() {
leafVariants={LEAF_COLOR_THEMES.AUTUMN} leafVariants={LEAF_COLOR_THEMES.AUTUMN}
/> */} /> */}
<Footer /> <Footer />
{spinWheelOpen && <SpinWheelModal isOpen onClose={() => setSpinWheelOpen(false)} menus={food} onPick={handleWheelPick} />}
<NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} /> <NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} />
<PriceModal
isOpen={priceModalOpen}
onClose={() => setPriceModalOpen(false)}
price={ownChoice?.choice.price}
suggestedPrice={ownSuggestedPrice}
onSave={savePrice}
/>
{payForAllLocationKey && data && ( {payForAllLocationKey && data && (
<PayForAllModal <PayForAllModal
isOpen isOpen
+24 -19
View File
@@ -1,52 +1,57 @@
import { Routes, Route } from "react-router-dom"; import { Routes, Route } from "react-router-dom";
import { ProvideSettings } from "./context/settings"; import { ProvideSettings, useSettings } from "./context/settings";
// import Snowfall from "react-snowfall"; import FlyingButterflies, { BUTTERFLY_PRESETS } from "./FlyingButterflies";
import { SnowOverlay } from 'react-snow-overlay'; import "./FlyingButterflies.scss";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import { SocketContext, socket } from "./context/socket"; import { SocketContext, socket } from "./context/socket";
import StatsPage from "./pages/StatsPage"; import StatsPage from "./pages/StatsPage";
import OrderGroupsPage from "./pages/OrderGroupsPage"; import OrderGroupsPage from "./pages/OrderGroupsPage";
import SuggestionsPage from "./pages/SuggestionsPage"; import SuggestionsPage from "./pages/SuggestionsPage";
import App from "./App"; import App from "./App";
import HellGirlFlames from "./components/HellGirlFlames";
export const STATS_URL = '/stats'; export const STATS_URL = '/stats';
export const OBJEDNANI_URL = '/objednani'; export const OBJEDNANI_URL = '/objednani';
export const NAVRHY_URL = '/navrhy'; export const NAVRHY_URL = '/navrhy';
/** Motýlci se vykreslují jen pokud je uživatel nevypnul v nastavení. */
function Butterflies() {
const settings = useSettings();
if (settings?.showButterflies === false) {
return null;
}
return <FlyingButterflies numButterflies={BUTTERFLY_PRESETS.NORMAL} />;
}
/** Doplňkové efekty vizuálních stylů — společné pro všechny stránky (např. plamen při otevření dialogu). */
function StyleThemeEffects() {
const settings = useSettings();
return settings?.styleTheme === 'hellgirl' ? <HellGirlFlames /> : null;
}
export default function AppRoutes() { export default function AppRoutes() {
return ( return (
<ProvideSettings>
<StyleThemeEffects />
<Routes> <Routes>
<Route path={STATS_URL} element={<StatsPage />} /> <Route path={STATS_URL} element={<StatsPage />} />
<Route path={NAVRHY_URL} element={ <Route path={NAVRHY_URL} element={<SuggestionsPage />} />
<ProvideSettings>
<SuggestionsPage />
</ProvideSettings>
} />
<Route path={OBJEDNANI_URL} element={ <Route path={OBJEDNANI_URL} element={
<ProvideSettings>
<SocketContext.Provider value={socket}> <SocketContext.Provider value={socket}>
<OrderGroupsPage /> <OrderGroupsPage />
<ToastContainer /> <ToastContainer />
</SocketContext.Provider> </SocketContext.Provider>
</ProvideSettings>
} /> } />
<Route path="/" element={ <Route path="/" element={
<ProvideSettings>
<SocketContext.Provider value={socket}> <SocketContext.Provider value={socket}>
<> <>
{/* <Snowfall style={{ <Butterflies />
zIndex: 2,
position: 'fixed',
width: '100vw',
height: '100vh'
}} /> */}
<SnowOverlay color={'rgba(240, 240, 240, 0.9)'} disabledOnSingleCpuDevices={true} />
<App /> <App />
</> </>
<ToastContainer /> <ToastContainer />
</SocketContext.Provider> </SocketContext.Provider>
</ProvideSettings>
} /> } />
</Routes> </Routes>
</ProvideSettings>
); );
} }
+749
View File
@@ -0,0 +1,749 @@
.flying-butterflies {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
z-index: 2;
overflow: hidden;
// V režimu hraní herní vrstva zachytává kliknutí, aby se nedalo omylem
// překliknout do objednávek. V režimu objednávání propouští vše dál (none).
&.playing {
pointer-events: auto;
}
}
// Přepínač režimu hraní / objednávání
.butterfly-mode-toggle {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 7;
pointer-events: auto;
padding: 8px 14px;
border: none;
border-radius: 999px;
background: rgba(33, 37, 41, 0.88);
color: #fff;
font-weight: 700;
font-size: 0.9rem;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
backdrop-filter: blur(4px);
transition: background 0.15s ease, transform 0.08s ease;
&:hover { background: rgba(33, 37, 41, 1); }
&:active { transform: scale(0.96); }
&.playing {
background: #2f9e44;
&:hover { background: #2b8a3e; }
}
}
// Vrstva herních prvků (síťka, škůdci, pavučina) – skrývá se v režimu objednávání
.butterfly-game-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.butterfly-scene {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 100%;
// Vnější element – pozici a natočení nastavuje JS přes transform
> div {
position: absolute;
top: 0;
left: 0;
width: 34px;
height: 34px;
will-change: transform;
}
}
// Vnitřní element se sprite motýla – máchá křídly (scaleX kolem svislé osy těla)
.butterfly-sprite {
width: 100%;
height: 100%;
background-repeat: no-repeat;
background-position: center;
background-size: contain;
animation: butterfly-flap 0.45s ease-in-out infinite;
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.15));
// Vzácný zlatý motýl – WOW: silná pulzující zlatá záře + duhový nádech
&.golden {
animation:
butterfly-flap 0.35s ease-in-out infinite,
butterfly-golden-glow 1.4s ease-in-out infinite;
}
}
@keyframes butterfly-golden-glow {
0%,
100% {
filter:
saturate(1.15) hue-rotate(0deg)
drop-shadow(0 0 5px rgba(255, 215, 80, 0.85))
drop-shadow(0 0 10px rgba(255, 180, 40, 0.55))
drop-shadow(0 1px 1px rgba(0, 0, 0, 0.2));
}
50% {
filter:
saturate(1.4) hue-rotate(-16deg)
drop-shadow(0 0 13px rgba(255, 235, 130, 1))
drop-shadow(0 0 24px rgba(255, 190, 50, 0.95))
drop-shadow(0 1px 1px rgba(0, 0, 0, 0.2));
}
}
@keyframes butterfly-flap {
0%,
100% {
transform: scaleX(1);
}
50% {
transform: scaleX(0.35);
}
}
// Korunovaný motýl (anti-bot inspekce) – nepřehlédnutelná duhová záře
.butterfly-sprite.royal {
animation: butterfly-flap 0.35s ease-in-out infinite, butterfly-royal-glow 0.9s ease-in-out infinite;
}
@keyframes butterfly-royal-glow {
0%, 100% {
filter: drop-shadow(0 0 8px rgba(255, 215, 80, 1)) drop-shadow(0 0 16px rgba(120, 200, 255, 0.8));
}
50% {
filter: drop-shadow(0 0 16px rgba(255, 120, 220, 1)) drop-shadow(0 0 28px rgba(120, 255, 160, 0.9));
}
}
// Černá můra – zlověstný tmavý nádech
.butterfly-sprite.moth {
filter: drop-shadow(0 0 4px rgba(120, 60, 160, 0.6)) drop-shadow(0 1px 1px rgba(0, 0, 0, 0.4));
animation: butterfly-flap 0.5s ease-in-out infinite, butterfly-moth-pulse 1.6s ease-in-out infinite;
}
@keyframes butterfly-moth-pulse {
0%, 100% { filter: drop-shadow(0 0 4px rgba(120, 60, 160, 0.5)) drop-shadow(0 1px 1px rgba(0, 0, 0, 0.4)); }
50% { filter: drop-shadow(0 0 9px rgba(150, 40, 40, 0.8)) drop-shadow(0 1px 1px rgba(0, 0, 0, 0.4)); }
}
// --- Havěť (ptáci a vosy) ----------------------------------------------------
.butterfly-critter {
position: absolute;
top: 0;
left: 0;
background-repeat: no-repeat;
background-position: center;
background-size: contain;
pointer-events: none;
will-change: transform;
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.2));
z-index: 3;
// Vosa jde zaklikat (plácačka)
&.wasp {
pointer-events: auto;
cursor: crosshair;
touch-action: none;
}
// Netopýr jde zahnat klikáním
&.bat {
pointer-events: auto;
cursor: crosshair;
touch-action: none;
z-index: 5;
filter: drop-shadow(0 0 6px rgba(60, 40, 90, 0.8));
}
&.hit {
animation: butterfly-stalker-hit 0.18s ease-out;
}
}
// Pavučina přes síťku – blokuje chytání, dokud ji hráč nestrhne
.butterfly-web {
position: fixed;
top: 0;
left: 0;
width: 120px;
height: 120px;
background-image: url(/spider.svg);
background-repeat: no-repeat;
background-position: center;
background-size: contain;
pointer-events: auto;
cursor: crosshair;
touch-action: none;
z-index: 6;
opacity: 0.9;
filter: drop-shadow(0 0 4px rgba(0, 0, 0, 0.4));
&.hit {
animation: butterfly-stalker-hit 0.15s ease-out;
}
}
// --- Síťka na chytání --------------------------------------------------------
.butterfly-net {
position: fixed;
top: 0;
left: 0;
background-repeat: no-repeat;
background-position: center;
background-size: contain;
pointer-events: auto;
cursor: grab;
z-index: 4;
will-change: transform;
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25));
transition: filter 0.15s ease;
touch-action: none;
user-select: none;
&.grabbed {
cursor: grabbing;
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.35));
}
// Prémiová (zlatá) síťka – zlatá záře a jemný pulz
&.premium {
filter: drop-shadow(0 0 8px rgba(255, 200, 60, 0.85)) drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25));
animation: butterfly-net-glow 1.3s ease-in-out infinite;
}
// Protržená síťka – nedá se chytat
&.torn {
cursor: not-allowed;
opacity: 0.9;
filter: drop-shadow(0 2px 3px rgba(150, 30, 30, 0.35));
}
&.catch-pop {
animation: butterfly-net-pop 0.3s ease-out;
}
&.stung {
animation: butterfly-net-stung 0.4s ease-in-out;
}
// Záblesk při chycení černé můry (chyba)
&.moth-hit {
animation: butterfly-net-moth 0.4s ease-out;
}
// Omráčená síťka (po žihnutí vosou) – po celou dobu, kdy nejde chytat.
// Má přednost před grabbed kurzorem.
&.stunned {
cursor: not-allowed !important;
animation: butterfly-net-dizzy 0.9s ease-in-out infinite;
}
}
// Zešednutí + červený nádech, ať je jasné, že síťka teď nechytá.
// NEanimujeme transform (ten řídí JS inline) – jen filter.
@keyframes butterfly-net-dizzy {
0%,
100% {
filter: grayscale(0.7) brightness(0.85) drop-shadow(0 0 5px rgba(217, 72, 15, 0.55));
}
50% {
filter: grayscale(0.85) brightness(0.8) drop-shadow(0 0 9px rgba(217, 72, 15, 0.8));
}
}
@keyframes butterfly-net-pop {
0% {
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25)) brightness(1);
}
40% {
filter: drop-shadow(0 2px 8px rgba(120, 200, 120, 0.7)) brightness(1.25);
}
100% {
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25)) brightness(1);
}
}
@keyframes butterfly-net-glow {
0%,
100% {
filter: drop-shadow(0 0 6px rgba(255, 200, 60, 0.6)) drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25));
}
50% {
filter: drop-shadow(0 0 12px rgba(255, 220, 100, 1)) drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25));
}
}
// Pozn.: NEanimujeme transform – ten nastavuje JS inline (poloha síťky).
// Žihnutí vosou proto naznačíme jen červeným zábleskem filtru.
@keyframes butterfly-net-stung {
0%,
100% {
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25)) brightness(1);
}
30% {
filter: drop-shadow(0 0 8px rgba(217, 72, 15, 0.85)) brightness(1.1) hue-rotate(-15deg);
}
}
// Prchavé texty (chycení, protržení, žihnutí)
.butterfly-catch-fx,
.butterfly-tear-fx,
.butterfly-sting-fx {
position: fixed;
margin: -12px 0 0 10px;
font-weight: 700;
font-size: 1.1rem;
pointer-events: none;
z-index: 5;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.8);
animation: butterfly-catch-float 0.8s ease-out forwards;
}
.butterfly-catch-fx {
color: #2f9e44;
&.golden {
color: #e0a200;
font-size: 1.35rem;
text-shadow: 0 0 6px rgba(255, 220, 100, 0.9), 0 1px 2px rgba(0, 0, 0, 0.3);
}
&.moth {
color: #c92a2a;
font-size: 1.35rem;
text-shadow: 0 0 6px rgba(120, 40, 160, 0.7), 0 1px 2px rgba(0, 0, 0, 0.3);
}
}
@keyframes butterfly-net-moth {
0%, 100% { filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25)) brightness(1); }
30% { filter: drop-shadow(0 0 9px rgba(150, 40, 160, 0.9)) brightness(0.8); }
}
.butterfly-tear-fx {
color: #c92a2a;
}
.butterfly-sting-fx {
color: #d9480f;
}
@keyframes butterfly-catch-float {
0% {
opacity: 0;
transform: translateY(0) scale(0.9);
}
20% {
opacity: 1;
transform: translateY(-6px) scale(1);
}
100% {
opacity: 0;
transform: translateY(-40px) scale(1);
}
}
// --- HUD: počítadlo, mince, úroveň, kombo, hlášky ----------------------------
.butterfly-hud {
position: fixed;
bottom: 16px;
left: 16px;
z-index: 5;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
// HUD sám o sobě neblokuje klikání; interaktivní je jen tlačítko opravy
pointer-events: none;
}
.butterfly-shop-open {
pointer-events: auto;
align-self: flex-start;
padding: 6px 12px;
border: none;
border-radius: 999px;
background: #f59e1e;
color: #fff;
font-weight: 700;
font-size: 0.85rem;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
transition: background 0.15s ease, transform 0.08s ease;
&:hover { background: #e8890a; }
&:active { transform: scale(0.96); }
}
.butterfly-counter {
display: inline-flex;
flex-direction: column;
gap: 4px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.88);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 14px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
backdrop-filter: blur(4px);
font-weight: 600;
font-size: 0.95rem;
color: #333;
user-select: none;
pointer-events: auto;
cursor: pointer;
transition: box-shadow 0.15s ease;
&:hover {
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.22);
}
&.bump {
animation: butterfly-counter-bump 0.3s ease-out;
}
}
.butterfly-counter-row {
display: inline-flex;
align-items: center;
gap: 6px;
}
.butterfly-counter-icon,
.butterfly-coin-icon {
display: inline-block;
width: 20px;
height: 20px;
background-repeat: no-repeat;
background-position: center;
background-size: contain;
}
.butterfly-counter-icon {
background-image: url(/butterfly-orange.svg);
}
.butterfly-coin-icon {
background-image: url(/coin.svg);
margin-left: 4px;
}
.butterfly-counter-value {
min-width: 1ch;
text-align: center;
font-variant-numeric: tabular-nums;
}
.butterfly-level {
display: flex;
flex-direction: column;
gap: 3px;
}
.butterfly-level-title {
font-size: 0.72rem;
font-weight: 600;
color: #6b5b2e;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 180px;
}
.butterfly-progress {
width: 100%;
height: 5px;
background: rgba(0, 0, 0, 0.1);
border-radius: 999px;
overflow: hidden;
}
.butterfly-progress-bar {
height: 100%;
background: linear-gradient(90deg, #ffcb3d, #e0a200);
border-radius: 999px;
transition: width 0.4s ease;
}
.butterfly-pests {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8rem;
font-weight: 700;
}
.butterfly-pest {
color: #868e96;
font-variant-numeric: tabular-nums;
&.warn {
color: #d9480f;
}
&.ok {
color: #2f9e44;
}
}
.butterfly-torn-note {
margin-top: 4px;
font-size: 0.76rem;
font-weight: 600;
color: #c92a2a;
}
.butterfly-action-btn {
margin-top: 4px;
padding: 5px 10px;
border: none;
border-radius: 999px;
background: #1c7ed6;
color: #fff;
font-weight: 700;
font-size: 0.82rem;
cursor: pointer;
pointer-events: auto;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
transition: background 0.15s ease, transform 0.1s ease;
&:hover:not(:disabled) {
background: #1971c2;
}
&:active:not(:disabled) {
transform: scale(0.96);
}
&:disabled {
background: #adb5bd;
cursor: not-allowed;
}
&.danger {
background: #c92a2a;
&:hover:not(:disabled) {
background: #e03131;
}
}
}
// --- Bossové (zloděj / housenka) ---------------------------------------------
.butterfly-stalker {
position: fixed;
top: 0;
left: 0;
width: 56px;
display: flex;
flex-direction: column;
align-items: center;
pointer-events: auto;
cursor: crosshair;
user-select: none;
touch-action: none;
z-index: 6;
will-change: transform;
&.hit .stalker-emoji {
animation: butterfly-stalker-hit 0.18s ease-out;
}
}
.stalker-emoji {
font-size: 44px;
line-height: 1;
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.35));
}
.stalker-hp {
width: 46px;
height: 6px;
margin-bottom: 3px;
background: rgba(0, 0, 0, 0.25);
border-radius: 999px;
overflow: hidden;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.stalker-hp-inner {
height: 100%;
width: 100%;
background: linear-gradient(90deg, #ff6b6b, #c92a2a);
border-radius: 999px;
transition: width 0.1s linear;
}
@keyframes butterfly-stalker-hit {
0% { transform: scale(1) rotate(0); }
50% { transform: scale(0.82) rotate(-8deg); filter: brightness(1.6); }
100% { transform: scale(1) rotate(0); }
}
// Efekt při plácnutí vosy
.butterfly-swat-fx {
position: fixed;
margin: -14px 0 0 -6px;
font-size: 1.4rem;
pointer-events: none;
z-index: 5;
animation: butterfly-catch-float 0.8s ease-out forwards;
}
.butterfly-combo {
padding: 4px 10px;
border-radius: 999px;
background: rgba(224, 162, 0, 0.92);
color: #fff;
font-weight: 800;
font-size: 0.9rem;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
animation: butterfly-counter-bump 0.3s ease-out;
}
.butterfly-flash {
max-width: 260px;
padding: 8px 12px;
border-radius: 12px;
background: rgba(33, 37, 41, 0.92);
color: #fff;
font-weight: 600;
font-size: 0.85rem;
line-height: 1.25;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.25);
animation: butterfly-flash-in 0.25s ease-out;
pointer-events: auto;
cursor: pointer;
}
@keyframes butterfly-flash-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes butterfly-counter-bump {
0% {
transform: scale(1);
}
40% {
transform: scale(1.18);
}
100% {
transform: scale(1);
}
}
// Ohleduplnost k uživatelům, kteří nechtějí pohyb
@media (prefers-reduced-motion: reduce) {
.butterfly-sprite,
.butterfly-sprite.golden {
animation: none;
}
.butterfly-net.catch-pop,
.butterfly-net.premium,
.butterfly-net.stung,
.butterfly-net.stunned,
.butterfly-counter.bump,
.butterfly-combo {
animation: none;
}
// Bez animace aspoň staticky ukážeme, že síťka je omráčená
.butterfly-net.stunned {
filter: grayscale(0.8) brightness(0.82) drop-shadow(0 0 6px rgba(217, 72, 15, 0.7));
}
// Zlatý motýl bez animace aspoň staticky září
.butterfly-sprite.golden {
filter: saturate(1.3) drop-shadow(0 0 8px rgba(255, 215, 80, 0.95)) drop-shadow(0 0 16px rgba(255, 190, 50, 0.7));
}
}
// --- Anti-bot inspekce a vězení ---------------------------------------------
.butterfly-inspection {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 8;
pointer-events: none;
max-width: 92vw;
padding: 10px 16px;
border-radius: 999px;
background: rgba(28, 126, 214, 0.95);
color: #fff;
font-weight: 700;
font-size: 0.95rem;
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.3);
animation: butterfly-flash-in 0.25s ease-out;
}
.butterfly-jail {
position: fixed;
inset: 0;
z-index: 6;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
}
// Svislé mříže přes celou obrazovku
.butterfly-jail-bars {
position: absolute;
inset: 0;
background:
repeating-linear-gradient(
90deg,
rgba(20, 22, 28, 0.92) 0px,
rgba(20, 22, 28, 0.92) 14px,
rgba(20, 22, 28, 0) 14px,
rgba(20, 22, 28, 0) 70px
);
box-shadow: inset 0 0 120px rgba(0, 0, 0, 0.6);
}
.butterfly-jail-note {
position: relative;
text-align: center;
padding: 22px 26px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4);
max-width: 90vw;
.jail-emoji { font-size: 2.4rem; }
.jail-title { font-weight: 800; font-size: 1.2rem; color: #c92a2a; margin-top: 4px; }
.jail-sub { margin-top: 6px; font-size: 0.95rem; color: #333; font-variant-numeric: tabular-nums; }
.jail-hint { margin-top: 8px; font-size: 0.8rem; color: #868e96; }
}
@media (prefers-reduced-motion: reduce) {
.butterfly-sprite.royal { animation: none; }
}
File diff suppressed because it is too large. Load diff
+10
View File
@@ -87,3 +87,13 @@
font-weight: 600; font-weight: 600;
margin-top: 8px; margin-top: 8px;
} }
.login-card > .hint {
color: var(--luncher-text-muted);
}
.login-error {
color: var(--luncher-danger);
line-height: 1.5;
margin: 0;
}
+46 -14
View File
@@ -1,29 +1,37 @@
import React, { useCallback, useEffect, useRef } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Button } from 'react-bootstrap'; import { Button } from 'react-bootstrap';
import { useAuth } from './context/auth'; import { useAuth } from './context/auth';
import { login } from '../../types'; import { login } from '../../types';
import { useAppConfig } from './hooks/useGroupLimits';
import './Login.css'; import './Login.css';
/** /**
* Formulář pro prvotní zadání přihlašovacího jména. * Přihlášení. Podle konfigurace serveru buď přihlásí uživatele automaticky přes důvěryhodné
* hlavičky z proxy (bez formuláře), nebo zobrazí formulář pro zadání jména. Když se konfigurace
* nenačte, zkusí nejprve přihlášení „naprázdno" a jinak zobrazí formulář (původní chování).
*/ */
export default function Login() { export default function Login() {
const auth = useAuth(); const auth = useAuth();
const { loaded, trustedHeaders } = useAppConfig();
const loginRef = useRef<HTMLInputElement>(null); const loginRef = useRef<HTMLInputElement>(null);
const [trustedError, setTrustedError] = useState<string>();
const [attempt, setAttempt] = useState(0);
useEffect(() => { useEffect(() => {
if (auth && !auth.login) { if (!auth || auth.login || !loaded || trustedHeaders === false) return;
// Vyzkoušíme přihlášení "naprázdno", pokud projde, přihlásili nás trusted headers // Přihlášení přes hlavičky z proxy (případně pokus „naprázdno", pokud nevíme, jak server přihlašuje)
setTrustedError(undefined);
login().then(response => { login().then(response => {
const token = response.data; const token = response.data;
if (token) { if (token) {
auth?.setToken(token as unknown as string); // TODO vyřešit, API definice je špatně, je to skutečně string auth.setToken(token as unknown as string); // TODO vyřešit, API definice je špatně, je to skutečně string
} else if (trustedHeaders) {
setTrustedError((response.error as any)?.error ?? 'Přihlášení přes proxy se nezdařilo.');
} }
}).catch(error => { }).catch(() => {
// nezajímá nás if (trustedHeaders) setTrustedError('Server je nedostupný. Zkuste to prosím za chvíli.');
}); });
} }, [auth, loaded, trustedHeaders, attempt]);
}, [auth]);
const doLogin = useCallback(async () => { const doLogin = useCallback(async () => {
const length = loginRef?.current?.value.length && loginRef.current.value.replaceAll(/\s/g, '').length const length = loginRef?.current?.value.length && loginRef.current.value.replaceAll(/\s/g, '').length
@@ -35,12 +43,38 @@ export default function Login() {
} }
}, [auth]); }, [auth]);
if (!auth?.login) { if (auth?.login) {
return <div>Neplatný stav</div>
}
const header = <>
<h1 className='login-logo'>Luncher</h1>
<p className='login-subtitle'>Aplikace pro profesionální management obědů</p>
</>;
// Přihlašuje proxy — formulář se jménem nedává smysl
if (!loaded || trustedHeaders) {
return ( return (
<div className='login-page'> <div className='login-page'>
<div className='login-card'> <div className='login-card'>
<h1 className='login-logo'>Luncher</h1> {header}
<p className='login-subtitle'>Aplikace pro profesionální management obědů</p> {trustedError ? (
<div className='login-form'>
<p className='login-error'>{trustedError}</p>
<Button onClick={() => setAttempt(a => a + 1)}>Zkusit znovu</Button>
</div>
) : (
<p className='hint'>Přihlašuji…</p>
)}
</div>
</div>
);
}
return (
<div className='login-page'>
<div className='login-card'>
{header}
<div className='login-form'> <div className='login-form'>
<div> <div>
<label htmlFor="login-input">Zobrazované jméno</label> <label htmlFor="login-input">Zobrazované jméno</label>
@@ -66,5 +100,3 @@ export default function Login() {
</div> </div>
); );
} }
return <div>Neplatný stav</div>
}
-100
View File
@@ -1,100 +0,0 @@
import { OverlayTrigger, Tooltip } from 'react-bootstrap';
import './BoltOrderProgress.scss';
const STEPS = ['Přijato', 'Příprava', 'Vyzvedávání', 'Na cestě', 'Doručeno'];
/**
* Známé stavy objednávky z Bolt API → index kroku ve stepperu.
* Pozor: waiting_delivery znamená "jídlo čeká v podniku na vyzvednutí",
* nikoli "na cestě" — tu signalizuje až stav kurýra (picked_up apod.).
* Krok „Vyzvedávání" je vyhrazen pro kombinaci „kurýr u podniku + jídlo hotové"
* (viz logika níže), samotný order_state ho neudělí — jinak bychom hlásili
* vyzvedávání i když se ještě peče (chování, na které si Bolt sám občas stěžuje).
*/
const ORDER_STATE_TO_STEP: Record<string, number> = {
created: 0,
pending: 0,
accepted: 0,
waiting_preparation: 0,
preparing: 1,
waiting_delivery: 1,
ready_for_pickup: 1,
waiting_courier: 1,
waiting_pickup: 1,
picked_up: 3,
in_delivery: 3,
delivering: 3,
heading_to_client: 3,
delivered: 4,
finished: 4,
};
/** Stavy kurýra z Bolt API → index kroku. Kurýr u podniku ještě neznamená "na cestě". */
const COURIER_STATE_TO_STEP: Record<string, number> = {
heading_to_provider: 1,
arrived_to_provider: 1,
picked_up: 3,
heading_to_client: 3,
delivering: 3,
arrived_to_client: 3,
delivered: 4,
};
/** Order states ve kterých už jídlo čeká připravené na kurýra. */
const PICKUP_READY_ORDER_STATES = new Set(['waiting_delivery', 'ready_for_pickup', 'waiting_courier', 'waiting_pickup']);
/** Neznámé stavy se mapují heuristicky podle klíčových slov. */
function stepForOrderState(state: string): number | 'cancelled' {
const s = state.toLowerCase();
if (s in ORDER_STATE_TO_STEP) return ORDER_STATE_TO_STEP[s];
if (/cancel|reject|fail/.test(s)) return 'cancelled';
if (/delivered|finished/.test(s)) return 4;
if (/^waiting|prepar|ready|cook/.test(s)) return 1;
if (/picked|delivering|heading_to_client|transport/.test(s)) return 3;
return 0;
}
function stepForCourierState(state?: string): number {
if (!state) return 0;
const s = state.toLowerCase();
if (s in COURIER_STATE_TO_STEP) return COURIER_STATE_TO_STEP[s];
if (/picked|client|delivering|transport/.test(s)) return 3;
return 0;
}
interface Props {
/** Raw order_state z Bolt API (např. waiting_preparation) */
state: string;
/** Raw courier.state z Bolt API (např. arrived_to_provider) */
courierState?: string;
/** Zda sledování stále běží (skupina má boltTrackingToken) */
tracking: boolean;
}
/** Mini progress stepper se stavem objednávky Bolt Food. */
export default function BoltOrderProgress({ state, courierState, tracking }: Props) {
const orderStep = stepForOrderState(state);
if (orderStep === 'cancelled') {
return <small className="text-danger">Objednávka Bolt byla zrušena</small>;
}
// Stav kurýra může krok jen zpřesnit dopředu (např. waiting_delivery + picked_up → Na cestě)
let step = Math.max(orderStep, stepForCourierState(courierState));
// Vyzvedávání = kurýr u podniku a jídlo skutečně hotové. Sám arrived_to_provider
// (bez toho, že by jídlo bylo hotové) nestačí — viz komentář u ORDER_STATE_TO_STEP.
const courierAtProvider = courierState?.toLowerCase() === 'arrived_to_provider';
const foodReadyForPickup = PICKUP_READY_ORDER_STATES.has(state.toLowerCase());
if (step < 3 && courierAtProvider && foodReadyForPickup) step = 2;
const rawInfo = courierState ? `${state} / kurýr: ${courierState}` : state;
return (
<OverlayTrigger overlay={<Tooltip>Stav z Bolt Food: {rawInfo}</Tooltip>}>
<div className={`bolt-progress${tracking && step < 4 ? ' live' : ''}`}>
{STEPS.map((label, i) => (
<div key={label} className={`bolt-step${i <= step ? ' done' : ''}${i === step ? ' active' : ''}`}>
<div className="bolt-dot" />
<div className="bolt-label">{label}</div>
</div>
))}
</div>
</OverlayTrigger>
);
}
+181
View File
@@ -0,0 +1,181 @@
.duel-invite {
position: fixed;
left: 16px;
bottom: 16px;
z-index: 1040;
padding: 10px 16px;
border: none;
border-radius: 999px;
background: var(--luncher-primary, #198754);
color: #fff;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
cursor: pointer;
animation: duel-pulse 1.6s ease-in-out infinite;
}
@keyframes duel-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.04); }
}
.drawing-game-modal {
.draw-topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 10px;
font-size: 1.05rem;
}
.draw-hint {
// Mezery mezi slovy v masce se nesmí slít, jinak nejde poznat počet slov
white-space: pre;
font-family: monospace;
font-size: 1.4rem;
letter-spacing: 0.15em;
}
.draw-word strong {
font-size: 1.3rem;
}
.draw-timer.urgent {
color: #ef4444;
font-weight: 700;
}
.draw-layout {
display: flex;
gap: 16px;
align-items: stretch;
}
.draw-canvas-col {
flex: 1 1 auto;
min-width: 0;
}
.draw-canvas {
display: block;
width: 100%;
height: auto;
border-radius: 8px;
background: #fff;
touch-action: none;
&.active {
cursor: crosshair;
}
}
.draw-tools {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-top: 8px;
}
.draw-color {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid rgba(127, 127, 127, 0.5);
padding: 0;
&.selected {
outline: 3px solid var(--luncher-primary, #198754);
outline-offset: 1px;
}
}
.draw-size {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
border: 1px solid rgba(127, 127, 127, 0.5);
background: transparent;
span {
display: block;
border-radius: 50%;
background: currentColor;
}
&.selected {
outline: 3px solid var(--luncher-primary, #198754);
}
}
.draw-sep {
width: 8px;
}
.draw-side {
flex: 0 0 260px;
display: flex;
flex-direction: column;
gap: 8px;
}
.draw-scores {
list-style: none;
padding: 0;
margin: 0;
li {
display: flex;
justify-content: space-between;
padding: 2px 0;
}
.guessed {
color: #22c55e;
}
}
.draw-chat {
flex: 1 1 auto;
min-height: 200px;
max-height: 360px;
overflow-y: auto;
border: 1px solid rgba(127, 127, 127, 0.35);
border-radius: 6px;
padding: 6px 8px;
font-size: 0.9rem;
.system {
color: #22c55e;
font-style: italic;
}
}
.draw-players {
columns: 2;
}
// Na úzkém displeji (telefon na výšku) jde chat pod plátno, jinak by se plátno smrsklo na proužek
@media (max-width: 767.98px) {
.draw-topbar {
flex-wrap: wrap;
gap: 4px 16px;
}
.draw-layout {
flex-direction: column;
}
.draw-side {
flex: 0 0 auto;
}
.draw-chat {
min-height: 120px;
max-height: 200px;
}
}
}
+429
View File
@@ -0,0 +1,429 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Button, Form, Modal, Table } from "react-bootstrap";
import { socket } from "../context/socket";
import { useAuth } from "../context/auth";
import { getToken } from "../Utils";
import { useGameIcon } from "../games/themeIcons";
import "./DrawingGame.scss";
// Typy musí odpovídat server/src/drawingGame.ts (stav chodí přes websocket, ne přes OpenAPI)
type DrawPhase = 'idle' | 'lobby' | 'drawing' | 'reveal' | 'finished';
type DrawPlayer = { login: string, score: number, guessed: boolean, place?: number };
type ChatMessage = { login?: string, text: string, system?: boolean };
type Stroke = { color: string, size: number, points: [number, number][], start: boolean };
type DrawState = {
phase: DrawPhase,
host?: string,
players: DrawPlayer[],
drawer?: string,
round: number,
totalRounds: number,
hint?: string,
answer?: string,
remainingMs?: number,
messages: ChatMessage[],
minPlayers: number,
};
const OPEN_EVENT = 'luncher:open-drawing';
/** Otevře okno Kreslení. */
export const openDrawingGame = () => window.dispatchEvent(new Event(OPEN_EVENT));
const CANVAS_W = 800;
const CANVAS_H = 600;
/** Souřadnice tahů jsou 0–1000 v obou osách, nezávisle na velikosti plátna. */
const COORD_MAX = 1000;
const COLORS = ['#111827', '#ef4444', '#f97316', '#facc15', '#22c55e', '#3b82f6', '#a855f7', '#92400e', '#ffffff'];
const SIZES = [3, 8, 18];
/** Jak často se rozkreslený tah posílá ostatním (ms). */
const FLUSH_MS = 40;
const MEDALS = ['🥇', '🥈', '🥉'];
function drawStroke(ctx: CanvasRenderingContext2D, stroke: Stroke) {
const sx = CANVAS_W / COORD_MAX;
const sy = CANVAS_H / COORD_MAX;
ctx.strokeStyle = stroke.color;
ctx.fillStyle = stroke.color;
ctx.lineWidth = stroke.size;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const [first, ...rest] = stroke.points;
if (rest.length === 0) {
ctx.beginPath();
ctx.arc(first[0] * sx, first[1] * sy, stroke.size / 2, 0, Math.PI * 2);
ctx.fill();
return;
}
ctx.beginPath();
ctx.moveTo(first[0] * sx, first[1] * sy);
rest.forEach(([x, y]) => ctx.lineTo(x * sx, y * sy));
ctx.stroke();
}
function clearCanvas(ctx: CanvasRenderingContext2D) {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
}
/** Kreslení — jeden kreslí jídlo z dnešních menu, ostatní hádají v chatu. */
export default function DrawingGame() {
const auth = useAuth();
const login = auth?.login;
const [state, setState] = useState<DrawState | null>(null);
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [word, setWord] = useState<string | null>(null);
const [color, setColor] = useState(COLORS[0]);
const [size, setSize] = useState(SIZES[1]);
const [guess, setGuess] = useState('');
const [secondsLeft, setSecondsLeft] = useState(0);
const canvasRef = useRef<HTMLCanvasElement>(null);
const chatRef = useRef<HTMLDivElement>(null);
const prevPhase = useRef<DrawPhase>('idle');
/** Rozkreslený tah: body čekající na odeslání a poslední odeslaný bod pro navázání. */
const pending = useRef<{ points: [number, number][], start: boolean } | null>(null);
const drawingRef = useRef(false);
const icon = useGameIcon('drawing');
const send = useCallback((event: string, extra?: object) => {
setError(null);
socket.emit(event, { token: getToken(), ...extra });
}, []);
const ctx = () => canvasRef.current?.getContext('2d') ?? null;
useEffect(() => {
const onState = (s: DrawState) => setState(s);
const onError = (msg: string) => setError(msg);
const onOpen = () => setOpen(true);
const sync = () => socket.emit('draw:sync');
socket.on('draw:state', onState);
socket.on('draw:error', onError);
socket.on('connect', sync);
window.addEventListener(OPEN_EVENT, onOpen);
sync();
return () => {
socket.off('draw:state', onState);
socket.off('draw:error', onError);
socket.off('connect', sync);
window.removeEventListener(OPEN_EVENT, onOpen);
};
}, []);
const isDrawer = !!login && state?.phase === 'drawing' && state.drawer === login;
const isDrawerRef = useRef(isDrawer);
isDrawerRef.current = isDrawer;
// Kresba a slovo chodí jen klientům s otevřeným oknem
useEffect(() => {
if (!open) return;
const watch = () => socket.emit('draw:watch', { token: getToken() });
const onStrokes = (strokes: Stroke[]) => {
const c = ctx();
if (!c) return;
clearCanvas(c);
strokes.forEach(s => drawStroke(c, s));
};
// Vlastní tahy kreslíř vykresluje hned lokálně
const onStroke = (s: Stroke) => {
const c = ctx();
if (c && !isDrawerRef.current) drawStroke(c, s);
};
const onClear = () => {
const c = ctx();
if (c) clearCanvas(c);
};
const onWord = (w: string) => setWord(w);
socket.on('draw:strokes', onStrokes);
socket.on('draw:stroke', onStroke);
socket.on('draw:clear', onClear);
socket.on('draw:word', onWord);
socket.on('connect', watch);
watch();
return () => {
socket.off('draw:strokes', onStrokes);
socket.off('draw:stroke', onStroke);
socket.off('draw:clear', onClear);
socket.off('draw:word', onWord);
socket.off('connect', watch);
socket.emit('draw:unwatch');
};
}, [open]);
const isPlayer = !!login && !!state?.players.some(p => p.login === login);
const phase = state?.phase ?? 'idle';
const inGame = phase === 'drawing' || phase === 'reveal';
useEffect(() => {
if (prevPhase.current === 'lobby' && phase === 'drawing' && isPlayer) {
setOpen(true);
}
prevPhase.current = phase;
}, [phase, isPlayer]);
// Slovo patří jen k aktuálnímu kolu
useEffect(() => {
if (!isDrawer) setWord(null);
}, [isDrawer, state?.round]);
// Plátno se po vykreslení (nové kolo, otevření okna) vyplní bílou a znovu vyžádá kresba
useEffect(() => {
if (open && inGame) {
const c = ctx();
if (c) clearCanvas(c);
socket.emit('draw:watch', { token: getToken() });
}
}, [open, inGame]);
// Odpočet kola počítaný lokálně od posledního stavu ze serveru
useEffect(() => {
if (phase !== 'drawing' || state?.remainingMs === undefined) {
setSecondsLeft(0);
return;
}
const endsAt = Date.now() + state.remainingMs;
const update = () => setSecondsLeft(Math.max(0, Math.ceil((endsAt - Date.now()) / 1000)));
update();
const id = window.setInterval(update, 250);
return () => window.clearInterval(id);
}, [phase, state?.remainingMs]);
useEffect(() => {
chatRef.current?.scrollTo({ top: chatRef.current.scrollHeight });
}, [state?.messages.length]);
// Průběžné odesílání rozkresleného tahu
useEffect(() => {
if (!isDrawer) return;
const id = window.setInterval(() => {
const p = pending.current;
if (!p || p.points.length === 0 || (p.points.length === 1 && !p.start)) return;
send('draw:stroke', { color, size, points: p.points, start: p.start });
// Další dávka začne posledním bodem, aby čára navazovala
pending.current = drawingRef.current ? { points: [p.points[p.points.length - 1]], start: false } : null;
}, FLUSH_MS);
return () => window.clearInterval(id);
}, [isDrawer, color, size, send]);
const toCoords = (e: React.PointerEvent<HTMLCanvasElement>): [number, number] => {
const rect = e.currentTarget.getBoundingClientRect();
const x = Math.round(((e.clientX - rect.left) / rect.width) * COORD_MAX);
const y = Math.round(((e.clientY - rect.top) / rect.height) * COORD_MAX);
return [Math.min(COORD_MAX, Math.max(0, x)), Math.min(COORD_MAX, Math.max(0, y))];
};
const onPointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!isDrawer) return;
e.currentTarget.setPointerCapture(e.pointerId);
drawingRef.current = true;
const point = toCoords(e);
pending.current = { points: [point], start: true };
const c = ctx();
if (c) drawStroke(c, { color, size, points: [point], start: true });
};
const onPointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!isDrawer || !drawingRef.current || !pending.current) return;
const point = toCoords(e);
const prev = pending.current.points[pending.current.points.length - 1];
if (prev && prev[0] === point[0] && prev[1] === point[1]) return;
pending.current.points.push(point);
const c = ctx();
if (c && prev) drawStroke(c, { color, size, points: [prev, point], start: false });
};
const onPointerUp = () => {
drawingRef.current = false;
};
const submitGuess = (e: React.FormEvent) => {
e.preventDefault();
if (!guess.trim()) return;
send('draw:guess', { text: guess });
setGuess('');
};
if (!login) return null;
const me = state?.players.find(p => p.login === login);
const canChat = isPlayer && !isDrawer && !(phase === 'drawing' && me?.guessed);
const ranked = [...(state?.players ?? [])].sort((a, b) => b.score - a.score);
const showInvite = !open && phase === 'lobby' && !isPlayer;
const showReturn = !open && isPlayer && inGame;
const renderTopBar = () => {
if (phase === 'reveal' || phase === 'finished') {
return <div className="draw-topbar">Bylo to: <strong>{state?.answer}</strong></div>;
}
return (
<div className="draw-topbar">
<span>Kolo {state?.round}/{state?.totalRounds} · kreslí <strong>{state?.drawer}</strong></span>
{isDrawer
? <span className="draw-word">Nakreslete: <strong>{word ?? '…'}</strong></span>
: <span className="draw-hint">{state?.hint}</span>}
<span className={secondsLeft <= 10 ? 'draw-timer urgent' : 'draw-timer'}>⏱ {secondsLeft} s</span>
</div>
);
};
const renderGame = () => (
<>
{renderTopBar()}
<div className="draw-layout">
<div className="draw-canvas-col">
<canvas
ref={canvasRef}
className={`draw-canvas ${isDrawer ? 'active' : ''}`}
width={CANVAS_W}
height={CANVAS_H}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
/>
{isDrawer && (
<div className="draw-tools">
{COLORS.map(c => (
<button key={c} className={`draw-color ${c === color ? 'selected' : ''}`}
style={{ background: c }} onClick={() => setColor(c)} aria-label={`Barva ${c}`} />
))}
<span className="draw-sep" />
{SIZES.map(s => (
<button key={s} className={`draw-size ${s === size ? 'selected' : ''}`} onClick={() => setSize(s)}
aria-label={`Tloušťka ${s}`}>
<span style={{ width: s, height: s }} />
</button>
))}
<span className="draw-sep" />
<Button size="sm" variant="outline-secondary" onClick={() => send('draw:clear')}>Smazat</Button>
</div>
)}
</div>
<div className="draw-side">
<ul className="draw-scores">
{ranked.map(p => (
<li key={p.login} className={p.guessed && phase === 'drawing' ? 'guessed' : undefined}>
<span>{p.login === state?.drawer && phase === 'drawing' ? '✏️ ' : ''}{p.login}{p.login === login && ' (vy)'}</span>
<strong>{p.score}</strong>
</li>
))}
</ul>
{renderChat()}
</div>
</div>
</>
);
const renderChat = () => (
<>
<div className="draw-chat" ref={chatRef}>
{state?.messages.map((m, i) => (
<div key={i} className={m.system ? 'system' : undefined}>
{m.login && <strong>{m.login}: </strong>}{m.text}
</div>
))}
</div>
<Form onSubmit={submitGuess}>
<Form.Control
value={guess}
onChange={e => setGuess(e.target.value)}
placeholder={isDrawer ? 'Kreslíte…' : canChat ? 'Váš tip…' : 'Uhodli jste ✔'}
disabled={!canChat}
maxLength={100}
onKeyDown={e => e.stopPropagation()}
/>
</Form>
</>
);
const renderBody = () => {
switch (phase) {
case 'idle':
return (
<p>
Jeden kreslí jídlo z dnešních menu, ostatní hádají v chatu. Stačí trefit hlavní slovo
(např. „guláš"), diakritika nevadí. Čím dřív uhodnete, tím víc bodů; kreslíř dostává body
za každého, kdo uhodl. Každý kreslí jednou.
</p>
);
case 'lobby':
return (
<>
<p>Lobby založil(a) <strong>{state?.host}</strong>. Hráči ({state?.players.length}):</p>
<ul className="draw-players">
{state?.players.map(p => <li key={p.login}>{p.login}{p.login === state.host && ' 👑'}</li>)}
</ul>
{renderChat()}
</>
);
case 'drawing':
case 'reveal':
return renderGame();
case 'finished':
return (
<>
<Table size="sm">
<thead><tr><th>#</th><th>Hráč</th><th>Body</th></tr></thead>
<tbody>
{ranked.map((p, i) => (
<tr key={p.login}>
<td>{i < 3 ? MEDALS[i] : i + 1}</td>
<td>{p.login}{p.login === login && ' (vy)'}</td>
<td>{p.score}</td>
</tr>
))}
</tbody>
</Table>
</>
);
}
};
const renderFooter = () => {
const buttons = [];
if (phase === 'idle' || phase === 'finished') {
buttons.push(<Button key="new" onClick={() => send('draw:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>);
}
if (phase === 'lobby') {
if (!isPlayer) {
buttons.push(<Button key="join" onClick={() => send('draw:join')}>Připojit se</Button>);
} else {
buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('draw:leave')}>
{login === state?.host ? 'Zrušit lobby' : 'Odejít'}
</Button>);
if (login === state?.host) {
buttons.push(<Button key="start" onClick={() => send('draw:start')}
disabled={(state?.players.length ?? 0) < (state?.minPlayers ?? 2)}>Spustit hru</Button>);
}
}
}
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
return buttons;
};
return (
<>
{showInvite && (
<button className="duel-invite draw-invite" onClick={() => setOpen(true)}>
{icon} <strong>{state?.host}</strong> zve na kreslení — připojit se
</button>
)}
{showReturn && (
<button className="duel-invite draw-invite" onClick={() => setOpen(true)}>
{icon} Kreslení běží — zpět do hry
</button>
)}
<Modal show={open} onHide={() => setOpen(false)} size="xl" fullscreen="sm-down" className="drawing-game-modal">
<Modal.Header closeButton>
<Modal.Title><h2>{icon} Kreslení</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{renderBody()}
{error && <div className="text-danger mt-2">{error}</div>}
</Modal.Body>
<Modal.Footer>{renderFooter()}</Modal.Footer>
</Modal>
</>
);
}
+76
View File
@@ -0,0 +1,76 @@
// Kulatý portrét postavy vedle jména strávníka s více jídly (styl Umamusume)
.eater-badge {
width: 30px;
height: 30px;
object-fit: cover;
flex: none;
margin-right: 6px;
vertical-align: middle;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 0 0 2px #ff6fa3;
cursor: help;
transition: transform 0.2s ease;
&:hover {
transform: scale(2);
position: relative;
z-index: 5;
}
}
// Styl My Little Pony: cutie mark s emoji v kulatém odznaku
.eater-badge-mlp {
display: inline-grid;
place-items: center;
font-size: 17px;
line-height: 1;
background: radial-gradient(circle at 35% 30%, #fff, #f6ecff);
box-shadow: 0 0 0 2px #8e4fd1;
}
// Styl Hell Girl: emoji v černém odznaku s rudým lemem (rudá nit)
.eater-badge-hellgirl {
display: inline-grid;
place-items: center;
font-size: 16px;
line-height: 1;
border-color: #1c1012;
background: radial-gradient(circle at 50% 60%, #3a0d16, #12070a);
box-shadow: 0 0 0 2px #c8102e, 0 0 6px rgb(200 16 46 / 0.5);
}
// Styl K-ON!: emoji na krémovém „trsátku" s lemem v barvě kytary Gitah (cherry sunburst)
.eater-badge-kon {
display: inline-grid;
place-items: center;
font-size: 16px;
line-height: 1;
border-radius: 50% 50% 50% 50% / 42% 42% 58% 58%;
background: radial-gradient(circle at 40% 35%, #fffaf0, #fbe6c8);
box-shadow: 0 0 0 2px #d43f62, 0 2px 0 3px #27315f;
}
// Styl Bocchi the Rock!: emoji na žluté samolepce s černou linkou a růžovým stínem
.eater-badge-bocchi {
display: inline-grid;
place-items: center;
font-size: 15px;
line-height: 1;
border-radius: 6px;
border-color: #1d1a22;
background: #ffe066;
box-shadow: 2px 2px 0 #e0457b;
transform: rotate(-4deg);
&:hover {
transform: rotate(0deg) scale(2);
}
}
// Odznak a jméno drží na jednom řádku (úzký sloupec „Člen" na stránce objednávek)
.user-info:has(.eater-badge) {
display: inline-flex;
align-items: center;
white-space: nowrap;
}
+85
View File
@@ -0,0 +1,85 @@
import { useEffect, useState } from "react";
import { OverlayTrigger, Tooltip } from "react-bootstrap";
import { StyleTheme, useSettings } from "../context/settings";
import { UmaCharacter, loadUmaCharacters } from "../utils/umaCharacters";
import "./EaterBadge.scss";
/**
* Úrovně „jedlíka" podle počtu jídel: Special Week sní hodně, Oguri Cap ještě víc.
* Seřazeno od nejvyšší úrovně.
*/
const LEVELS = [
{ minItems: 3, character: 'Oguri Cap', title: 'Úroveň Oguri Cap — bezedný žaludek' },
{ minItems: 2, character: 'Special Week', title: 'Úroveň Special Week — pořádný apetit' },
];
/** Úrovně ve stylu My Little Pony — místo portrétu cutie mark (emoji). Seřazeno od nejvyšší úrovně. */
const MLP_LEVELS = [
{ minItems: 3, emoji: '🧁', title: 'Úroveň Pinkie Pie — cupcaků není nikdy dost' },
{ minItems: 2, emoji: '🍎', title: 'Úroveň Applejack — poctivý apetit' },
];
/** Úrovně ve stylu Hell Girl — emoji v černém odznaku s rudým lemem. Seřazeno od nejvyšší úrovně. */
const HG_LEVELS = [
{ minItems: 3, emoji: '🔥', title: 'Úroveň Wanyūdō — hlad jako pekelný oheň' },
{ minItems: 2, emoji: '🥀', title: 'Úroveň Ai Enma — tichý, ale nenasytný apetit' },
];
/** Úrovně ve stylu K-ON! — emoji v odznaku jako trsátko. Seřazeno od nejvyšší úrovně. */
const KON_LEVELS = [
{ minItems: 3, emoji: '🍰', title: 'Úroveň Yui — sní všechno a nepřibere ani gram' },
{ minItems: 2, emoji: '☕', title: 'Úroveň Tea Time — k obědu ještě čaj a dortík' },
];
/** Úrovně ve stylu Bocchi the Rock! — emoji na samolepce jako z flyeru live house. Seřazeno od nejvyšší úrovně. */
const BOCCHI_LEVELS = [
{ minItems: 3, emoji: '🌿', title: 'Úroveň Ryo — když dojdou peníze, sní i trávu u cesty' },
{ minItems: 2, emoji: '🥭', title: 'Úroveň Bocchi — dvojitý oběd v klidu v krabici od manga' },
];
/** Úrovně podle stylu pro odznaky s emoji. */
const EMOJI_LEVELS: Partial<Record<StyleTheme, typeof MLP_LEVELS>> = {
mlp: MLP_LEVELS,
hellgirl: HG_LEVELS,
kon: KON_LEVELS,
bocchi: BOCCHI_LEVELS,
};
type Props = {
/** Počet jídel/položek, které si strávník vybral či objednal */
items: number,
};
/** Odznak s postavou u strávníků s více jídly — jen ve stylech Umamusume, My Little Pony, Hell Girl, K-ON! a Bocchi the Rock!. */
export default function EaterBadge({ items }: Readonly<Props>) {
const styleTheme = useSettings()?.styleTheme;
const isUma = styleTheme === 'umamusume';
const [characters, setCharacters] = useState<UmaCharacter[]>([]);
useEffect(() => {
if (isUma) loadUmaCharacters().then(setCharacters);
}, [isUma]);
const emojiLevels = styleTheme && EMOJI_LEVELS[styleTheme];
if (emojiLevels) {
const emojiLevel = emojiLevels.find(l => items >= l.minItems);
if (!emojiLevel) return null;
return (
<OverlayTrigger placement="top" overlay={<Tooltip>{emojiLevel.title} ({items} jídla)</Tooltip>}>
<span className={`eater-badge eater-badge-${styleTheme}`} role="img" aria-label={emojiLevel.title}>{emojiLevel.emoji}</span>
</OverlayTrigger>
);
}
const level = LEVELS.find(l => items >= l.minItems);
const character = level && characters.find(c => c.name === level.character);
// Malý odznak potřebuje obličej — portrét (icon), případně kulatý obrázek postavy
const src = character?.icon ?? (character?.round ? character.images[0] : undefined);
if (!isUma || !level || !src) return null;
return (
<OverlayTrigger placement="top" overlay={<Tooltip>{level.title} ({items} jídla)</Tooltip>}>
<img src={src} alt={level.character} className="eater-badge" />
</OverlayTrigger>
);
}
+31
View File
@@ -0,0 +1,31 @@
// Bez zoomu dvojitým ťuknutím, jinak by iOS sérii ťuknutí na logo spolkl
.navbar-brand {
touch-action: manipulation;
}
// Skryté pole pro kód minihry — musí jít zaměřit (kvůli softwarové klávesnici), proto ne display: none.
// Písmo aspoň 16px, jinak iOS při zaměření přiblíží stránku.
.minigame-secret-input {
position: fixed;
top: 0;
left: 0;
width: 1px;
height: 1px;
padding: 0;
border: 0;
font-size: 16px;
opacity: 0;
pointer-events: none;
}
// Nenápadné potvrzení, že logo přijalo sérii kliknutí a čeká na tajné slovo
.navbar-brand.duel-armed {
display: inline-block;
animation: duel-wiggle 0.35s ease-in-out 2;
}
@keyframes duel-wiggle {
0%, 100% { transform: rotate(0); }
25% { transform: rotate(-4deg); }
75% { transform: rotate(4deg); }
}
+14 -4
View File
@@ -2,6 +2,9 @@ import { useEffect, useState } from "react";
import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap"; import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap";
import { useAuth } from "../context/auth"; import { useAuth } from "../context/auth";
import SettingsModal from "./modals/SettingsModal"; import SettingsModal from "./modals/SettingsModal";
import DrawingGame from "./DrawingGame";
import "./Header.scss";
import { useMinigameSecret } from "../hooks/useMinigameSecret";
import { useSettings, ThemePreference } from "../context/settings"; import { useSettings, ThemePreference } from "../context/settings";
import HuePicker from "./HuePicker"; import HuePicker from "./HuePicker";
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal"; import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
@@ -39,6 +42,7 @@ export default function Header({ choices, dayIndex }: Props) {
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false); const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
const effectiveDark = settings?.effectiveDark ?? false; const effectiveDark = settings?.effectiveDark ?? false;
const minigameSecret = useMinigameSecret("/");
useEffect(() => { useEffect(() => {
if (!auth?.login) return; if (!auth?.login) return;
@@ -77,6 +81,9 @@ export default function Header({ choices, dayIndex }: Props) {
setQrModalOpen(true); setQrModalOpen(true);
} }
// Tematické styly mají pevnou paletu, výběr barvy má smysl jen u jednoduchého stylu
const isSimpleStyle = (settings?.styleTheme ?? 'simple') === 'simple';
const toggleTheme = () => { const toggleTheme = () => {
const newTheme: ThemePreference = effectiveDark ? 'light' : 'dark'; const newTheme: ThemePreference = effectiveDark ? 'light' : 'dark';
settings?.setThemePreference(newTheme); settings?.setThemePreference(newTheme);
@@ -144,7 +151,9 @@ export default function Header({ choices, dayIndex }: Props) {
} }
return <Navbar variant='dark' expand="lg"> return <Navbar variant='dark' expand="lg">
<Navbar.Brand href="/">Luncher</Navbar.Brand> <Navbar.Brand href="/" onClick={minigameSecret.onLogoClick} className={minigameSecret.armed ? "duel-armed" : undefined}>Luncher</Navbar.Brand>
<input ref={minigameSecret.inputRef} className="minigame-secret-input" tabIndex={-1} aria-hidden="true"
autoComplete="off" autoCorrect="off" autoCapitalize="none" spellCheck={false} />
<Navbar.Toggle aria-controls="basic-navbar-nav" /> <Navbar.Toggle aria-controls="basic-navbar-nav" />
<Navbar.Collapse id="basic-navbar-nav"> <Navbar.Collapse id="basic-navbar-nav">
<Nav className="nav"> <Nav className="nav">
@@ -156,11 +165,11 @@ export default function Header({ choices, dayIndex }: Props) {
> >
<FontAwesomeIcon icon={effectiveDark ? faSun : faMoon} /> <FontAwesomeIcon icon={effectiveDark ? faSun : faMoon} />
</button> </button>
<HuePicker {isSimpleStyle && <HuePicker
accentHue={settings?.accentHue ?? 142} accentHue={settings?.accentHue ?? 142}
isDark={effectiveDark} isDark={effectiveDark}
onChange={hue => settings?.setAccentHue(hue)} onChange={hue => settings?.setAccentHue(hue)}
/> />}
<NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown"> <NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown">
<NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item> <NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item>
<NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item> <NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item>
@@ -184,7 +193,7 @@ export default function Header({ choices, dayIndex }: Props) {
<> <>
<NavDropdown.Divider /> <NavDropdown.Divider />
<NavDropdown.Item onClick={() => setGenerateMockModalOpen(true)}>🔧 Generovat mock data</NavDropdown.Item> <NavDropdown.Item onClick={() => setGenerateMockModalOpen(true)}>🔧 Generovat mock data</NavDropdown.Item>
<NavDropdown.Item onClick={() => setClearMockModalOpen(true)}>🔧 Smazat data dne</NavDropdown.Item> <NavDropdown.Item onClick={() => setClearMockModalOpen(true)}>🔧 Smazat mock data</NavDropdown.Item>
</> </>
)} )}
<NavDropdown.Divider /> <NavDropdown.Divider />
@@ -192,6 +201,7 @@ export default function Header({ choices, dayIndex }: Props) {
</NavDropdown> </NavDropdown>
</Nav> </Nav>
</Navbar.Collapse> </Navbar.Collapse>
<DrawingGame />
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} /> <SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} /> <RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} /> <PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
+24
View File
@@ -0,0 +1,24 @@
// Plamen stylu Hell Girl — hoří na černé přes obsah dialogu (.modal-content je relative a ve stylu
// Hell Girl ořezává přetečení), pak černá odezní a odhalí obsah.
.hg-burn {
position: absolute;
inset: 0;
z-index: 10;
background: #020204; // barva pozadí GIFu, aby kolem plamene nebyl vidět obdélník
pointer-events: none;
animation: hg-burn-reveal 1.5s ease-in forwards;
}
.hg-burn-flame {
position: absolute;
left: 50%;
// Dlouhý dialog může přesahovat obrazovku — plamen držet ve viditelné části
top: min(50%, 45vh);
height: min(85%, 60vh);
transform: translate(-50%, -50%);
}
@keyframes hg-burn-reveal {
0%, 50% { opacity: 1; }
100% { opacity: 0; }
}
+81
View File
@@ -0,0 +1,81 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { createSymbolMask, loadHellGirlImages } from "../utils/hellGirlImages";
import "./HellGirlFlames.scss";
/** Plamen vzplane a rozpadne se v jiskry za ~1,2 s, pak GIF drží černý snímek — mezitím se obsah vynoří z černé. */
const BURN_MS = 1500;
/** `target` = obsah dialogu, ve kterém se hoří. */
type Burn = { src: string, target: HTMLElement };
/**
* Doplňky stylu Hell Girl: při otevření dialogu jeho obsah zčerná, uprostřed jednou
* vzplane plamen („Ippen, shinde miru?") a obsah se pak z černé vynoří. Dále symbol ohně ze znělky
* u titulku dne (nastaví CSS proměnnou --hg-symbol, dokud se nenačte, zůstávají v titulku původní květy).
*/
const HellGirlFlames: React.FC = () => {
const [burning, setBurning] = useState<Burn>();
const flameBlobRef = useRef<Blob | undefined>(undefined);
const burn = (target: HTMLElement) => {
const blob = flameBlobRef.current;
if (!blob || window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return;
// Pokaždé nová URL — jinak by prohlížeč GIF (bez opakování) znovu nepřehrál
setBurning({ src: URL.createObjectURL(blob), target });
};
useEffect(() => {
if (!burning) return;
const timeout = setTimeout(() => setBurning(undefined), BURN_MS);
return () => {
clearTimeout(timeout);
URL.revokeObjectURL(burning.src);
};
}, [burning]);
useEffect(() => {
let cancelled = false;
const root = document.documentElement;
loadHellGirlImages().then(images => {
if (!images || cancelled) return;
fetch(images.flame)
.then(r => r.ok ? r.blob() : undefined)
.then(blob => { flameBlobRef.current = blob; })
.catch(() => { /* bez plamenů */ });
createSymbolMask(images.symbol).then(mask => {
if (!mask || cancelled) return;
root.style.setProperty('--hg-symbol', `url(${mask})`);
root.setAttribute('data-hg-symbol', '');
});
});
return () => {
cancelled = true;
root.style.removeProperty('--hg-symbol');
root.removeAttribute('data-hg-symbol');
};
}, []);
// Dialogy (react-bootstrap) se vkládají přes portál přímo do <body>
useEffect(() => {
const observer = new MutationObserver(mutations => {
const modalContent = mutations
.flatMap(m => Array.from(m.addedNodes))
.map(node => node instanceof HTMLElement ? node.querySelector<HTMLElement>('.modal-content') : null)
.find(element => !!element);
if (modalContent) burn(modalContent);
});
observer.observe(document.body, { childList: true });
return () => observer.disconnect();
}, []);
if (!burning) return null;
return createPortal(
<div className="hg-burn" aria-hidden="true">
<img className="hg-burn-flame" src={burning.src} alt="" />
</div>,
burning.target,
);
};
export default HellGirlFlames;
+3
View File
@@ -1,4 +1,7 @@
.hue-picker-dropdown { .hue-picker-dropdown {
display: flex;
align-items: center;
.dropdown-toggle { .dropdown-toggle {
background: transparent !important; background: transparent !important;
border: none !important; border: none !important;
+117
View File
@@ -0,0 +1,117 @@
// Kompaktní výběr z nabídky podniku v tabulce skupinové objednávky
.menu-hint-cell {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 200px;
.select-search-container {
// Barvy knihovny napojené na témata aplikace (jinak bílé pozadí i v tmavém režimu)
--select-search-background: var(--luncher-bg-card);
--select-search-border: var(--luncher-border);
--select-search-text: var(--luncher-text);
--select-search-subtle-text: var(--luncher-text-muted);
--select-search-highlight: var(--luncher-bg-hover);
--select-search-selected: var(--luncher-primary);
--select-search-inverted-text: #fff;
--select-search-font: inherit;
width: 100%;
margin: 0;
position: relative;
.select-search-input {
height: 32px;
padding: 0 12px;
font-size: 0.85rem;
border-width: 1px;
background: var(--luncher-bg);
color: var(--luncher-text);
}
// Seznam přes okraje tabulky/karty (jinak by ho karta ořízla)
.select-search-select {
position: absolute;
z-index: 20;
left: 0;
min-width: 320px;
max-height: 320px;
overflow-y: auto;
}
.select-search-options, .select-search-group ul {
margin: 0;
padding: 0;
list-style: none;
}
.select-search-option {
height: auto;
padding: 6px 12px;
font-size: 0.85rem;
background: transparent;
color: var(--luncher-text);
text-align: left;
}
// Jídlo na limitu kusů — nejde přidat
.select-search-option:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.select-search-group-header {
padding: 4px 12px;
font-size: 0.7rem;
}
}
.menu-hint-items {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 2px;
}
.menu-hint-item {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 4px 1px 8px;
border-radius: 999px;
background: var(--luncher-bg-hover);
border: 1px solid var(--luncher-border);
font-size: 0.75rem;
line-height: 1.4;
small {
color: var(--luncher-text-muted);
}
button {
border: none;
background: none;
padding: 0 4px;
color: var(--luncher-text-muted);
font-weight: 700;
line-height: 1;
&:hover {
color: var(--luncher-danger);
}
}
}
.menu-hint-price {
cursor: help;
}
.menu-hint-use {
margin-left: 6px;
padding: 0;
border: none;
background: none;
color: var(--luncher-primary);
font-weight: 700;
text-decoration: underline;
}
}
+120
View File
@@ -0,0 +1,120 @@
import { useMemo } from "react";
import { OverlayTrigger, Tooltip } from "react-bootstrap";
import SelectSearch, { SelectedOptionValue, SelectSearchOption } from "react-select-search";
import { StoreMenuDish } from "../../../types";
import { countNoteItems, matchNoteToMenu, noteItemQuantity, splitNoteItems } from "../utils/menuMatch";
import { useGroupLimits } from "../hooks/useGroupLimits";
import "./MenuHintCell.scss";
type Props = {
/** Celá nabídka podniku (stažená z Bolt Food/Wolt), undefined = načítá se / není */
menu?: StoreMenuDish[],
note?: string,
/** Zadaná částka člena v haléřích */
amount?: number,
canEdit: boolean,
onPick: (dish: StoreMenuDish) => void,
onUsePrice: (priceKc: number) => void,
/** Odebere jeden kus položky poznámky na daném indexu (a odečte její cenu) */
onRemoveItem: (index: number) => void,
};
const formatDish = (d: StoreMenuDish) => d.price != null ? `${d.name} (${d.price} Kč)` : d.name;
/**
* Buňka „Z nabídky" ve skupinové objednávce: výběr položky ze stažené nabídky podniku
* (doplní poznámku i částku) a kontrola ceny rozpoznané z poznámky proti zadané částce.
*/
export default function MenuHintCell({ menu, note, amount, canEdit, onPick, onUsePrice, onRemoveItem }: Readonly<Props>) {
const { maxItemQuantity, maxItems } = useGroupLimits();
const options = useMemo<SelectSearchOption[]>(() => {
if (!menu?.length) return [];
const groups = new Map<string, SelectSearchOption[]>();
menu.forEach((d, index) => {
const category = d.category || 'Ostatní';
if (!groups.has(category)) groups.set(category, []);
// Jídlo, kterého už je v objednávce maximum kusů, nejde přidat znovu
const atLimit = noteItemQuantity(note, d.name, menu) >= maxItemQuantity;
groups.get(category)!.push({ name: atLimit ? `${formatDish(d)} — max ${maxItemQuantity} ks` : formatDish(d), value: String(index), disabled: atLimit });
});
return [...groups.entries()].map(([name, items]) => ({ name, type: 'group', items }));
}, [menu, note, maxItemQuantity]);
const itemLimitReached = useMemo(() => countNoteItems(note, menu ?? []) >= maxItems, [note, menu, maxItems]);
const match = useMemo(() => menu ? matchNoteToMenu(note, menu) : undefined, [menu, note]);
const items = useMemo(() => splitNoteItems(note, menu), [note, menu]);
if (!menu?.length) {
return <small className="text-muted">—</small>;
}
const menuPrice = match?.total;
const differs = menuPrice != null && amount != null && Math.round(menuPrice * 100) !== amount;
const unmatched = match?.parts.filter(p => !p.dish).map(p => p.text) ?? [];
return (
<div className="menu-hint-cell">
{canEdit && itemLimitReached && (
<small className="text-muted">Dosažen limit {maxItems} položek — nejdřív něco odeber (×).</small>
)}
{canEdit && !itemLimitReached && (
<SelectSearch
search
options={options}
placeholder="Přidat z nabídky…"
value=""
onBlur={() => { }}
onFocus={() => { }}
onChange={(value: SelectedOptionValue | SelectedOptionValue[]) => {
const dish = menu[Number(value)];
if (dish) onPick(dish);
}}
/>
)}
{canEdit && items.length > 0 && (
<div className="menu-hint-items">
{items.map((text, i) => {
// Rozpoznaná položka ze stejného rozdělení poznámky — kvůli ceně na čipu
const part = match && match.parts.length === items.length ? match.parts[i] : undefined;
const price = part?.dish?.price;
const quantity = part?.quantity ?? 1;
return (
<span key={`${i}-${text}`} className="menu-hint-item">
{text}{price != null && <small> {quantity > 1 ? `${quantity}× ${price} = ${quantity * price}` : price} Kč</small>}
<button type="button" onClick={() => onRemoveItem(i)} title={`Odebrat 1× ${text}`} aria-label={`Odebrat ${text}`}>×</button>
</span>
);
})}
</div>
)}
{match && (
<OverlayTrigger
placement="top"
overlay={
<Tooltip>
{match.parts.map((p, i) => (
<div key={i}>
{p.dish
? `${p.quantity > 1 ? `${p.quantity}× ` : ''}${formatDish(p.dish)}`
: `„${p.text}" — v nabídce nenalezeno`}
</div>
))}
</Tooltip>
}
>
<small className={`menu-hint-price ${differs ? 'text-warning' : 'text-muted'}`}>
{menuPrice != null
? <>Dle nabídky: <strong>{menuPrice} Kč</strong></>
: <>Nalezeno {match.parts.length - unmatched.length}/{match.parts.length} položek</>}
{differs && canEdit && (
<button type="button" className="menu-hint-use" onClick={() => onUsePrice(menuPrice!)}
title="Nastavit částku podle ceny v nabídce">
použít
</button>
)}
</small>
</OverlayTrigger>
)}
</div>
);
}
@@ -1,9 +1,9 @@
.bolt-progress { .tracking-progress {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
max-width: 400px; max-width: 400px;
.bolt-step { .tracking-step {
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -11,7 +11,7 @@
flex: 1; flex: 1;
min-width: 64px; min-width: 64px;
.bolt-dot { .tracking-dot {
position: relative; position: relative;
z-index: 1; z-index: 1;
width: 12px; width: 12px;
@@ -20,7 +20,7 @@
background: var(--luncher-border, #ced4da); background: var(--luncher-border, #ced4da);
} }
.bolt-label { .tracking-label {
margin-top: 2px; margin-top: 2px;
font-size: 0.7em; font-size: 0.7em;
color: var(--luncher-text-muted, #6c757d); color: var(--luncher-text-muted, #6c757d);
@@ -40,7 +40,7 @@
} }
&.done { &.done {
.bolt-dot { .tracking-dot {
background: var(--bs-success, #198754); background: var(--bs-success, #198754);
} }
@@ -49,19 +49,19 @@
} }
} }
&.active .bolt-label { &.active .tracking-label {
font-weight: 600; font-weight: 600;
color: inherit; color: inherit;
} }
} }
// Pulzování aktivního kroku, dokud sledování běží // Pulzování aktivního kroku, dokud sledování běží
&.live .bolt-step.active .bolt-dot { &.live .tracking-step.active .tracking-dot {
animation: bolt-pulse 2s ease-in-out infinite; animation: tracking-pulse 2s ease-in-out infinite;
} }
} }
@keyframes bolt-pulse { @keyframes tracking-pulse {
0%, 100% { 0%, 100% {
box-shadow: 0 0 0 0 rgba(25, 135, 84, 0.5); box-shadow: 0 0 0 0 rgba(25, 135, 84, 0.5);
} }
+153
View File
@@ -0,0 +1,153 @@
import { OverlayTrigger, Tooltip } from 'react-bootstrap';
import { TrackingProvider } from '../../../types';
import './OrderProgress.scss';
const STEPS = ['Přijato', 'Příprava', 'Vyzvedávání', 'Na cestě', 'Doručeno'];
/** Lidský název rozvozové služby do tooltipu. */
export const PROVIDER_LABEL: Record<TrackingProvider, string> = {
bolt: 'Bolt Food',
wolt: 'Wolt',
};
/**
* Známé stavy objednávky z Bolt API → index kroku ve stepperu.
* Pozor: waiting_delivery znamená "jídlo čeká v podniku na vyzvednutí",
* nikoli "na cestě" — tu signalizuje až stav kurýra (picked_up apod.).
* Krok „Vyzvedávání" je vyhrazen pro kombinaci „kurýr u podniku + jídlo hotové"
* (viz logika níže), samotný order_state ho neudělí — jinak bychom hlásili
* vyzvedávání i když se ještě peče (chování, na které si Bolt sám občas stěžuje).
*/
const BOLT_STATE_TO_STEP: Record<string, number> = {
created: 0,
pending: 0,
waiting_acceptance: 0,
accepted: 0,
waiting_preparation: 0,
preparing: 1,
waiting_delivery: 1,
ready_for_pickup: 1,
waiting_courier: 1,
waiting_pickup: 1,
picked_up: 3,
in_delivery: 3,
delivering: 3,
heading_to_client: 3,
delivered: 4,
finished: 4,
};
/** Stavy kurýra z Bolt API → index kroku. Kurýr u podniku ještě neznamená "na cestě". */
const BOLT_COURIER_STATE_TO_STEP: Record<string, number> = {
matched: 0,
accepted: 0,
heading_to_provider: 1,
arrived_to_provider: 1,
picked_up: 3,
heading_to_client: 3,
delivering: 3,
arrived_to_client: 3,
delivered: 4,
};
/**
* Známé stavy objednávky z Wolt tracking API → index kroku ve stepperu.
* Pozor: Wolt hlásí stav podniku, ne rozvozu — "ready" (jídlo hotové) zůstává
* i celou dobu, co kurýr objednávku veze. Kroky „Vyzvedávání" a „Na cestě" proto
* neurčuje status, ale odvozený stav kurýra (viz trackingProviders.ts).
*/
const WOLT_STATE_TO_STEP: Record<string, number> = {
received: 0,
acknowledged: 0,
confirmed: 0,
waiting: 0,
production: 1,
preparing: 1,
ready: 1,
picked_up: 3,
transport: 3,
delivering: 3,
delivered: 4,
};
/** Popis odvozeného stavu kurýra u Woltu do tooltipu. */
const WOLT_COURIER_INFO: Record<string, string> = {
assigned: 'kurýr přiřazen',
delivering: 'kurýr veze objednávku',
delivering_other: 'kurýr veze objednávku, ale nejdřív doručuje jinou',
};
/** Stavy, ve kterých už jídlo čeká připravené na kurýra. */
const PICKUP_READY_STATES: Record<TrackingProvider, Set<string>> = {
bolt: new Set(['waiting_delivery', 'ready_for_pickup', 'waiting_courier', 'waiting_pickup']),
wolt: new Set(['ready']),
};
/** Neznámé stavy se mapují heuristicky podle klíčových slov. */
function stepForOrderState(provider: TrackingProvider, state: string): number | 'cancelled' {
const s = state.toLowerCase();
const known = provider === 'wolt' ? WOLT_STATE_TO_STEP : BOLT_STATE_TO_STEP;
if (s in known) return known[s];
if (/cancel|reject|fail/.test(s)) return 'cancelled';
if (/delivered|finished/.test(s)) return 4;
if (/accept|pending|created|received/.test(s)) return 0;
if (/^waiting|prepar|ready|cook|production/.test(s)) return 1;
if (/picked|delivering|heading_to_client|transport/.test(s)) return 3;
return 0;
}
function stepForCourierState(provider: TrackingProvider, state?: string): number {
if (!state) return 0;
const s = state.toLowerCase();
// Wolt má jen odvozené stavy: 'assigned' o kroku nic neříká, 'delivering'
// i 'delivering_other' znamenají, že kurýr naši objednávku už veze
if (provider === 'wolt') return s.startsWith('delivering') ? 3 : 0;
if (s in BOLT_COURIER_STATE_TO_STEP) return BOLT_COURIER_STATE_TO_STEP[s];
if (/picked|client|delivering|transport/.test(s)) return 3;
return 0;
}
interface Props {
/** Rozvozová služba, ze které stav pochází */
provider: TrackingProvider;
/** Raw stav objednávky (Bolt order_state, Wolt status) */
state: string;
/** Raw stav kurýra (Bolt courier.state, Wolt 'assigned') */
courierState?: string;
/** Zda sledování stále běží (skupina má trackingCode) */
tracking: boolean;
}
/** Mini progress stepper se stavem objednávky u rozvozové služby. */
export default function OrderProgress({ provider, state, courierState, tracking }: Props) {
const label = PROVIDER_LABEL[provider] ?? provider;
const orderStep = stepForOrderState(provider, state);
if (orderStep === 'cancelled') {
return <small className="text-danger">Objednávka {label} byla zrušena</small>;
}
// Stav kurýra může krok jen zpřesnit dopředu (např. waiting_delivery + picked_up → Na cestě)
let step = Math.max(orderStep, stepForCourierState(provider, courierState));
// Vyzvedávání = kurýr u podniku a jídlo skutečně hotové. Sám arrived_to_provider
// (bez toho, že by jídlo bylo hotové) nestačí — viz komentář u BOLT_STATE_TO_STEP.
const courierAtProvider = provider === 'wolt'
? !!courierState
: courierState?.toLowerCase() === 'arrived_to_provider';
const foodReadyForPickup = PICKUP_READY_STATES[provider]?.has(state.toLowerCase());
if (step < 3 && courierAtProvider && foodReadyForPickup) step = 2;
const courierInfo = provider === 'wolt'
? WOLT_COURIER_INFO[courierState ?? ''] ?? 'kurýr přiřazen'
: `kurýr: ${courierState}`;
const rawInfo = courierState ? `${state} / ${courierInfo}` : state;
return (
<OverlayTrigger overlay={<Tooltip>Stav z {label}: {rawInfo}</Tooltip>}>
<div className={`tracking-progress${tracking && step < 4 ? ' live' : ''}`}>
{STEPS.map((stepLabel, i) => (
<div key={stepLabel} className={`tracking-step${i <= step ? ' done' : ''}${i === step ? ' active' : ''}`}>
<div className="tracking-dot" />
<div className="tracking-label">{stepLabel}</div>
</div>
))}
</div>
</OverlayTrigger>
);
}
+57
View File
@@ -0,0 +1,57 @@
.price-history {
margin: 4px 0 8px;
padding: 8px 10px;
max-height: 260px;
overflow-y: auto;
border-left: 3px solid var(--luncher-border);
font-size: 0.8rem;
ul {
list-style: none;
margin: 2px 0 6px;
padding: 0;
}
li {
padding: 1px 0;
}
.price-tag {
display: inline-block;
min-width: 72px;
font-weight: 700;
}
.price-up .price-tag { color: var(--luncher-danger); }
.price-down .price-tag { color: var(--luncher-success); }
.price-new .price-tag { color: var(--luncher-primary); }
.price-removed { color: var(--luncher-text-muted); }
}
.price-history-entry + .price-history-entry {
margin-top: 6px;
}
.price-history-tabs {
display: inline-flex;
gap: 2px;
padding: 2px;
margin-bottom: 6px;
border-radius: 999px;
background: var(--luncher-bg-hover);
button {
border: none;
background: none;
border-radius: 999px;
padding: 2px 10px;
font-size: 0.75rem;
font-weight: 700;
color: var(--luncher-text-secondary);
&.active {
background: var(--luncher-bg-card);
color: var(--luncher-primary);
}
}
}
+112
View File
@@ -0,0 +1,112 @@
import { useEffect, useMemo, useState } from "react";
import { getStoreMenuHistory, StoreMenuProvider, StorePriceChange, StorePriceHistoryEntry } from "../../../types";
import { getHumanDateTime } from "../Utils";
import { computeSinceStart, plural, SinceStart } from "../utils/priceHistory";
import "./PriceHistory.scss";
type Props = {
store: string,
provider: StoreMenuProvider,
};
/** Popis jedné změny: zdražení/zlevnění s rozdílem, nová nebo odebraná položka. */
function ChangeRow({ change }: Readonly<{ change: StorePriceChange }>) {
const { name, before, after } = change;
if (before == null && after != null) {
return <li className="price-new"><span className="price-tag">nové</span> {name} <strong>{after} Kč</strong></li>;
}
if (after == null) {
return <li className="price-removed"><span className="price-tag">odebráno</span> <s>{name}</s>{before != null && <> ({before} Kč)</>}</li>;
}
const diff = after - (before ?? after);
const up = diff > 0;
return (
<li className={up ? 'price-up' : 'price-down'}>
<span className="price-tag">{up ? '▲' : '▼'} {up ? '+' : ''}{diff} Kč</span> {name}: {before} → <strong>{after} Kč</strong>
</li>
);
}
function EntryRow({ entry }: Readonly<{ entry: StorePriceHistoryEntry }>) {
const when = getHumanDateTime(new Date(entry.at));
if (entry.initial) {
return <div className="price-history-entry"><small className="text-muted">{when} — první stažení ({plural(entry.itemCount ?? 0, 'položka', 'položky', 'položek')})</small></div>;
}
const ups = entry.changes.filter(c => c.before != null && c.after != null && c.after > c.before).length;
const downs = entry.changes.filter(c => c.before != null && c.after != null && c.after < c.before).length;
return (
<div className="price-history-entry">
<small className="text-muted">
{when} — {plural(entry.changes.length, 'změna', 'změny', 'změn')}{ups > 0 && `, ${ups}× zdražení`}{downs > 0 && `, ${downs}× zlevnění`}
</small>
<ul>{entry.changes.map((c, i) => <ChangeRow key={i} change={c} />)}</ul>
</div>
);
}
function SinceStartView({ summary }: Readonly<{ summary: SinceStart }>) {
const { since, changed, added, removed } = summary;
if (changed.length + added.length + removed.length === 0) {
return <small className="text-muted">Od {getHumanDateTime(new Date(since))} beze změn.</small>;
}
const ups = changed.filter(c => c.current > c.start).length;
const avgPct = changed.length
? changed.reduce((sum, c) => sum + (c.current - c.start) / c.start * 100, 0) / changed.length
: 0;
return (
<div className="price-history-entry">
<small className="text-muted">
Od {getHumanDateTime(new Date(since))}: {plural(changed.length, 'změna ceny', 'změny cen', 'změn cen')} ({ups}× zdražení, {changed.length - ups}× zlevnění)
{changed.length > 0 && `, průměrně ${avgPct > 0 ? '+' : ''}${avgPct.toFixed(1)} %`}
{added.length > 0 && `, ${plural(added.length, 'nová položka', 'nové položky', 'nových položek')}`}{removed.length > 0 && `, ${plural(removed.length, 'odebraná', 'odebrané', 'odebraných')}`}
</small>
<ul>
{changed.map(c => {
const diff = c.current - c.start;
const up = diff > 0;
return (
<li key={c.name} className={up ? 'price-up' : 'price-down'}>
<span className="price-tag">{up ? '▲' : '▼'} {up ? '+' : ''}{diff} Kč</span> {c.name}: {c.start} → <strong>{c.current} Kč</strong>
<small className="text-muted"> ({up ? '+' : ''}{(diff / c.start * 100).toFixed(0)} %)</small>
</li>
);
})}
{added.map(a => <li key={`+${a.name}`} className="price-new"><span className="price-tag">nové</span> {a.name}{a.price != null && <> <strong>{a.price} Kč</strong></>}</li>)}
{removed.map(r => <li key={`-${r.name}`} className="price-removed"><span className="price-tag">odebráno</span> <s>{r.name}</s>{r.price != null && <> ({r.price} Kč)</>}</li>)}
</ul>
</div>
);
}
/** Historie cen nabídky obchodu u jedné služby (verze z jednotlivých stažení, nejnovější nahoře). */
export default function PriceHistory({ store, provider }: Readonly<Props>) {
const [entries, setEntries] = useState<StorePriceHistoryEntry[]>();
const [view, setView] = useState<'versions' | 'since'>('versions');
const summary = useMemo(() => entries ? computeSinceStart(entries) : undefined, [entries]);
useEffect(() => {
getStoreMenuHistory({ query: { name: store } })
.then(r => setEntries(r.data?.providers.find(p => p.provider === provider)?.entries ?? []))
.catch(() => setEntries([]));
}, [store, provider]);
if (!entries) return <small className="text-muted">Načítám historii…</small>;
if (entries.length === 0) return <small className="text-muted">Historie cen zatím není — vznikne s prvním stažením nabídky.</small>;
const hasChanges = entries.some(e => e.changes.length > 0);
return (
<div className="price-history">
{hasChanges && (
<div className="price-history-tabs">
<button type="button" className={view === 'versions' ? 'active' : ''} onClick={() => setView('versions')}>Po verzích</button>
<button type="button" className={view === 'since' ? 'active' : ''} onClick={() => setView('since')}>Od začátku</button>
</div>
)}
{view === 'since' && summary
? <SinceStartView summary={summary} />
: entries.map(e => <EntryRow key={e.at} entry={e} />)}
{entries.length === 1 && entries[0].initial && (
<small className="text-muted d-block mt-1">Změny se objeví po dalším stažení (↻).</small>
)}
</div>
);
}
+475
View File
@@ -0,0 +1,475 @@
.wheel-confetti {
position: fixed;
inset: 0;
z-index: 2000; // nad modalem (bootstrap 1055)
pointer-events: none;
overflow: hidden;
> span {
position: absolute;
will-change: transform, opacity;
animation-fill-mode: both;
}
}
// Vrh: rychle vzhůru do vrcholu (zpomalení), pak volný pád (zrychlení)
@keyframes confetti-throw {
0% { transform: translate3d(0, 0, 0) rotate(0deg) scale(0.4); opacity: 0; animation-timing-function: cubic-bezier(0.2, 0.7, 0.4, 1); }
6% { opacity: 1; }
38% { transform: translate3d(var(--x1), var(--y1), 0) rotate(var(--r1)) scale(1); animation-timing-function: cubic-bezier(0.5, 0, 0.9, 0.6); }
88% { opacity: 1; }
100% { transform: translate3d(var(--x2), var(--y2), 0) rotate(var(--r2)) scale(1); opacity: 0; }
}
// Déšť: plynulý pád shora s mírným houpáním
@keyframes confetti-rain {
0% { transform: translate3d(0, 0, 0) rotate(0deg); opacity: 0; animation-timing-function: ease-in-out; }
8% { opacity: 1; }
40% { transform: translate3d(var(--x1), var(--y1), 0) rotate(var(--r1)); animation-timing-function: ease-in; }
88% { opacity: 1; }
100% { transform: translate3d(var(--x2), var(--y2), 0) rotate(var(--r2)); opacity: 0; }
}
// Samolepky Umamusume — stín pro čitelnost na libovolném pozadí
.confetti-uma {
filter: drop-shadow(0 3px 3px rgb(0 0 0 / 0.2));
}
.confetti-uma-emoji {
line-height: 1;
}
.confetti-uma-badge {
padding: 4px 14px;
border-radius: 999px;
border: 3px solid #fff;
color: #fff;
font-family: 'M PLUS Rounded 1c', 'Nunito', sans-serif;
font-weight: 900;
font-size: 1.15rem;
white-space: nowrap;
text-shadow: 0 2px 0 rgb(0 0 0 / 0.2);
box-shadow: 0 4px 0 rgb(0 0 0 / 0.15);
}
.confetti-uma-img img {
width: 92px;
height: auto;
}
// Portréty (bez průhlednosti) jako kulaté samolepky s barevným lemem
.confetti-uma-img.round img,
.confetti-uma-voice img.round {
width: 84px;
height: 84px;
object-fit: cover;
border-radius: 50%;
border: 4px solid #fff;
box-shadow: 0 0 0 3px #ff6fa3, 0 4px 8px rgb(0 0 0 / 0.3);
}
.confetti-uma-voice img.round {
width: 96px;
height: 96px;
margin-bottom: 8px;
}
.confetti-uma-banner {
position: absolute;
top: 38%;
left: 50%;
transform: translate(-50%, -50%);
padding: 12px 48px;
font-family: 'M PLUS Rounded 1c', 'Nunito', sans-serif;
font-weight: 900;
font-size: clamp(2rem, 7vw, 4rem);
color: #fff;
background: linear-gradient(90deg, #e84d86, #ff6fa3);
clip-path: polygon(24px 0, 100% 0, calc(100% - 24px) 100%, 0 100%);
text-shadow: 0 3px 0 rgb(0 0 0 / 0.2);
border-top: 5px solid #ffc531;
border-bottom: 5px solid #ffc531;
animation: uma-banner 1.8s cubic-bezier(0.2, 1.4, 0.4, 1) both;
}
@keyframes uma-banner {
0% { transform: translate(-150%, -50%) skewX(-12deg); opacity: 0; }
25% { transform: translate(-50%, -50%) skewX(-12deg); opacity: 1; }
75% { transform: translate(-50%, -50%) skewX(-12deg) scale(1.04); opacity: 1; }
100% { transform: translate(60%, -50%) skewX(-12deg); opacity: 0; }
}
// „Hláška" postavy při finálním výsledku
.confetti-uma-voice {
position: absolute;
left: 16px;
bottom: 16px;
display: flex;
align-items: flex-end;
gap: 8px;
max-width: min(460px, calc(100vw - 32px));
animation: uma-voice 4.6s ease both;
img {
flex: none;
width: 110px;
height: auto;
filter: drop-shadow(0 4px 6px rgb(0 0 0 / 0.3));
}
}
.confetti-uma-bubble {
position: relative;
margin-bottom: 40px;
padding: 12px 16px;
background: #fff;
color: #1d3322;
border: 3px solid #ff6fa3;
border-radius: 18px;
box-shadow: 0 4px 0 #e84d86;
font-family: 'M PLUS Rounded 1c', 'Nunito', sans-serif;
p {
margin: 0;
font-weight: 800;
font-size: 0.95rem;
line-height: 1.35;
}
small {
display: block;
margin-top: 4px;
color: #e84d86;
font-weight: 800;
}
// ocásek bubliny směrem k postavě
&::before {
content: '';
position: absolute;
left: -14px;
bottom: 14px;
border: 8px solid transparent;
border-right: 10px solid #ff6fa3;
}
}
@keyframes uma-voice {
0% { opacity: 0; transform: translateY(40px) scale(0.8); }
10% { opacity: 1; transform: translateY(0) scale(1.03); }
15% { transform: translateY(0) scale(1); }
85% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(20px); }
}
// ============================================
// Konfety ve stylu My Little Pony
// ============================================
.confetti-mlp {
filter: drop-shadow(0 3px 3px rgb(0 0 0 / 0.2));
}
.confetti-mlp-badge {
padding: 4px 14px;
border-radius: 999px;
border: 3px solid #fff;
color: #fff;
font-family: 'Fredoka', 'Nunito', sans-serif;
font-weight: 700;
font-size: 1.1rem;
white-space: nowrap;
text-shadow: 0 2px 0 rgb(0 0 0 / 0.2);
box-shadow: 0 0 12px rgb(255 255 255 / 0.7), 0 4px 0 rgb(0 0 0 / 0.15);
}
// Duhový nápis „Sonic Rainboom" přes celou obrazovku
.confetti-mlp-banner {
position: absolute;
top: 38%;
left: 50%;
transform: translate(-50%, -50%);
padding: 12px 44px;
font-family: 'Fredoka', 'Nunito', sans-serif;
font-weight: 700;
font-size: clamp(1.8rem, 6.5vw, 3.6rem);
white-space: nowrap;
color: #fff;
border-radius: 999px;
background: linear-gradient(90deg, #ee4144, #f37033, #e3b829, #62bc4d, #1e98d3, #672f89);
border: 5px solid #fff;
text-shadow: 0 3px 0 rgb(0 0 0 / 0.25);
box-shadow: 0 0 30px rgb(236 61 143 / 0.5), 0 8px 0 rgb(0 0 0 / 0.12);
animation: mlp-banner 2s cubic-bezier(0.2, 1.4, 0.4, 1) both;
}
@keyframes mlp-banner {
0% { transform: translate(-50%, -50%) scale(0.2) rotate(-8deg); opacity: 0; }
25% { transform: translate(-50%, -50%) scale(1.08) rotate(0deg); opacity: 1; }
35% { transform: translate(-50%, -50%) scale(1); }
75% { transform: translate(-50%, -50%) scale(1.03); opacity: 1; }
100% { transform: translate(-50%, -160%) scale(0.9); opacity: 0; }
}
// Postava s hláškou — místo obrázku kulatý „cutie mark" s emoji
.confetti-mlp-avatar {
flex: none;
display: grid;
place-items: center;
width: 84px;
height: 84px;
margin-bottom: 8px;
font-size: 46px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #fff, #f6ecff);
border: 4px solid #fff;
box-shadow: 0 0 0 4px var(--mlp-speaker, #8e4fd1), 0 4px 10px rgb(0 0 0 / 0.3);
}
.confetti-mlp-bubble {
position: relative;
margin-bottom: 40px;
padding: 12px 16px;
background: #fff;
color: #2e1f47;
border: 3px solid var(--mlp-speaker, #8e4fd1);
border-radius: 22px;
box-shadow: 0 4px 0 var(--mlp-speaker, #8e4fd1);
font-family: 'Fredoka', 'Nunito', sans-serif;
p {
margin: 0;
font-weight: 600;
font-size: 1rem;
line-height: 1.35;
}
small {
display: block;
margin-top: 4px;
color: var(--mlp-speaker, #8e4fd1);
font-weight: 700;
}
// ocásek bubliny směrem k postavě
&::before {
content: '';
position: absolute;
left: -14px;
bottom: 14px;
border: 8px solid transparent;
border-right: 10px solid var(--mlp-speaker, #8e4fd1);
}
}
// ============================================
// Konfety ve stylu Hell Girl
// ============================================
.confetti-hg {
filter: drop-shadow(0 0 6px rgb(200 16 46 / 0.55)) drop-shadow(0 3px 3px rgb(0 0 0 / 0.3));
}
.confetti-hg-badge {
padding: 4px 14px;
border: 1px solid #c8102e;
outline: 1px solid rgb(200 16 46 / 0.4);
outline-offset: 2px;
color: #fff4ee;
font-family: 'Shippori Mincho', 'Noto Serif JP', serif;
font-weight: 800;
font-size: 1.1rem;
letter-spacing: 1px;
white-space: nowrap;
text-shadow: 0 0 8px rgb(232 52 79 / 0.7);
}
// Nápis „poslání do pekla" — černá deska s rudým lemem, vynoří se z mlhy
.confetti-hg-banner {
position: absolute;
top: 38%;
left: 50%;
transform: translate(-50%, -50%);
padding: 14px 48px;
font-family: 'Shippori Mincho', 'Noto Serif JP', serif;
font-weight: 800;
font-size: clamp(2rem, 7vw, 4rem);
letter-spacing: 0.3em;
white-space: nowrap;
color: #fff4ee;
background: linear-gradient(90deg, transparent, #12070a 15%, #12070a 85%, transparent);
border-top: 2px solid #c8102e;
border-bottom: 2px solid #c8102e;
text-shadow: 0 0 18px rgb(232 52 79 / 0.9), 0 0 4px #c8102e;
animation: hg-banner 2.6s ease both;
}
@keyframes hg-banner {
0% { transform: translate(-50%, -50%) scale(1.15); opacity: 0; filter: blur(12px); }
25% { transform: translate(-50%, -50%) scale(1); opacity: 1; filter: blur(0); }
75% { transform: translate(-50%, -50%) scale(1); opacity: 1; filter: blur(0); }
100% { transform: translate(-50%, -50%) scale(0.96); opacity: 0; filter: blur(8px); }
}
// Postava s hláškou — tmavý odznak s rudou září a bublina jako svitek
.confetti-hellgirl-voice {
.confetti-mlp-avatar {
border-color: #12070a;
background: radial-gradient(circle at 50% 60%, #3a0d16, #12070a);
box-shadow: 0 0 0 3px var(--mlp-speaker, #c8102e), 0 0 18px rgb(200 16 46 / 0.6);
}
.confetti-mlp-bubble {
background: #170c0f;
color: #f3e6e2;
border: 1px solid var(--mlp-speaker, #c8102e);
border-radius: 3px;
box-shadow: 0 0 16px rgb(0 0 0 / 0.6), 0 0 10px rgb(200 16 46 / 0.3);
font-family: 'Shippori Mincho', 'Noto Serif JP', serif;
}
}
// ============================================
// Konfety ve stylu K-ON!
// ============================================
.confetti-kon {
filter: drop-shadow(0 3px 3px rgb(39 49 95 / 0.25));
}
// Samolepky jako na pouzdru kytary — bílý okraj a lehce natočené
.confetti-kon-badge {
padding: 4px 14px;
border-radius: 10px;
border: 3px solid #fff;
color: #fff;
font-family: 'Zen Maru Gothic', 'M PLUS Rounded 1c', sans-serif;
font-weight: 900;
font-size: 1.05rem;
white-space: nowrap;
text-shadow: 0 1px 0 rgb(0 0 0 / 0.2);
box-shadow: 0 3px 0 rgb(39 49 95 / 0.25);
}
// Nápis „Houkago Tea Time" — vstupenka na školní koncert s notovou osnovou
.confetti-kon-banner {
position: absolute;
top: 38%;
left: 50%;
transform: translate(-50%, -50%);
padding: 14px 44px;
font-family: 'Zen Maru Gothic', 'M PLUS Rounded 1c', sans-serif;
font-weight: 900;
font-size: clamp(1.6rem, 6vw, 3.4rem);
white-space: nowrap;
color: #27315f;
border-radius: 18px;
background:
repeating-linear-gradient(180deg, transparent 0 9px, rgb(39 49 95 / 0.12) 9px 10px) center / 100% 50px no-repeat,
#fffaf0;
border: 4px solid #27315f;
box-shadow: 0 0 0 6px #fffaf0, 0 0 0 9px #d43f62, 0 12px 26px rgb(0 0 0 / 0.25);
animation: kon-banner 2.2s cubic-bezier(0.3, 1.5, 0.5, 1) both;
}
// Vyskočí do rytmu (raz-dva) a odplave nahoru
@keyframes kon-banner {
0% { transform: translate(-50%, -50%) scale(0.3) rotate(6deg); opacity: 0; }
18% { transform: translate(-50%, -50%) scale(1.1) rotate(-3deg); opacity: 1; }
28% { transform: translate(-50%, -50%) scale(1) rotate(-2deg); }
40% { transform: translate(-50%, -50%) scale(1.05) rotate(-2deg); }
50% { transform: translate(-50%, -50%) scale(1) rotate(-2deg); }
80% { transform: translate(-50%, -50%) scale(1) rotate(-2deg); opacity: 1; }
100% { transform: translate(-50%, -150%) scale(0.9) rotate(-2deg); opacity: 0; }
}
// Postava s hláškou — krémový odznak a bublina jako stránka notového sešitu
.confetti-kon-voice {
.confetti-mlp-avatar {
background: radial-gradient(circle at 40% 35%, #fffaf0, #fbe6c8);
box-shadow: 0 0 0 4px var(--mlp-speaker, #d43f62), 0 4px 10px rgb(0 0 0 / 0.3);
}
.confetti-mlp-bubble {
background:
repeating-linear-gradient(180deg, transparent 0 21px, rgb(39 49 95 / 0.08) 21px 22px),
#fffaf0;
color: #27315f;
border-radius: 16px;
font-family: 'Zen Maru Gothic', 'M PLUS Rounded 1c', sans-serif;
p {
font-weight: 700;
}
}
}
// ============================================
// Konfety ve stylu Bocchi the Rock!
// ============================================
.confetti-bocchi {
filter: drop-shadow(3px 3px 0 rgb(29 26 34 / 0.85));
}
// Samolepky z flyeru live house — černá linka a tvrdý stín
.confetti-bocchi-badge {
padding: 4px 14px;
border-radius: 6px;
border: 3px solid #1d1a22;
color: #fff;
font-family: 'Dela Gothic One', 'M PLUS Rounded 1c', sans-serif;
font-size: 1.05rem;
white-space: nowrap;
}
// Nápis „Kessoku Band" — žlutá samolepka s růžovým stínem, při dopadu se zachvěje jako Bocchi
.confetti-bocchi-banner {
position: absolute;
top: 38%;
left: 50%;
transform: translate(-50%, -50%);
padding: 12px 40px;
font-family: 'Dela Gothic One', 'M PLUS Rounded 1c', sans-serif;
font-size: clamp(1.8rem, 7vw, 4rem);
white-space: nowrap;
color: #1d1a22;
background: #ffcf33;
border: 5px solid #1d1a22;
border-radius: 10px;
box-shadow: 10px 10px 0 #e0457b, 18px 18px 0 #1d1a22;
animation: bocchi-banner 2.4s ease both;
}
@keyframes bocchi-banner {
0% { transform: translate(-50%, -50%) scale(2.2) rotate(-8deg); opacity: 0; }
14% { transform: translate(-50%, -50%) scale(1) rotate(-3deg); opacity: 1; }
17% { transform: translate(calc(-50% - 6px), -50%) rotate(-3deg); }
20% { transform: translate(calc(-50% + 6px), -50%) rotate(-3deg); }
23% { transform: translate(calc(-50% - 3px), -50%) rotate(-3deg); }
26%, 80% { transform: translate(-50%, -50%) rotate(-3deg); opacity: 1; }
100% { transform: translate(-50%, 60%) rotate(4deg); opacity: 0; }
}
// Postava s hláškou — hranatý odznak a bublina jako z mangy s tvrdým stínem
.confetti-bocchi-voice {
.confetti-mlp-avatar {
border-radius: 12px;
border: 3px solid #1d1a22;
background: #ffcf33;
box-shadow: 5px 5px 0 var(--mlp-speaker, #e0457b);
}
.confetti-mlp-bubble {
background: #fff;
color: #1d1a22;
border: 3px solid #1d1a22;
border-radius: 10px;
box-shadow: 5px 5px 0 var(--mlp-speaker, #e0457b);
font-family: 'M PLUS Rounded 1c', 'Nunito', sans-serif;
p {
font-weight: 800;
}
&::before {
border-right-color: #1d1a22;
}
}
}
+278
View File
@@ -0,0 +1,278 @@
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { StyleTheme } from "../context/settings";
import { UmaCharacter, loadUmaCharacters } from "../utils/umaCharacters";
import "./WheelConfetti.scss";
type WheelTheme = StyleTheme;
type Props = {
theme: WheelTheme,
/** Mění se s každým výsledkem — nová hodnota spustí novou salvu. */
burstKey: number,
/** Velká salva (finální výsledek) vs. menší (mezivýsledek). */
big?: boolean,
};
type UmaImage = { src: string, round: boolean };
const SIMPLE_COLORS = ['#16a34a', '#0ea5e9', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6', '#ef4444'];
const UMA_EMOJI = ['🐴', '🏇', '🥕', '🏆', '🌸', '✨', '⭐', '🍙', '🎀', '🥇'];
// Hlášky z fandomu Umamusume (Umapyoi Densetsu, Teio step, hachimī, Golshi, Oguri „okawari"...)
const UMA_BADGES = ['1着!', 'Umazing!', 'Umapyoi!', 'Teio step!', 'Hachimī~', 'Golshi approves', 'Okawari!', 'Pakapaka~', 'ウマい!'];
const UMA_BADGE_COLORS = ['#ff6fa3', '#2e9a3a', '#43b8f0', '#f2a91a'];
const MLP_EMOJI = ['🦄', '🌈', '✨', '⭐', '🧁', '🍎', '🦋', '💎', '🎈', '🎉', '🍰', '💜'];
// Hlášky a narážky z My Little Pony: Friendship is Magic
const MLP_BADGES = ['Friendship is Magic!', '20% cooler!', 'Sonic Rainboom!', 'Yay!', 'Party!', 'Yeehaw!', 'Everypony!', 'Awesome!', 'Darling!', 'Pinkie Promise'];
const MLP_BADGE_COLORS = ['#8e4fd1', '#f06aa6', '#1e98d3', '#f37033', '#e3b829', '#5b4fc9'];
/** Postavy Mane 6 (+ Spike) s „hláškou" po finálním výsledku — místo obrázků cutie mark emoji. */
const MLP_SPEAKERS = [
{ name: 'Twilight Sparkle', emoji: '⭐', color: '#8e4fd1', quote: 'Podle mého seznamu je to ta nejlogičtější volba!' },
{ name: 'Rainbow Dash', emoji: '🌈', color: '#1e98d3', quote: 'Tohle je o 20 % úžasnější než ostatní!' },
{ name: 'Pinkie Pie', emoji: '🎈', color: '#f06aa6', quote: 'Tohle volá po PÁRTY! S dortem! A s cupcaky!' },
{ name: 'Applejack', emoji: '🍎', color: '#f37033', quote: 'Poctivé jídlo pro poctivou práci. Jíhá!' },
{ name: 'Fluttershy', emoji: '🦋', color: '#e3b829', quote: 'Jé… teda, jestli to nevadí… yay.' },
{ name: 'Rarity', emoji: '💎', color: '#5b4fc9', quote: 'Naprosto božská volba, drahoušku!' },
{ name: 'Spike', emoji: '🐉', color: '#62bc4d', quote: 'Zapsáno do svitku a odesláno princezně!' },
];
const HG_EMOJI = ['🥀', '🏮', '⛩️', '🔥', '🕯️', '🌑', '🦋', '👁️', '🕸️', '🌸'];
// Hlášky z Jigoku Shōjo (Pekelná korespondence, „Ippen, shinde miru?", poslání do pekla...)
const HG_BADGES = ['いっぺん、死んでみる?', '地獄通信', '地獄流し', 'Ippen… shinde miru?', 'Pomsta vykonána', '0:00', 'Uvaž to dobře…', 'Smlouva uzavřena'];
const HG_BADGE_COLORS = ['#12070a', '#7a0c1f', '#2e0d24'];
/** Ai Enma a její služebníci s „hláškou" po finálním výsledku — místo obrázků emoji. */
const HG_SPEAKERS = [
{ name: 'Ai Enma', emoji: '🥀', color: '#c8102e', quote: 'Smlouva je uzavřena. Tenhle oběd tě bude provázet až na věčnost.' },
{ name: 'Ichimoku Ren', emoji: '👁️', color: '#6b5a8a', quote: 'Prohlédl jsem svým okem všechny podniky. Tenhle jediný obstál.' },
{ name: 'Hone Onna', emoji: '💀', color: '#b8862b', quote: 'Dobré až na kost, zlato. Mně z toho stejně nic nezůstane.' },
{ name: 'Wanyūdō', emoji: '🔥', color: '#d9582b', quote: 'Já jsem kolo. Kolo rozhodlo. Víc k tomu nemám.' },
{ name: 'Kikuri', emoji: '🎀', color: '#d4407a', quote: 'Hihi! Tohle bych do pekla poslala jako první!' },
{ name: 'Babička', emoji: '🕸️', color: '#8c7672', quote: 'Ai, oběd je na stole… jako každý den už čtyři sta let.' },
];
const KON_EMOJI = ['🎸', '🥁', '🎹', '🎵', '🎶', '🍰', '☕', '🍓', '🐢', '🎀', '🍪', '🫖'];
// Hlášky z K-ON! (Houkago Tea Time, Fuwa Fuwa Time, Gitah, Ton-chan, Mugi a její čaj...)
const KON_BADGES = ['放課後ティータイム', 'Fuwa Fuwa Time ♪', 'Tea time!', 'Gitah!', 'Ton-chan ♡', 'けいおん!', 'Nejdřív čaj, pak zkouška', "Don't say lazy", 'Azunyan~', 'Ui!'];
const KON_BADGE_COLORS = ['#d43f62', '#3d6fc4', '#e8a10f', '#8a6bc7', '#e46f9a', '#27315f'];
/** Členky Houkago Tea Time (+ Ui, Sawa-chan a Ton-chan) s „hláškou" po finálním výsledku. */
const KON_SPEAKERS = [
{ name: 'Yui Hirasawa', emoji: '🎸', color: '#d43f62', quote: 'Gitah říká, že tohle bude mňamka! Fuwa fuwa~' },
{ name: 'Mio Akiyama', emoji: '🎼', color: '#3d6fc4', quote: 'Dobře… ale hned po obědě se jde zkoušet! Opravdu!' },
{ name: 'Ritsu Tainaka', emoji: '🥁', color: '#e8a10f', quote: 'Prezidentka klubu rozhodla! Raz, dva, raz dva tři čtyři!' },
{ name: 'Tsumugi Kotobuki', emoji: '🫖', color: '#c9a227', quote: 'Čaj a dortík k tomu přinesu já. Těším se!' },
{ name: 'Azusa Nakano', emoji: '🐱', color: '#8a6bc7', quote: 'Zase jídlo místo zkoušky? …No dobře, ale jen chvilku!' },
{ name: 'Ui Hirasawa', emoji: '🍳', color: '#e46f9a', quote: 'Sestřičko, nezapomeň si vzít ubrousek!' },
{ name: 'Sawako Yamanaka', emoji: '👗', color: '#27315f', quote: 'Oběd? Výborně. Mezitím vám ušiju nové kostýmy!' },
{ name: 'Ton-chan', emoji: '🐢', color: '#3c9a6b', quote: '(spokojeně mává ploutvičkou)' },
];
const BOCCHI_EMOJI = ['🎸', '🥁', '🎤', '📦', '🥭', '🟨', '🟦', '✨', '🌿', '🫠', '⭐', '🔊'];
// Hlášky z Bocchi the Rock! (Kessoku Band, Kita-aura, krabice od manga, guitarhero, live house STARRY...)
const BOCCHI_BADGES = ['結束バンド', 'Kessoku Band!', 'Kitaaan ✨', 'guitarhero', 'A-ano…', 'Live at STARRY', 'Bocchi-chan!', 'ぼっち・ざ・ろっく!', 'Mango box', 'Rock!'];
const BOCCHI_BADGE_COLORS = ['#e0457b', '#e8b400', '#3a63c9', '#d93a30', '#1d1a22'];
/** Kessoku Band a jejich okolí s „hláškou" po finálním výsledku. */
const BOCCHI_SPEAKERS = [
{ name: 'Hitori Gotoh', emoji: '🎸', color: '#e0457b', quote: 'A-ano… t-tohle by šlo… teda, pokud to nikomu nevadí…' },
{ name: 'Nijika Ijichi', emoji: '🥁', color: '#e8b400', quote: 'Tak jo, kapelo, jdeme jíst! Bocchi, vylez z té krabice!' },
{ name: 'Ryo Yamada', emoji: '🌿', color: '#3a63c9', quote: 'Dobrá volba. Mimochodem… nepůjčíš mi na oběd?' },
{ name: 'Ikuyo Kita', emoji: '✨', color: '#d93a30', quote: 'Kitaaan! Tohle se mi děsně líbí, vyfotím to na Instagram!' },
{ name: 'Seika Ijichi', emoji: '🎛️', color: '#7a4fc0', quote: 'Najezte se pořádně. Večer v STARRY neodfláknete zkoušku.' },
{ name: 'Futari Gotoh', emoji: '🐕', color: '#f08fb4', quote: 'Ségra zase obědvá ve skříni! Jimihen, pojď se podívat!' },
];
/** Styly, jejichž konfety tvoří emoji a samolepky s hláškami (a po finále promluví postava s emoji). */
const EMOJI_CONFETTI: Partial<Record<WheelTheme, {
prefix: string, badges: string[], colors: string[], emoji: string[],
speakers: { name: string, emoji: string, color: string, quote: string }[],
banner: string,
}>> = {
mlp: { prefix: 'confetti-mlp', badges: MLP_BADGES, colors: MLP_BADGE_COLORS, emoji: MLP_EMOJI, speakers: MLP_SPEAKERS, banner: '🌈 Sonic Rainboom! 🌈' },
hellgirl: { prefix: 'confetti-hg', badges: HG_BADGES, colors: HG_BADGE_COLORS, emoji: HG_EMOJI, speakers: HG_SPEAKERS, banner: '🏮 地獄流し 🏮' },
kon: { prefix: 'confetti-kon', badges: KON_BADGES, colors: KON_BADGE_COLORS, emoji: KON_EMOJI, speakers: KON_SPEAKERS, banner: '🎸 放課後ティータイム 🍰' },
bocchi: { prefix: 'confetti-bocchi', badges: BOCCHI_BADGES, colors: BOCCHI_BADGE_COLORS, emoji: BOCCHI_EMOJI, speakers: BOCCHI_SPEAKERS, banner: '🎸 結束バンド 🎸' },
};
const DURATION_MS = 6400;
const rand = (min: number, max: number) => min + Math.random() * (max - min);
const pick = <T,>(list: T[]) => list[Math.floor(Math.random() * list.length)];
type Piece = { id: number, style: React.CSSProperties, content: React.ReactNode, className: string };
type Launch = 'cannon-left' | 'cannon-right' | 'burst' | 'rain';
const LAUNCHES: Launch[] = ['cannon-left', 'cannon-left', 'cannon-right', 'cannon-right', 'burst', 'rain'];
/**
* Výchozí pozice a dráha jednoho kousku: děla z dolních rohů střílí šikmo vzhůru,
* „výbuch" se rozletí ze středu a déšť padá opožděně shora. Kousek nejprve vyletí
* do vrcholu (--x1/--y1) a pak dopadne mimo obrazovku (--x2/--y2).
*/
function launchStyle(launch: Launch): React.CSSProperties {
let left: number, top: number, x1: number, y1: number, x2: number, y2: number, delay: number;
switch (launch) {
case 'cannon-left':
case 'cannon-right': {
const dir = launch === 'cannon-left' ? 1 : -1;
left = launch === 'cannon-left' ? rand(-3, 3) : rand(97, 103);
top = rand(88, 100);
x1 = dir * rand(12, 62);
y1 = -rand(45, 88);
x2 = x1 + dir * rand(6, 22);
y2 = rand(12, 25);
delay = rand(0, 450);
break;
}
case 'burst': {
const angle = rand(0, Math.PI * 2);
const radius = rand(18, 48);
left = 50;
top = 42;
x1 = Math.cos(angle) * radius;
y1 = Math.sin(angle) * radius * 0.8 - rand(5, 15);
x2 = x1 * 1.35;
y2 = rand(70, 85);
delay = rand(0, 250);
break;
}
default: {
left = rand(0, 100);
top = -12;
x1 = rand(-8, 8);
y1 = rand(35, 55);
x2 = x1 + rand(-10, 10);
y2 = rand(115, 125);
delay = rand(400, 1800);
}
}
const spin = rand(180, 720) * (Math.random() < 0.5 ? -1 : 1);
return {
left: `${left}vw`,
top: `${top}vh`,
animationName: launch === 'rain' ? 'confetti-rain' : 'confetti-throw',
animationDelay: `${delay}ms`,
animationDuration: `${rand(2600, 3900)}ms`,
['--x1' as any]: `${x1}vw`,
['--y1' as any]: `${y1}vh`,
['--x2' as any]: `${x2}vw`,
['--y2' as any]: `${y2}vh`,
['--r1' as any]: `${spin * 0.4}deg`,
['--r2' as any]: `${spin}deg`,
};
}
function buildPieces(theme: WheelTheme, big: boolean, images: UmaImage[]): Piece[] {
if (theme === 'simple') {
const count = big ? 170 : 80;
return Array.from({ length: count }, (_, id) => ({
id,
className: 'confetti-paper',
content: null,
style: {
...launchStyle(pick(LAUNCHES)),
width: `${rand(6, 11)}px`,
height: `${rand(10, 18)}px`,
background: pick(SIMPLE_COLORS),
borderRadius: Math.random() < 0.3 ? '50%' : '2px',
},
}));
}
const count = big ? 50 : 24;
const emojiConfetti = EMOJI_CONFETTI[theme];
if (emojiConfetti) {
const { prefix, badges, colors, emoji } = emojiConfetti;
return Array.from({ length: count }, (_, id) => {
const base: React.CSSProperties = {
...launchStyle(pick(LAUNCHES)),
['--r1' as any]: `${rand(-25, 25)}deg`,
['--r2' as any]: `${rand(-40, 40)}deg`,
};
if (Math.random() < 0.35) {
return { id, className: `${prefix} ${prefix}-badge`, content: pick(badges),
style: { ...base, background: pick(colors) } };
}
return { id, className: `${prefix} confetti-uma-emoji`, style: { ...base, fontSize: `${rand(28, 52)}px` }, content: pick(emoji) };
});
}
return Array.from({ length: count }, (_, id) => {
const roll = Math.random();
// Samolepky se tolik netočí, ať jsou čitelné
const base: React.CSSProperties = {
...launchStyle(pick(LAUNCHES)),
['--r1' as any]: `${rand(-25, 25)}deg`,
['--r2' as any]: `${rand(-40, 40)}deg`,
};
if (images.length && roll < 0.45) {
const image = pick(images);
return { id, className: `confetti-uma confetti-uma-img${image.round ? ' round' : ''}`, style: base,
content: <img src={image.src} alt="" loading="eager" /> };
}
if (roll < 0.7) {
return { id, className: 'confetti-uma confetti-uma-badge', content: pick(UMA_BADGES),
style: { ...base, background: pick(UMA_BADGE_COLORS) } };
}
return { id, className: 'confetti-uma confetti-uma-emoji', style: { ...base, fontSize: `${rand(28, 52)}px` }, content: pick(UMA_EMOJI) };
});
}
/** Salva konfet po výsledku kola štěstí, ve vzhledu podle zvoleného stylu. */
export default function WheelConfetti({ theme, burstKey, big = false }: Readonly<Props>) {
const [characters, setCharacters] = useState<UmaCharacter[]>([]);
const [active, setActive] = useState(false);
useEffect(() => {
if (theme === 'umamusume') loadUmaCharacters().then(setCharacters);
}, [theme]);
useEffect(() => {
if (!burstKey) return;
setActive(true);
const t = setTimeout(() => setActive(false), DURATION_MS + 900);
return () => clearTimeout(t);
}, [burstKey]);
const pieces = useMemo(
() => buildPieces(theme, big, characters.flatMap(c => c.images.map(src => ({ src, round: !!c.round })))),
// eslint-disable-next-line react-hooks/exhaustive-deps
[burstKey],
);
// Při finálním výsledku „promluví" náhodná postava svou hláškou
const speaker = useMemo(() => {
const withQuote = characters.filter(c => c.quote);
return theme === 'umamusume' && big && withQuote.length ? pick(withQuote) : undefined;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [burstKey]);
// Ostatní tematické styly: hlášku pronese náhodná postava (Mane 6, Ai Enma, Houkago Tea Time, Kessoku Band...)
const emojiConfetti = EMOJI_CONFETTI[theme];
const emojiSpeaker = useMemo(
() => big && emojiConfetti ? pick(emojiConfetti.speakers) : undefined,
// eslint-disable-next-line react-hooks/exhaustive-deps
[burstKey],
);
if (!active || window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return null;
return createPortal(
<div className={`wheel-confetti wheel-confetti-${theme}`} key={burstKey} aria-hidden>
{theme === 'umamusume' && big && <div className="confetti-uma-banner">🏆 1着 🏆</div>}
{emojiConfetti && big && <div className={`${emojiConfetti.prefix}-banner`}>{emojiConfetti.banner}</div>}
{pieces.map(p => <span key={p.id} className={p.className} style={p.style}>{p.content}</span>)}
{speaker && (
<div className="confetti-uma-voice">
<img src={pick(speaker.images)} alt="" className={speaker.round ? 'round' : undefined} />
<div className="confetti-uma-bubble">
<p>„{speaker.quote}"</p>
<small>— {speaker.name}</small>
</div>
</div>
)}
{emojiSpeaker && (
<div className={`confetti-uma-voice confetti-${theme}-voice`} style={{ ['--mlp-speaker' as any]: emojiSpeaker.color }}>
<span className="confetti-mlp-avatar">{emojiSpeaker.emoji}</span>
<div className="confetti-mlp-bubble">
<p>„{emojiSpeaker.quote}"</p>
<small>— {emojiSpeaker.name}</small>
</div>
</div>
)}
</div>,
document.body,
);
}
@@ -12,7 +12,7 @@ type Props = {
group: OrderGroup; group: OrderGroup;
}; };
/** Nabídka stavů pro ruční nastavení (odpovídá mapování v BoltOrderProgress). */ /** Nabídka stavů pro ruční nastavení (odpovídá mapování v OrderProgress). */
const STATE_OPTIONS: { key: string; label: string; order_state: string; courier_state?: string }[] = [ const STATE_OPTIONS: { key: string; label: string; order_state: string; courier_state?: string }[] = [
{ key: 'accepted', label: 'Přijato', order_state: 'accepted' }, { key: 'accepted', label: 'Přijato', order_state: 'accepted' },
{ key: 'preparing', label: 'Příprava', order_state: 'preparing' }, { key: 'preparing', label: 'Příprava', order_state: 'preparing' },
@@ -30,7 +30,7 @@ export default function BoltSimulationModal({ isOpen, onClose, group }: Readonly
const [info, setInfo] = useState<string | null>(null); const [info, setInfo] = useState<string | null>(null);
const [manualKey, setManualKey] = useState<string>('preparing'); const [manualKey, setManualKey] = useState<string>('preparing');
const running = !!group.boltTrackingToken; const running = !!group.trackingCode;
/** Obecný runner — spustí akci, ošetří chybu a krátce zobrazí výsledek. */ /** Obecný runner — spustí akci, ošetří chybu a krátce zobrazí výsledek. */
const run = async (action: () => Promise<{ error?: unknown }>, okMsg: string) => { const run = async (action: () => Promise<{ error?: unknown }>, okMsg: string) => {
@@ -113,7 +113,7 @@ export default function BoltSimulationModal({ isOpen, onClose, group }: Readonly
<p className="mb-2"> <p className="mb-2">
Stav simulace:{' '} Stav simulace:{' '}
{running {running
? <Badge bg="success">běží{group.boltOrderState ? ` — ${group.boltOrderState}` : ''}</Badge> ? <Badge bg="success">běží{group.trackingOrderState ? ` — ${group.trackingOrderState}` : ''}</Badge>
: <Badge bg="secondary">neběží</Badge>} : <Badge bg="secondary">neběží</Badge>}
</p> </p>
@@ -0,0 +1,142 @@
.butterfly-mystats {
margin-bottom: 16px;
padding: 12px;
border-radius: 12px;
background: linear-gradient(135deg, rgba(255, 203, 61, 0.16), rgba(224, 162, 0, 0.08));
border: 1px solid rgba(224, 162, 0, 0.35);
}
.butterfly-mystats-title {
font-weight: 700;
color: #6b5b2e;
margin-bottom: 8px;
}
.butterfly-mystats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
gap: 8px;
> div {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.v {
font-size: 1.3rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
line-height: 1.1;
&.gold {
color: #e0a200;
}
}
.l {
font-size: 0.72rem;
color: #6c757d;
}
}
.butterfly-leaderboard-heading {
font-weight: 700;
margin: 12px 0 8px;
}
.butterfly-achievements {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 6px;
}
.butterfly-achievement {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.04);
font-size: 0.78rem;
opacity: 0.55;
filter: grayscale(1);
&.unlocked {
opacity: 1;
filter: none;
background: rgba(255, 203, 61, 0.18);
}
.ach-icon { flex: 0 0 auto; }
.ach-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.butterfly-leaderboard {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.butterfly-leaderboard-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 10px;
background: rgba(0, 0, 0, 0.03);
&.me {
background: rgba(255, 203, 61, 0.22);
border: 1px solid rgba(224, 162, 0, 0.5);
font-weight: 700;
}
}
.butterfly-leaderboard-rank {
flex: 0 0 auto;
width: 28px;
text-align: center;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.butterfly-leaderboard-name {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.butterfly-leaderboard-title {
font-size: 0.72rem;
font-weight: 500;
color: #6b5b2e;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.butterfly-leaderboard-stats {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
font-variant-numeric: tabular-nums;
}
.butterfly-leaderboard-golden {
color: #e0a200;
font-weight: 700;
}
@@ -0,0 +1,111 @@
import { useEffect, useState } from "react";
import { Modal } from "react-bootstrap";
import {
ButterflyLeaderboardEntry, ButterflyStats, ButterflyAchievement,
getButterflyLeaderboard, getButterflyAchievements,
} from "../../../../types";
import { useAuth } from "../../context/auth";
import "./ButterflyLeaderboardModal.scss";
type Props = {
isOpen: boolean;
onClose: () => void;
myStats?: ButterflyStats;
};
const MEDALS = ['🥇', '🥈', '🥉'];
/** Modální dialog s osobními statistikami, achievementy a týmovým žebříčkem. */
export default function ButterflyLeaderboardModal({ isOpen, onClose, myStats }: Readonly<Props>) {
const auth = useAuth();
const [entries, setEntries] = useState<ButterflyLeaderboardEntry[]>();
const [achievements, setAchievements] = useState<ButterflyAchievement[]>();
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!isOpen) return;
let cancelled = false;
setLoading(true);
Promise.all([
getButterflyLeaderboard({ query: { limit: 20 } }).then(r => r.data ?? []).catch(() => []),
getButterflyAchievements().then(r => r.data ?? []).catch(() => []),
]).then(([lb, ach]) => {
if (cancelled) return;
setEntries(lb);
setAchievements(ach);
}).finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [isOpen]);
return (
<Modal show={isOpen} onHide={onClose} centered>
<Modal.Header closeButton>
<Modal.Title>🦋 Chytání motýlků</Modal.Title>
</Modal.Header>
<Modal.Body>
{myStats && (
<div className="butterfly-mystats">
<div className="butterfly-mystats-title">
{myStats.level}. {myStats.title}
</div>
<div className="butterfly-mystats-grid">
<div><span className="v">{myStats.caught}</span><span className="l">🦋 chyceno</span></div>
<div><span className="v gold">{myStats.goldenCaught}</span><span className="l">✨ zlatých</span></div>
<div><span className="v">{myStats.coins}</span><span className="l">🪙 mincí</span></div>
<div><span className="v">{myStats.waspsKilled}</span><span className="l">🪰 vos zabito</span></div>
<div><span className="v">{myStats.birdsScared}</span><span className="l">🦅 vyplašeno</span></div>
<div><span className="v">{myStats.thievesDefeated}</span><span className="l">🦹 zlodějů</span></div>
<div><span className="v">{myStats.caterpillarsDefeated}</span><span className="l">🐛 housenek</span></div>
</div>
</div>
)}
{achievements && achievements.length > 0 && (
<>
<h6 className="butterfly-leaderboard-heading">Odznaky</h6>
<div className="butterfly-achievements">
{achievements.map(a => (
<div
key={a.id}
className={a.unlocked ? 'butterfly-achievement unlocked' : 'butterfly-achievement'}
title={a.description}
>
<span className="ach-icon">{a.unlocked ? '🏅' : '🔒'}</span>
<span className="ach-title">{a.title}</span>
</div>
))}
</div>
</>
)}
<h6 className="butterfly-leaderboard-heading">Žebříček týmu</h6>
{loading && <p className="text-center text-muted mb-0">Načítám žebříček…</p>}
{!loading && entries && entries.length === 0 && (
<p className="text-center text-muted mb-0">Zatím nikdo nic nechytil. Buď první!</p>
)}
{!loading && entries && entries.length > 0 && (
<ol className="butterfly-leaderboard">
{entries.map((e, i) => (
<li
key={e.login}
className={e.login === auth?.login ? 'butterfly-leaderboard-row me' : 'butterfly-leaderboard-row'}
>
<span className="butterfly-leaderboard-rank">{MEDALS[i] ?? `${i + 1}.`}</span>
<span className="butterfly-leaderboard-name" title={e.title}>
{e.login}
<span className="butterfly-leaderboard-title">{e.level}. {e.title}</span>
</span>
<span className="butterfly-leaderboard-stats">
<span className="butterfly-leaderboard-caught">{e.caught} 🦋</span>
{e.goldenCaught > 0 && (
<span className="butterfly-leaderboard-golden">{e.goldenCaught} ✨</span>
)}
</span>
</li>
))}
</ol>
)}
</Modal.Body>
</Modal>
);
}
@@ -0,0 +1,106 @@
.shop-balance {
font-size: 0.9rem;
font-weight: 700;
color: #e0a200;
margin-left: 8px;
}
.shop-heading {
font-weight: 700;
margin: 14px 0 8px;
}
.butterfly-shop-daily {
padding: 10px 12px;
border-radius: 12px;
background: rgba(28, 126, 214, 0.12);
border: 1px solid rgba(28, 126, 214, 0.35);
margin-bottom: 12px;
.daily-title {
font-weight: 700;
font-size: 0.9rem;
margin-bottom: 6px;
}
.daily-bar {
height: 8px;
background: rgba(0, 0, 0, 0.1);
border-radius: 999px;
overflow: hidden;
}
.daily-bar-inner {
height: 100%;
background: linear-gradient(90deg, #4dabf7, #1c7ed6);
border-radius: 999px;
transition: width 0.3s ease;
}
.daily-status {
margin-top: 5px;
font-size: 0.8rem;
color: #495057;
font-variant-numeric: tabular-nums;
}
}
.butterfly-shop-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.butterfly-shop-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 10px;
background: #fff;
cursor: pointer;
text-align: left;
transition: background 0.12s ease, transform 0.08s ease;
&:hover:not(:disabled) {
background: #f1f3f5;
}
&:active:not(:disabled) {
transform: scale(0.99);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.shop-item-label {
display: flex;
flex-direction: column;
font-weight: 600;
font-size: 0.9rem;
}
.shop-item-lvl {
display: inline;
color: #1c7ed6;
font-weight: 700;
}
.shop-item-desc {
font-weight: 400;
font-size: 0.74rem;
color: #868e96;
}
.shop-item-cost {
flex: 0 0 auto;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: #e0a200;
white-space: nowrap;
}
}
@@ -0,0 +1,113 @@
import { Modal } from "react-bootstrap";
import { ButterflyStats } from "../../../../types";
import "./ButterflyShopModal.scss";
type UpgradeId = 'net' | 'scarecrow' | 'reinforced';
type Props = {
isOpen: boolean;
onClose: () => void;
stats?: ButterflyStats;
onBuyPremium: () => void;
onWaspSpray: () => void;
onRepellent: () => void;
onBuyInsurance: () => void;
onBuyUpgrade: (item: UpgradeId) => void;
};
// Zrcadlí serverové hodnoty (server/src/butterflies.ts)
const PREMIUM_BUY_COST = 40;
const WASP_SPRAY_COST = 25;
const REPELLENT_COST = 70;
const INSURANCE_COST = 50;
const UPGRADE_BASE: Record<UpgradeId, number> = { net: 100, scarecrow: 120, reinforced: 150 };
const UPGRADE_MAX: Record<UpgradeId, number> = { net: 5, scarecrow: 4, reinforced: 3 };
const UPGRADE_META: Record<UpgradeId, { icon: string; title: string; desc: string }> = {
net: { icon: '🥅', title: 'Větší síťka', desc: 'Zvětší dosah chytání' },
scarecrow: { icon: '🐦‍⬛', title: 'Strašák', desc: 'Ptáci přibývají pomaleji' },
reinforced: { icon: '🧵', title: 'Zpevněná síťka', desc: 'Rychlejší samo-zašití' },
};
function upgradeCost(item: UpgradeId, level: number): number {
return UPGRADE_BASE[item] * Math.pow(2, level);
}
/** Obchod: spotřební pomůcky a trvalá vylepšení za mince. */
export default function ButterflyShopModal({
isOpen, onClose, stats, onBuyPremium, onWaspSpray, onRepellent, onBuyInsurance, onBuyUpgrade,
}: Readonly<Props>) {
const coins = stats?.coins ?? 0;
const insuranceActive = !!stats && stats.insuranceUntil > Date.now();
const consumable = (label: string, cost: number, onBuy: () => void, extra?: string) => (
<button
type="button"
className="butterfly-shop-item"
onClick={onBuy}
disabled={coins < cost}
>
<span className="shop-item-label">{label}{extra ? ` ${extra}` : ''}</span>
<span className="shop-item-cost">{cost} 🪙</span>
</button>
);
return (
<Modal show={isOpen} onHide={onClose} centered>
<Modal.Header closeButton>
<Modal.Title>🛒 Obchod <span className="shop-balance">{coins} 🪙</span></Modal.Title>
</Modal.Header>
<Modal.Body>
{stats?.daily && (
<div className="butterfly-shop-daily">
<div className="daily-title">🎯 Denní úkol: {stats.daily.title}</div>
<div className="daily-bar">
<div
className="daily-bar-inner"
style={{ width: `${Math.min(100, Math.round((stats.daily.progress / stats.daily.target) * 100))}%` }}
/>
</div>
<div className="daily-status">
{stats.daily.done
? `✅ Splněno (+${stats.daily.reward} 🪙)`
: `${stats.daily.progress}/${stats.daily.target} · odměna ${stats.daily.reward} 🪙`}
</div>
</div>
)}
<h6 className="shop-heading">Pomůcky</h6>
<div className="butterfly-shop-list">
{consumable('🪄 Prémiová síťka (60 s)', PREMIUM_BUY_COST, onBuyPremium)}
{consumable('💨 Vosí sprej (vyhubí vosy)', WASP_SPRAY_COST, onWaspSpray)}
{consumable('🦅🚫 Plašič ptáků', REPELLENT_COST, onRepellent)}
{consumable('🛡️ Pojistka proti zloději', INSURANCE_COST, onBuyInsurance, insuranceActive ? '(aktivní)' : '')}
</div>
<h6 className="shop-heading">Trvalá vylepšení</h6>
<div className="butterfly-shop-list">
{(Object.keys(UPGRADE_META) as UpgradeId[]).map(item => {
const level = stats?.upgrades?.[item] ?? 0;
const max = UPGRADE_MAX[item];
const maxed = level >= max;
const cost = upgradeCost(item, level);
const meta = UPGRADE_META[item];
return (
<button
key={item}
type="button"
className="butterfly-shop-item upgrade"
onClick={() => onBuyUpgrade(item)}
disabled={maxed || coins < cost}
>
<span className="shop-item-label">
{meta.icon} {meta.title} <span className="shop-item-lvl">{level}/{max}</span>
<span className="shop-item-desc">{meta.desc}</span>
</span>
<span className="shop-item-cost">{maxed ? 'MAX' : `${cost} 🪙`}</span>
</button>
);
})}
</div>
</Modal.Body>
</Modal>
);
}
@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { Modal, Button, Alert } from "react-bootstrap"; import { Modal, Button, Form, Alert } from "react-bootstrap";
import { clearMockData, DayIndex } from "../../../../types"; import { clearMockData, DayIndex } from "../../../../types";
type Props = { type Props = {
@@ -10,20 +10,28 @@ type Props = {
const DAY_NAMES = ['Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek']; const DAY_NAMES = ['Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek'];
/** Hodnota v selectu dne pro smazání dat celého týdne. */
const WHOLE_WEEK = 'week';
/** Modální dialog pro smazání mock dat (pouze DEV). */ /** Modální dialog pro smazání mock dat (pouze DEV). */
export default function ClearMockDataModal({ isOpen, onClose, currentDayIndex }: Readonly<Props>) { export default function ClearMockDataModal({ isOpen, onClose, currentDayIndex }: Readonly<Props>) {
const [daySelection, setDaySelection] = useState<string>(currentDayIndex !== undefined ? String(currentDayIndex) : '');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const wholeWeek = daySelection === WHOLE_WEEK;
const handleClear = async () => { const handleClear = async () => {
setError(null); setError(null);
setLoading(true); setLoading(true);
try { try {
const body: any = {}; const body: any = {};
if (currentDayIndex !== undefined) { if (wholeWeek) {
body.dayIndex = currentDayIndex as DayIndex; body.wholeWeek = true;
} else if (daySelection !== '') {
body.dayIndex = parseInt(daySelection, 10) as DayIndex;
} }
const response = await clearMockData({ body }); const response = await clearMockData({ body });
@@ -49,12 +57,20 @@ export default function ClearMockDataModal({ isOpen, onClose, currentDayIndex }:
onClose(); onClose();
}; };
const dayName = currentDayIndex !== undefined ? DAY_NAMES[currentDayIndex] : 'aktuální den'; // Popis rozsahu mazání pro potvrzovací otázku
let scopeLabel: string;
if (wholeWeek) {
scopeLabel = 'celý týden (pondělí až pátek)';
} else if (daySelection !== '') {
scopeLabel = DAY_NAMES[parseInt(daySelection, 10)];
} else {
scopeLabel = 'aktuální den';
}
return ( return (
<Modal show={isOpen} onHide={handleClose}> <Modal show={isOpen} onHide={handleClose}>
<Modal.Header closeButton> <Modal.Header closeButton>
<Modal.Title><h2>Smazat data</h2></Modal.Title> <Modal.Title><h2>Smazat mock data</h2></Modal.Title>
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
{success ? ( {success ? (
@@ -73,8 +89,22 @@ export default function ClearMockDataModal({ isOpen, onClose, currentDayIndex }:
</Alert> </Alert>
)} )}
<Form.Group className="mb-3">
<Form.Label>Den</Form.Label>
<Form.Select
value={daySelection}
onChange={e => setDaySelection(e.target.value)}
>
<option value="">Aktuální den</option>
{DAY_NAMES.map((name, index) => (
<option key={index} value={index}>{name}</option>
))}
<option value={WHOLE_WEEK}>Celý týden</option>
</Form.Select>
</Form.Group>
<p> <p>
Opravdu chcete smazat všechny volby stravování pro <strong>{dayName}</strong>? Opravdu chcete smazat všechny volby stravování pro <strong>{scopeLabel}</strong>?
</p> </p>
<p className="text-muted"> <p className="text-muted">
Tato akce je nevratná. Tato akce je nevratná.
@@ -10,13 +10,18 @@ type Props = {
const DAY_NAMES = ['Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek']; const DAY_NAMES = ['Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek'];
/** Hodnota v selectu dne pro generování napříč celým týdnem. */
const WHOLE_WEEK = 'week';
/** Modální dialog pro generování mock dat (pouze DEV). */ /** Modální dialog pro generování mock dat (pouze DEV). */
export default function GenerateMockDataModal({ isOpen, onClose, currentDayIndex }: Readonly<Props>) { export default function GenerateMockDataModal({ isOpen, onClose, currentDayIndex }: Readonly<Props>) {
const [dayIndex, setDayIndex] = useState<number | undefined>(currentDayIndex); const [daySelection, setDaySelection] = useState<string>(currentDayIndex !== undefined ? String(currentDayIndex) : '');
const [count, setCount] = useState<string>(''); const [count, setCount] = useState<string>('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState<string | null>(null);
const wholeWeek = daySelection === WHOLE_WEEK;
const handleGenerate = async () => { const handleGenerate = async () => {
setError(null); setError(null);
@@ -24,8 +29,10 @@ export default function GenerateMockDataModal({ isOpen, onClose, currentDayIndex
try { try {
const body: any = {}; const body: any = {};
if (dayIndex !== undefined) { if (wholeWeek) {
body.dayIndex = dayIndex as DayIndex; body.wholeWeek = true;
} else if (daySelection !== '') {
body.dayIndex = parseInt(daySelection, 10) as DayIndex;
} }
if (count && count.trim() !== '') { if (count && count.trim() !== '') {
const countNum = parseInt(count, 10); const countNum = parseInt(count, 10);
@@ -41,10 +48,13 @@ export default function GenerateMockDataModal({ isOpen, onClose, currentDayIndex
if (response.error) { if (response.error) {
setError((response.error as any).error || 'Nastala chyba při generování dat'); setError((response.error as any).error || 'Nastala chyba při generování dat');
} else { } else {
setSuccess(true); const total = (response.data as any)?.count;
setSuccess(wholeWeek
? `Mock data byla vygenerována pro celý týden (celkem ${total} záznamů).`
: 'Mock data byla úspěšně vygenerována!');
setTimeout(() => { setTimeout(() => {
onClose(); onClose();
setSuccess(false); setSuccess(null);
setCount(''); setCount('');
}, 1500); }, 1500);
} }
@@ -57,7 +67,7 @@ export default function GenerateMockDataModal({ isOpen, onClose, currentDayIndex
const handleClose = () => { const handleClose = () => {
setError(null); setError(null);
setSuccess(false); setSuccess(null);
setCount(''); setCount('');
onClose(); onClose();
}; };
@@ -70,7 +80,7 @@ export default function GenerateMockDataModal({ isOpen, onClose, currentDayIndex
<Modal.Body> <Modal.Body>
{success ? ( {success ? (
<Alert variant="success"> <Alert variant="success">
Mock data byla úspěšně vygenerována! {success}
</Alert> </Alert>
) : ( ) : (
<> <>
@@ -87,13 +97,14 @@ export default function GenerateMockDataModal({ isOpen, onClose, currentDayIndex
<Form.Group className="mb-3"> <Form.Group className="mb-3">
<Form.Label>Den</Form.Label> <Form.Label>Den</Form.Label>
<Form.Select <Form.Select
value={dayIndex ?? ''} value={daySelection}
onChange={e => setDayIndex(e.target.value === '' ? undefined : parseInt(e.target.value, 10))} onChange={e => setDaySelection(e.target.value)}
> >
<option value="">Aktuální den</option> <option value="">Aktuální den</option>
{DAY_NAMES.map((name, index) => ( {DAY_NAMES.map((name, index) => (
<option key={index} value={index}>{name}</option> <option key={index} value={index}>{name}</option>
))} ))}
<option value={WHOLE_WEEK}>Celý týden</option>
</Form.Select> </Form.Select>
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Pokud není vybráno, použije se aktuální den. Pokud není vybráno, použije se aktuální den.
@@ -112,7 +123,9 @@ export default function GenerateMockDataModal({ isOpen, onClose, currentDayIndex
onKeyDown={e => e.stopPropagation()} onKeyDown={e => e.stopPropagation()}
/> />
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Pokud není zadáno, vybere se náhodný počet 5-20. {wholeWeek
? 'Zadaný počet se vygeneruje pro každý den týdne. Pokud není zadáno, vybere se pro každý den náhodný počet 5-20.'
: 'Pokud není zadáno, vybere se náhodný počet 5-20.'}
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
</> </>
@@ -0,0 +1,175 @@
import { useState } from "react";
import { Modal, Button, Form, Alert } from "react-bootstrap";
import { generateMockOrders } from "../../../../types";
type Props = {
isOpen: boolean;
onClose: () => void;
/** Příznak, zda je pro dnešní den založena alespoň jedna skupina objednávky. */
hasGroups: boolean;
/** Příznak, zda je nadefinován alespoň jeden obchod (bez něj nelze generovat). */
hasStores: boolean;
};
const DEFAULT_COUNT = '3';
const DEFAULT_MIN_AMOUNT = '100';
const DEFAULT_MAX_AMOUNT = '1000';
/** Vybere český tvar slova podle počtu (1 osoba / 2 osoby / 5 osob). */
function plural(count: number, one: string, few: string, many: string): string {
if (count === 1) return one;
if (count >= 2 && count <= 4) return few;
return many;
}
/** Modální dialog pro generování mock dat sekce Objednání (pouze DEV). */
export default function GenerateMockOrdersModal({ isOpen, onClose, hasGroups, hasStores }: Readonly<Props>) {
const [count, setCount] = useState<string>(DEFAULT_COUNT);
const [minAmount, setMinAmount] = useState<string>(DEFAULT_MIN_AMOUNT);
const [maxAmount, setMaxAmount] = useState<string>(DEFAULT_MAX_AMOUNT);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const handleGenerate = async () => {
setError(null);
const countNum = parseInt(count, 10);
if (isNaN(countNum) || countNum < 1 || countNum > 50) {
setError('Počet osob musí být číslo mezi 1 a 50');
return;
}
const minNum = parseInt(minAmount, 10);
const maxNum = parseInt(maxAmount, 10);
if (isNaN(minNum) || isNaN(maxNum) || minNum < 0 || maxNum < minNum) {
setError('Zadejte platný rozsah částek (od ≤ do)');
return;
}
setLoading(true);
try {
const response = await generateMockOrders({
body: { count: countNum, minAmount: minNum, maxAmount: maxNum },
});
if (response.error) {
setError((response.error as any).error || 'Nastala chyba při generování dat');
} else {
const groups = response.data?.groups ?? [];
const total = response.data?.count ?? 0;
const created = groups.find(g => g.created);
setSuccess(
`Doplněno: ${total} ${plural(total, 'osoba', 'osoby', 'osob')}`
+ ` / ${groups.length} ${plural(groups.length, 'skupina', 'skupiny', 'skupin')}`
+ (created ? ` (skupina „${created.name}“ byla nově založena).` : '.')
);
setTimeout(() => {
onClose();
setSuccess(null);
}, 2000);
}
} catch (e: any) {
setError(e.message || 'Nastala chyba při generování dat');
} finally {
setLoading(false);
}
};
const handleClose = () => {
setError(null);
setSuccess(null);
onClose();
};
return (
<Modal show={isOpen} onHide={handleClose}>
<Modal.Header closeButton>
<Modal.Title><h2>Generovat mock objednávky</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{success ? (
<Alert variant="success">{success}</Alert>
) : (
<>
<Alert variant="warning">
<strong>DEV režim</strong> - Tato funkce je dostupná pouze ve vývojovém prostředí.
Data se generují vždy jen pro aktuální den.
</Alert>
{error && (
<Alert variant="danger" onClose={() => setError(null)} dismissible>
{error}
</Alert>
)}
{hasStores ? (
<>
<Form.Group className="mb-3">
<Form.Label>Počet osob</Form.Label>
<Form.Control
type="number"
value={count}
onChange={e => setCount(e.target.value)}
min={1}
max={50}
onKeyDown={e => e.stopPropagation()}
/>
<Form.Text className="text-muted">
{hasGroups
? 'Zadaný počet osob se doplní do každé existující skupiny.'
: 'Není založena žádná skupina — jedna se založí z náhodně vybraného obchodu a doplní se do ní zadaný počet osob.'}
</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Rozsah částek (Kč)</Form.Label>
<div className="d-flex gap-2 align-items-center">
<Form.Control
type="number"
value={minAmount}
onChange={e => setMinAmount(e.target.value)}
min={0}
onKeyDown={e => e.stopPropagation()}
/>
<span>—</span>
<Form.Control
type="number"
value={maxAmount}
onChange={e => setMaxAmount(e.target.value)}
min={0}
onKeyDown={e => e.stopPropagation()}
/>
</div>
<Form.Text className="text-muted">
Každé osobě se nastaví náhodná částka z tohoto rozmezí a do poznámky náhodné jídlo.
</Form.Text>
</Form.Group>
</>
) : (
<p className="text-muted mb-0">
Není nadefinován žádný obchod — mock objednávky nelze vygenerovat.
Nejprve přidejte obchod ve správě obchodů.
</p>
)}
</>
)}
</Modal.Body>
<Modal.Footer>
{!success && (
<>
<Button variant="secondary" onClick={handleClose} disabled={loading}>
Storno
</Button>
<Button variant="primary" onClick={handleGenerate} disabled={loading || !hasStores}>
{loading ? 'Generuji...' : 'Generovat'}
</Button>
</>
)}
{success && (
<Button variant="secondary" onClick={handleClose}>
Zavřít
</Button>
)}
</Modal.Footer>
</Modal>
);
}
@@ -49,9 +49,10 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
if (!isOpen) return; if (!isOpen) return;
const entries: DinerEntry[] = Object.entries(locationChoices).map(([login, choice]) => { const entries: DinerEntry[] = Object.entries(locationChoices).map(([login, choice]) => {
const selectedFoods = choice.selectedFoods ?? []; const selectedFoods = choice.selectedFoods ?? [];
let baseAmount = 0; // Cena, kterou si strávník zadal sám, má přednost před cenami z menu
let baseAmount = choice.price ?? 0;
let baseAmountParseFailed = false; let baseAmountParseFailed = false;
if (menu) { if (menu && choice.price == null) {
for (const idx of selectedFoods) { for (const idx of selectedFoods) {
const priceKc = parsePriceCzk(menu.food?.[idx]?.price); const priceKc = parsePriceCzk(menu.food?.[idx]?.price);
if (priceKc === null) { if (priceKc === null) {
@@ -168,7 +169,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
<> <>
<p>Zaplatili jste za skupinu v restauraci. Nastavte příplatky a společné poplatky, poté vygenerujte QR kódy pro ostatní.</p> <p>Zaplatili jste za skupinu v restauraci. Nastavte příplatky a společné poplatky, poté vygenerujte QR kódy pro ostatní.</p>
{!hasMenu && ( {!hasMenu && !diners.some(d => d.baseAmount > 0) && (
<Alert variant="info"> <Alert variant="info">
Pro tuto skupinu nejsou k dispozici ceny jídel — vyplňte příplatky ručně. Pro tuto skupinu nejsou k dispozici ceny jídel — vyplňte příplatky ručně.
</Alert> </Alert>
@@ -219,7 +220,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
<td> <td>
<small> <small>
{foodNames || <span className="text-muted">—</span>} {foodNames || <span className="text-muted">—</span>}
{hasMenu && d.baseAmount > 0 && <span className="text-muted"> ({d.baseAmount / 100} Kč)</span>} {d.baseAmount > 0 && <span className="text-muted"> ({d.baseAmount / 100} Kč)</span>}
{d.baseAmountParseFailed && <span className="text-warning"> ⚠</span>} {d.baseAmountParseFailed && <span className="text-warning"> ⚠</span>}
</small> </small>
</td> </td>
@@ -1,6 +1,6 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Modal, Button, Form, Table, Alert } from "react-bootstrap"; import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
import { generateQr, OrderGroup, OrderGroupMember, QrRecipient } from "../../../../types"; import { generateQr, GroupState, OrderGroup, OrderGroupMember, QrRecipient } from "../../../../types";
import { sanitizeQrMessage } from "../../Utils"; import { sanitizeQrMessage } from "../../Utils";
import { computeFeeShare, computeMemberTotal, countActiveMembers, isActiveMember } from "../../utils/groupFees"; import { computeFeeShare, computeMemberTotal, countActiveMembers, isActiveMember } from "../../utils/groupFees";
@@ -102,6 +102,8 @@ export default function PayForGroupModal({ isOpen, onClose, onSuccess, group, pa
}; };
const hasFees = totalFees > 0; const hasFees = totalFees > 0;
// Generování QR je poslední krok objednávky — server skupinu zároveň překlopí do stavu Doručeno
const willBeMarkedDelivered = group.state !== GroupState.DELIVERED;
return ( return (
<Modal show={isOpen} onHide={onClose} size="lg"> <Modal show={isOpen} onHide={onClose} size="lg">
@@ -115,6 +117,12 @@ export default function PayForGroupModal({ isOpen, onClose, onSuccess, group, pa
</Alert> </Alert>
) : ( ) : (
<> <>
{willBeMarkedDelivered && (
<Alert variant="info">
Objednávka není ve stavu <strong>Doručeno</strong> — vygenerováním QR kódů se do tohoto stavu automaticky přepne.
</Alert>
)}
<p>Zaplatili jste za skupinu. Vyberte, komu vygenerovat QR kód k úhradě.</p> <p>Zaplatili jste za skupinu. Vyberte, komu vygenerovat QR kód k úhradě.</p>
{error && ( {error && (
@@ -0,0 +1,74 @@
import { useEffect, useState } from "react";
import { Modal, Button, Form } from "react-bootstrap";
type Props = {
isOpen: boolean,
onClose: () => void,
/** Uložená cena v haléřích */
price?: number,
/** Cena v haléřích spočtená z vybraných jídel v menu, nabídne se, pokud cena ještě není uložená */
suggestedPrice?: number,
/** Cena v haléřích, undefined cenu odstraní */
onSave: (price?: number) => void,
}
const toCrownsText = (amount?: number) => amount ? String(amount / 100).replace('.', ',') : '';
/** Modální dialog pro zadání ceny oběda, která se promítne do přehledu stravování. */
const PriceModal = ({ isOpen, onClose, price, suggestedPrice, onSave }: Readonly<Props>) => {
const [value, setValue] = useState('');
useEffect(() => {
if (isOpen) {
setValue(toCrownsText(price ?? suggestedPrice));
}
}, [isOpen, price, suggestedPrice]);
const parsed = value.trim().length ? Number.parseFloat(value.replace(',', '.')) : undefined;
const invalid = parsed != null && (Number.isNaN(parsed) || parsed < 0);
const save = () => {
if (invalid) return;
onSave(parsed == null ? undefined : Math.round(parsed * 100));
}
return <Modal show={isOpen} onHide={onClose}>
<Modal.Header closeButton>
<Modal.Title>Cena oběda</Modal.Title>
</Modal.Header>
<Modal.Body>
<p className="text-muted small">Cena se promítne do vašeho měsíčního přehledu stravování (export).</p>
<div className="d-flex align-items-center gap-2">
<Form.Control
autoFocus
type="text"
inputMode="decimal"
placeholder="např. 145"
value={value}
isInvalid={invalid}
onChange={e => setValue(e.target.value.replace(/[^0-9.,]/g, ''))}
onKeyDown={event => {
if (event.key === 'Enter') {
save();
}
event.stopPropagation();
}}
/>
<span>Kč</span>
</div>
</Modal.Body>
<Modal.Footer>
{price != null && <Button variant="outline-danger" className="me-auto" onClick={() => onSave(undefined)}>
Odstranit cenu
</Button>}
<Button variant="secondary" onClick={() => onClose()}>
Storno
</Button>
<Button variant="primary" onClick={() => save()} disabled={invalid}>
Uložit
</Button>
</Modal.Footer>
</Modal>
}
export default PriceModal;
+116 -45
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Modal, Button, Form } from "react-bootstrap" import { Modal, Button, Form, Row, Col } from "react-bootstrap"
import { useSettings, ThemePreference } from "../../context/settings"; import { useSettings, ThemePreference, StyleTheme, STYLE_THEMES } from "../../context/settings";
import { NotificationSettings, UdalostEnum, getNotificationSettings, updateNotificationSettings } from "../../../../types"; import { NotificationSettings, UdalostEnum, getNotificationSettings, updateNotificationSettings } from "../../../../types";
import { useAuth } from "../../context/auth"; import { useAuth } from "../../context/auth";
import { subscribeToPush, unsubscribeFromPush } from "../../hooks/usePushReminder"; import { subscribeToPush, unsubscribeFromPush } from "../../hooks/usePushReminder";
@@ -19,8 +19,12 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
const nameRef = useRef<HTMLInputElement>(null); const nameRef = useRef<HTMLInputElement>(null);
const hideSoupsRef = useRef<HTMLInputElement>(null); const hideSoupsRef = useRef<HTMLInputElement>(null);
const themeRef = useRef<HTMLSelectElement>(null); const themeRef = useRef<HTMLSelectElement>(null);
const styleThemeRef = useRef<HTMLSelectElement>(null);
const showButterfliesRef = useRef<HTMLInputElement>(null);
const autoOrderChoiceRef = useRef<HTMLInputElement>(null);
const reminderTimeRef = useRef<HTMLInputElement>(null); const reminderTimeRef = useRef<HTMLInputElement>(null);
const boltDeliveredRef = useRef<HTMLInputElement>(null);
const ntfyTopicRef = useRef<HTMLInputElement>(null); const ntfyTopicRef = useRef<HTMLInputElement>(null);
const discordWebhookRef = useRef<HTMLInputElement>(null); const discordWebhookRef = useRef<HTMLInputElement>(null);
const teamsWebhookRef = useRef<HTMLInputElement>(null); const teamsWebhookRef = useRef<HTMLInputElement>(null);
@@ -47,26 +51,42 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
const handleSave = async () => { const handleSave = async () => {
const newReminderTime = reminderTimeRef.current?.value || undefined; const newReminderTime = reminderTimeRef.current?.value || undefined;
const oldReminderTime = notifSettings.reminderTime; const oldReminderTime = notifSettings.reminderTime;
const newBoltDelivered = boltDeliveredRef.current?.checked ?? false;
const oldBoltDelivered = notifSettings.boltDeliveredPush ?? false;
// Uložení notifikačních nastavení na server // Uložení notifikačních nastavení na server
await updateNotificationSettings({ const newSettings: NotificationSettings = {
body: {
ntfyTopic: ntfyTopicRef.current?.value || undefined, ntfyTopic: ntfyTopicRef.current?.value || undefined,
discordWebhookUrl: discordWebhookRef.current?.value || undefined, discordWebhookUrl: discordWebhookRef.current?.value || undefined,
teamsWebhookUrl: teamsWebhookRef.current?.value || undefined, teamsWebhookUrl: teamsWebhookRef.current?.value || undefined,
enabledEvents, enabledEvents,
reminderTime: newReminderTime, reminderTime: newReminderTime,
} boltDeliveredPush: newBoltDelivered,
}).catch(() => {}); };
await updateNotificationSettings({ body: newSettings }).catch(() => {});
setNotifSettings(newSettings);
// Správa push subscription pro připomínky // Správa push subscription — drží ji naživu kterákoli z push funkcí.
if (newReminderTime && newReminderTime !== oldReminderTime) { // Záměrně bez await: subscribeToPush si vyžádá oprávnění prohlížeče a modal
// by na tu dobu zůstal otevřený.
const wantsPush = !!newReminderTime || newBoltDelivered;
const hadPush = !!oldReminderTime || oldBoltDelivered;
if (wantsPush && (newReminderTime !== oldReminderTime || newBoltDelivered !== oldBoltDelivered)) {
subscribeToPush(newReminderTime); subscribeToPush(newReminderTime);
} else if (!newReminderTime && oldReminderTime) { } else if (!wantsPush && hadPush) {
unsubscribeFromPush(); unsubscribeFromPush();
} }
// Uložení ostatních nastavení (localStorage) // Uložení ostatních nastavení (localStorage)
if (styleThemeRef.current?.value) {
settings?.setStyleTheme(styleThemeRef.current.value as StyleTheme);
}
if (showButterfliesRef.current) {
settings?.setShowButterflies(showButterfliesRef.current.checked);
}
if (autoOrderChoiceRef.current) {
settings?.setAutoOrderChoice(autoOrderChoiceRef.current.checked);
}
onSave( onSave(
bankAccountRef.current?.value, bankAccountRef.current?.value,
nameRef.current?.value, nameRef.current?.value,
@@ -76,12 +96,21 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
}; };
return ( return (
<Modal show={isOpen} onHide={onClose} size="lg"> <Modal show={isOpen} onHide={onClose} size="xl">
<Modal.Header closeButton> <Modal.Header closeButton>
<Modal.Title><h2>Nastavení</h2></Modal.Title> <Modal.Title><h2>Nastavení</h2></Modal.Title>
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
{/* Na širších obrazovkách rozdělení do dvou sloupců, na užších pod sebou */}
<Row className="gx-5">
<Col lg={6}>
<h4>Vzhled</h4> <h4>Vzhled</h4>
<Form.Group className="mb-3">
<Form.Label>Styl</Form.Label>
<Form.Select ref={styleThemeRef} defaultValue={settings?.styleTheme}>
{STYLE_THEMES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</Form.Select>
</Form.Group>
<Form.Group className="mb-3"> <Form.Group className="mb-3">
<Form.Label>Barevný motiv</Form.Label> <Form.Label>Barevný motiv</Form.Label>
<Form.Select ref={themeRef} defaultValue={settings?.themePreference}> <Form.Select ref={themeRef} defaultValue={settings?.themePreference}>
@@ -90,6 +119,18 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
<option value="dark">Tmavý</option> <option value="dark">Tmavý</option>
</Form.Select> </Form.Select>
</Form.Group> </Form.Group>
<Form.Group className="mb-3">
<Form.Check
id="showButterfliesCheckbox"
ref={showButterfliesRef}
type="checkbox"
label="Zobrazit motýlky"
defaultChecked={settings?.showButterflies ?? true}
/>
<Form.Text className="text-muted">
Po vypnutí se poletující motýlci ani minihra nebudou vůbec zobrazovat.
</Form.Text>
</Form.Group>
<hr /> <hr />
@@ -107,9 +148,57 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
Experimentální funkce - zejména u TechTower bývá problém polévky spolehlivě rozeznat. Experimentální funkce - zejména u TechTower bývá problém polévky spolehlivě rozeznat.
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group className="mb-3">
<Form.Check
id="autoOrderChoiceCheckbox"
ref={autoOrderChoiceRef}
type="checkbox"
label='Při objednávání automaticky zvolit "Budu objednávat"'
defaultChecked={settings?.autoOrderChoice ?? false}
/>
<Form.Text className="text-muted">
Po založení skupinové objednávky nebo připojení k existující se vaše dnešní volba stravování přepne na "Budu objednávat". Volba Pizza day zůstane beze změny.
</Form.Text>
</Form.Group>
<hr /> <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>
</Col>
<Col lg={6}>
<hr className="d-lg-none" />
<h4>Notifikace</h4> <h4>Notifikace</h4>
<p> <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. 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.
@@ -128,6 +217,21 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
</Form.Text> </Form.Text>
</Form.Group> </Form.Group>
<Form.Group className="mb-3">
<Form.Check
id="boltDeliveredCheckbox"
ref={boltDeliveredRef}
type="checkbox"
label="Upozornění na doručení objednávky (Bolt Food / Wolt)"
defaultChecked={notifSettings.boltDeliveredPush ?? false}
key={`bolt-delivered-${notifSettings.boltDeliveredPush ?? false}`}
/>
<Form.Text className="text-muted">
Až bude sledovaná skupinová objednávka doručena, přijde vám push notifikace.
Zakladateli skupiny se neposílá — ten dostane upozornění přímo z aplikace rozvozové služby.
</Form.Text>
</Form.Group>
<Form.Group className="mb-3"> <Form.Group className="mb-3">
<Form.Label>ntfy téma (topic)</Form.Label> <Form.Label>ntfy téma (topic)</Form.Label>
<Form.Control <Form.Control
@@ -189,41 +293,8 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
Zvolte události, o kterých chcete být notifikováni. Notifikace jsou odesílány pouze uživatelům se stejnou zvolenou lokalitou. 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.Text>
</Form.Group> </Form.Group>
</Col>
<hr /> </Row>
<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.Body>
<Modal.Footer> <Modal.Footer>
<Button variant="secondary" onClick={onClose}> <Button variant="secondary" onClick={onClose}>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,407 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Modal, Button, ButtonGroup } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faXmark, faRotateLeft } from "@fortawesome/free-solid-svg-icons";
import { Restaurant, RestaurantDayMenuMap, Store, StoreMenu, StoreMenuSource, getStoreMenu, listStores } from "../../../../types";
import { getLunchChoiceName } from "../../enums";
import WheelConfetti from "../WheelConfetti";
import { useSettings } from "../../context/settings";
import "./SpinWheelModal.scss";
/** Výsledek kola štěstí — buď jídlo z dnešního menu podniku, nebo jídlo z rozvozu. */
export type WheelPick =
| { kind: 'menu', restaurant: Restaurant, foodIndex?: number, foodName?: string }
| { kind: 'store', store: string, dish?: string };
type Source = 'all' | 'menu' | 'store';
type Place = {
id: string,
label: string,
kind: 'menu' | 'store',
restaurant?: Restaurant,
/** Jídla dnešního menu; u rozvozu se nabídka dotahuje až při druhém kole. */
dishes: Dish[],
};
type Dish = { name: string, index?: number, price?: string };
const SOURCE_LABELS: Record<StoreMenuSource, string> = {
bolt: 'Nabídka z Bolt Food',
wolt: 'Nabídka z Wolt',
history: 'Nabídku se nepodařilo stáhnout — jídla z historie objednávek',
none: 'Nabídku se nepodařilo stáhnout',
};
type Props = {
isOpen: boolean,
onClose: () => void,
menus?: RestaurantDayMenuMap,
onPick: (pick: WheelPick) => Promise<void>,
};
const SPIN_MS = 4800;
const WHEEL_SIZE = 360;
const RADIUS = WHEEL_SIZE / 2 - 6;
const SEGMENT_COLORS = 6;
const MAX_LABELED_SEGMENTS = 40;
/** Točit má smysl až od dvou možností — jediná zbylá se vybere rovnou. */
const MIN_SPIN_ITEMS = 2;
/** Kryptograficky náhodný index v rozsahu 0..n-1 (bez modulo zkreslení to u malých n nevadí). */
function randomIndex(n: number): number {
const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
return buf[0] % n;
}
function truncate(text: string, max: number): string {
return text.length > max ? text.slice(0, max - 1) + '…' : text;
}
/** Bod na kružnici; úhel ve stupních měřený od „12 hodin" po směru hodinových ručiček. */
function polar(angle: number, r: number) {
const rad = (angle - 90) * Math.PI / 180;
return { x: WHEEL_SIZE / 2 + r * Math.cos(rad), y: WHEEL_SIZE / 2 + r * Math.sin(rad) };
}
function segmentPath(start: number, end: number): string {
const c = WHEEL_SIZE / 2;
if (end - start >= 360) {
return `M ${c} ${c - RADIUS} A ${RADIUS} ${RADIUS} 0 1 1 ${c - 0.01} ${c - RADIUS} Z`;
}
const a = polar(start, RADIUS);
const b = polar(end, RADIUS);
const large = end - start > 180 ? 1 : 0;
return `M ${c} ${c} L ${a.x} ${a.y} A ${RADIUS} ${RADIUS} 0 ${large} 1 ${b.x} ${b.y} Z`;
}
type WheelProps = {
labels: string[],
rotation: number,
spinning: boolean,
onSpinEnd: () => void,
};
function Wheel({ labels, rotation, spinning, onSpinEnd }: Readonly<WheelProps>) {
const n = labels.length;
const angle = 360 / Math.max(n, 1);
const fontSize = n > 24 ? 8.5 : n > 14 ? 10 : n > 8 ? 12 : 14;
const maxChars = n > 24 ? 26 : n > 14 ? 24 : 20;
// U velkých nabídek jsou popisky nečitelné — názvy jsou v seznamu vedle kola
const showLabels = n <= MAX_LABELED_SEGMENTS;
return (
<div className="spin-wheel">
<div className="spin-wheel-pointer" aria-hidden />
<svg
viewBox={`0 0 ${WHEEL_SIZE} ${WHEEL_SIZE}`}
className="spin-wheel-svg"
style={{
transform: `rotate(${rotation}deg)`,
transition: spinning ? `transform ${SPIN_MS}ms cubic-bezier(0.12, 0.72, 0.16, 1)` : 'none',
}}
onTransitionEnd={e => { if (e.propertyName === 'transform') onSpinEnd(); }}
role="img"
aria-label={`Kolo s ${n} možnostmi`}
>
<circle cx={WHEEL_SIZE / 2} cy={WHEEL_SIZE / 2} r={RADIUS + 4} className="spin-wheel-rim" />
{labels.map((label, i) => {
const start = i * angle;
const mid = start + angle / 2;
return (
<g key={`${i}-${label}`}>
<path d={segmentPath(start, start + angle)} className={`spin-seg spin-seg-${i % SEGMENT_COLORS}`} />
{showLabels && <text
x={WHEEL_SIZE / 2 + 34}
y={WHEEL_SIZE / 2}
transform={`rotate(${mid - 90} ${WHEEL_SIZE / 2} ${WHEEL_SIZE / 2})`}
dominantBaseline="middle"
fontSize={fontSize}
className="spin-seg-label"
>
{truncate(label, maxChars)}
</text>}
</g>
);
})}
<circle cx={WHEEL_SIZE / 2} cy={WHEEL_SIZE / 2} r={26} className="spin-wheel-hub" />
</svg>
</div>
);
}
/**
* Kolo štěstí pro nerozhodnuté: první kolo vybere podnik (dnešní menu nebo rozvoz),
* druhé kolo jídlo z něj (bez polévek). Možnosti lze z kola průběžně vyřazovat.
*/
export default function SpinWheelModal({ isOpen, onClose, menus, onPick }: Readonly<Props>) {
const [stores, setStores] = useState<Store[]>();
const [storeMenus, setStoreMenus] = useState<Record<string, StoreMenu | 'loading' | 'error'>>({});
const [source, setSource] = useState<Source>('all');
const [excludedPlaces, setExcludedPlaces] = useState<Set<string>>(new Set());
const [excludedDishes, setExcludedDishes] = useState<Set<string>>(new Set());
const [place, setPlace] = useState<Place>();
const [dish, setDish] = useState<Dish>();
const [phase, setPhase] = useState<'place' | 'dish'>('place');
const [rotation, setRotation] = useState(0);
const [spinning, setSpinning] = useState(false);
const [saving, setSaving] = useState(false);
const [respinRequested, setRespinRequested] = useState(false);
const theme = useSettings()?.styleTheme ?? 'simple';
const [confetti, setConfetti] = useState<{ key: number, big: boolean }>({ key: 0, big: false });
const pendingResult = useRef<number>(undefined);
useEffect(() => {
if (isOpen && !stores) {
listStores().then(r => setStores(r.data ?? [])).catch(() => setStores([]));
}
}, [isOpen, stores]);
const allPlaces = useMemo<Place[]>(() => {
const result: Place[] = [];
for (const key of Object.keys(Restaurant) as Restaurant[]) {
const menu = menus?.[key];
if (!menu || menu.closed || !menu.food?.length) continue;
const dishes = menu.food
.map((f, index) => ({ name: f.name, index, price: f.price, isSoup: f.isSoup }))
.filter(f => !f.isSoup)
.map(({ name, index, price }) => ({ name, index, price }));
result.push({ id: `menu:${key}`, label: getLunchChoiceName(key), kind: 'menu', restaurant: key, dishes });
}
for (const s of stores ?? []) {
result.push({ id: `store:${s.name}`, label: s.name, kind: 'store', dishes: [] });
}
return result;
}, [menus, stores]);
const storeMenu = place?.kind === 'store' ? storeMenus[place.label] : undefined;
const allDishes = useMemo<Dish[]>(() => {
if (place?.kind !== 'store') return place?.dishes ?? [];
if (!storeMenu || typeof storeMenu === 'string') return [];
return storeMenu.dishes
.filter(d => d.isMain !== false)
.map(d => ({ name: d.name, price: d.price != null ? `${d.price} Kč` : undefined }));
}, [place, storeMenu]);
const places = useMemo(
() => allPlaces.filter(p => (source === 'all' || p.kind === source) && !excludedPlaces.has(p.id)),
[allPlaces, source, excludedPlaces],
);
const dishes = useMemo(
() => allDishes.filter(d => !excludedDishes.has(`${place?.id}|${d.name}`)),
[place, allDishes, excludedDishes],
);
const wheelItems = phase === 'place' ? places.map(p => p.label) : dishes.map(d => d.name);
const result = phase === 'place' ? place : dish;
const resetWheel = () => {
setSpinning(false);
setRotation(r => r % 360);
};
// Zbyla jediná možnost — není co losovat, vybere se rovnou (lze pokračovat dál / převzít)
const onlyOne = wheelItems.length === 1;
useEffect(() => {
if (spinning || !onlyOne) return;
if (phase === 'place' && place?.id !== places[0].id) setPlace(places[0]);
else if (phase === 'dish' && dish?.name !== dishes[0].name) setDish(dishes[0]);
}, [onlyOne, phase, places, dishes, spinning, place?.id, dish?.name]);
const spin = () => {
const n = wheelItems.length;
if (n < MIN_SPIN_ITEMS || spinning) return;
const index = randomIndex(n);
const angle = 360 / n;
const jitter = (Math.random() - 0.5) * angle * 0.6;
const target = 360 - (index * angle + angle / 2) + jitter;
const turns = 5 + randomIndex(3);
pendingResult.current = index;
if (phase === 'place') setPlace(undefined); else setDish(undefined);
setSpinning(true);
setRotation(r => r - (r % 360) + turns * 360 + ((target % 360) + 360) % 360);
};
const handleSpinEnd = () => {
const index = pendingResult.current;
if (index == null) return;
pendingResult.current = undefined;
setSpinning(false);
if (phase === 'place') setPlace(places[index]);
else setDish(dishes[index]);
setConfetti(c => ({ key: c.key + 1, big: phase === 'dish' }));
};
const goToDishes = () => {
if (place?.kind === 'store' && (!storeMenus[place.label] || storeMenus[place.label] === 'error')) {
const name = place.label;
setStoreMenus(prev => ({ ...prev, [name]: 'loading' }));
getStoreMenu({ query: { name } })
.then(r => setStoreMenus(prev => ({ ...prev, [name]: r.data ?? 'error' })))
.catch(() => setStoreMenus(prev => ({ ...prev, [name]: 'error' })));
}
setDish(undefined);
setPhase('dish');
resetWheel();
};
const backToPlaces = () => {
setDish(undefined);
setPlace(undefined);
setPhase('place');
resetWheel();
};
const removeOption = (i: number) => {
if (spinning) return;
if (phase === 'place') {
const p = places[i];
setExcludedPlaces(prev => new Set(prev).add(p.id));
if (place?.id === p.id) setPlace(undefined);
} else if (place) {
const d = dishes[i];
setExcludedDishes(prev => new Set(prev).add(`${place.id}|${d.name}`));
if (dish?.name === d.name) setDish(undefined);
}
};
const removeResultAndRespin = () => {
const i = phase === 'place' ? places.findIndex(p => p.id === place?.id) : dishes.findIndex(d => d.name === dish?.name);
if (i < 0) return;
removeOption(i);
setRespinRequested(true);
};
// Po vyřazení se kolo překreslí s menším počtem výsečí — roztočíme ho až nad novými daty
useEffect(() => {
if (respinRequested) {
setRespinRequested(false);
spin();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [respinRequested]);
const restoreAll = () => {
if (phase === 'place') setExcludedPlaces(new Set());
else if (place) setExcludedDishes(prev => new Set([...prev].filter(k => !k.startsWith(`${place.id}|`))));
};
const accept = async () => {
if (!place) return;
setSaving(true);
try {
if (place.kind === 'menu') {
await onPick({ kind: 'menu', restaurant: place.restaurant!, foodIndex: dish?.index, foodName: dish?.name });
} else {
await onPick({ kind: 'store', store: place.label, dish: dish?.name });
}
onClose();
} finally {
setSaving(false);
}
};
const excludedCount = phase === 'place'
? allPlaces.filter(p => (source === 'all' || p.kind === source) && excludedPlaces.has(p.id)).length
: allDishes.length - dishes.length;
const menuLoading = phase === 'dish' && storeMenu === 'loading';
return (
<Modal show={isOpen} onHide={onClose} size="lg" className={`spin-wheel-modal spin-theme-${theme}`}>
<Modal.Header closeButton>
<Modal.Title>
<h2>{phase === 'place' ? 'Kolo štěstí: kam?' : `Kolo štěstí: co v ${place?.label}?`}</h2>
</Modal.Title>
</Modal.Header>
<WheelConfetti theme={theme} burstKey={confetti.key} big={confetti.big} />
<Modal.Body>
<div className="spin-steps">
<span className={phase === 'place' ? 'active' : 'done'}>1. Podnik</span>
<span className={phase === 'dish' ? 'active' : ''}>2. Jídlo</span>
{phase === 'dish' && storeMenu && typeof storeMenu !== 'string' && (
<small className="spin-source-info">{SOURCE_LABELS[storeMenu.source]}</small>
)}
</div>
{phase === 'place' && (
<ButtonGroup className="spin-source mb-3" aria-label="Zdroj podniků">
{([['all', 'Vše'], ['menu', 'Dnešní menu'], ['store', 'Rozvoz']] as [Source, string][]).map(([value, label]) => (
<Button key={value} variant={source === value ? 'primary' : 'outline-primary'} disabled={spinning}
onClick={() => { setSource(value); setPlace(undefined); }}>
{label}
</Button>
))}
</ButtonGroup>
)}
<div className="spin-layout">
<div className="spin-wheel-column">
{(phase === 'place' && !stores) || menuLoading ? (
<div className="spin-empty">{menuLoading ? 'Stahuji nabídku podniku…' : 'Načítám podniky…'}</div>
) : wheelItems.length === 0 ? (
<div className="spin-empty">
{phase === 'dish'
? 'Tady nemáme žádná jídla k točení — vyber si na místě.'
: 'Na kole nezbyla žádná možnost.'}
</div>
) : (
<Wheel labels={wheelItems} rotation={rotation} spinning={spinning} onSpinEnd={handleSpinEnd} />
)}
<div className={`spin-result${result ? ' visible' : ''}`} aria-live="polite">
{result && (phase === 'place'
? <>{onlyOne ? 'Zbývá jediná možnost' : 'Padlo'}: <strong>{place!.label}</strong> <small>({place!.kind === 'menu' ? 'dnešní menu' : 'rozvoz'})</small></>
: <>{onlyOne ? 'Zbývá jediné jídlo' : 'Padlo'}: <strong>{dish!.name}</strong>{dish!.price && <small> ({dish!.price})</small>}</>)}
</div>
<div className="spin-actions">
<Button onClick={spin} disabled={spinning || wheelItems.length < MIN_SPIN_ITEMS} className="spin-button"
title={wheelItems.length < MIN_SPIN_ITEMS ? 'Na kole musí být aspoň 2 možnosti' : undefined}>
{spinning ? 'Točí se…' : result && !onlyOne ? 'Točit znovu' : 'Roztočit!'}
</Button>
{result && !spinning && wheelItems.length > 1 && (
<Button variant="outline-primary" onClick={removeResultAndRespin}>Vyřadit a točit znovu</Button>
)}
</div>
{onlyOne && (
<small className="text-muted">Na kole musí být aspoň 2 možnosti — vrať nějaké vyřazené, nebo pokračuj s touto.</small>
)}
</div>
<div className="spin-options">
<div className="spin-options-header">
<span>Na kole ({wheelItems.length})</span>
{excludedCount > 0 && (
<button className="spin-restore" onClick={restoreAll} disabled={spinning} title="Vrátit vyřazené možnosti">
<FontAwesomeIcon icon={faRotateLeft} /> Vrátit {excludedCount}
</button>
)}
</div>
<ul>
{wheelItems.map((label, i) => (
<li key={`${i}-${label}`}>
<span className={`spin-dot spin-seg-${i % SEGMENT_COLORS}`} />
<span className="spin-option-label" title={label}>{label}</span>
{phase === 'place'
? <small className="spin-option-kind">{places[i].kind === 'menu' ? 'menu' : 'rozvoz'}</small>
: dishes[i].price && <small className="spin-option-kind">{dishes[i].price}</small>}
<button className="spin-remove" onClick={() => removeOption(i)} disabled={spinning}
title={`Vyřadit z kola: ${label}`} aria-label={`Vyřadit ${label}`}>
<FontAwesomeIcon icon={faXmark} />
</button>
</li>
))}
</ul>
</div>
</div>
</Modal.Body>
<Modal.Footer>
{phase === 'dish' && <Button variant="secondary" onClick={backToPlaces} disabled={spinning}>Zpět na podniky</Button>}
{phase === 'place' && place && !spinning && (
<Button variant="outline-primary" onClick={accept} disabled={saving}>Beru jen podnik</Button>
)}
{phase === 'place' && place && !spinning && (
<Button onClick={goToDishes}>Dál: točit jídlo</Button>
)}
{phase === 'dish' && !spinning && (dish || dishes.length === 0) && (
<Button onClick={accept} disabled={saving}>{dish ? 'Beru to!' : 'Beru podnik'}</Button>
)}
</Modal.Footer>
</Modal>
);
}
+281 -32
View File
@@ -1,9 +1,13 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { Modal, Button, Form, ListGroup, Alert } from "react-bootstrap"; import { Modal, Button, Form, ListGroup, Alert, OverlayTrigger, Tooltip } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrashCan } from "@fortawesome/free-regular-svg-icons"; import { faTrashCan } from "@fortawesome/free-regular-svg-icons";
import { faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons"; import { faArrowsRotate, faPen, faPlus, faUpRightFromSquare, faXmark } from "@fortawesome/free-solid-svg-icons";
import { addStore, deleteStore, Store } from "../../../../types"; import { addStore, deleteStore, updateStore, getStoreMenuStatus, refreshStoreMenu, Store, StoreMenuProvider, StoreScrapeInfo, StoreScrapeStatus } from "../../../../types";
import { getHumanDateTime } from "../../Utils";
import PriceHistory from "../PriceHistory";
import { useAppConfig } from "../../hooks/useGroupLimits";
import { getStoreUrls, storeUrlLabel } from "../../utils/storeUrls";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
@@ -12,25 +16,137 @@ type Props = {
onStoresChanged: (stores: Store[]) => void; onStoresChanged: (stores: Store[]) => void;
}; };
/** Rozpracovaná editace obchodu — původní název slouží k jeho identifikaci na serveru. */
type EditState = {
originalName: string;
name: string;
urls: string[];
};
const PROVIDER_LABELS: Record<StoreMenuProvider, string> = { bolt: 'Bolt Food', wolt: 'Wolt' };
/** Krátký popis stavu stahování nabídky od jedné služby. */
function describeScrape(p: StoreScrapeStatus): { text: string, className: string, title?: string } {
const when = (iso?: string) => iso ? getHumanDateTime(new Date(iso)) : '';
switch (p.state) {
case 'ok':
return { text: `${p.dishCount} hlavních jídel (${when(p.lastSuccessAt)})${p.lastError ? ` · poslední obnovení selhalo: ${p.lastError}` : ''}`, className: 'text-success' };
case 'failed':
return { text: `selhalo ${p.failures}× (${when(p.lastAttemptAt)}): ${p.lastError ?? ''} · zkusí se znovu`, className: 'text-warning' };
case 'blocked':
return { text: `zablokováno po ${p.failures} neúspěších (${when(p.lastAttemptAt)}): ${p.lastError ?? ''}`, className: 'text-danger' };
default:
return { text: 'nestaženo', className: 'text-muted', title: 'Stáhne se automaticky, až bude nabídka potřeba v kole štěstí (a žádná jiná služba ji nemá)' };
}
}
/** Vstupní pole pro URL nabídek — vždy alespoň jedno prázdné, aby bylo kam psát. */
function toUrlInputs(urls: string[]): string[] {
return urls.length > 0 ? urls : [''];
}
export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChanged }: Readonly<Props>) { export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChanged }: Readonly<Props>) {
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
const [newUrl, setNewUrl] = useState(''); // Jeden podnik může být dostupný přes více dovozových služeb — proto seznam URL
const [newUrls, setNewUrls] = useState<string[]>(['']);
const [edit, setEdit] = useState<EditState | null>(null);
const [heslo, setHeslo] = useState(''); const [heslo, setHeslo] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Server bez admin hesla — správa (přidání/úprava/odebrání/obnovení) nejde, zbývá jen přehled.
// undefined = konfigurace nenačtena, chováme se jako dřív (správa zapnutá).
const adminEnabled = useAppConfig().storeAdmin !== false;
const [scrapeInfo, setScrapeInfo] = useState<Record<string, StoreScrapeInfo>>({});
const [refreshing, setRefreshing] = useState<string | null>(null);
// Rozbalená historie cen („obchod|služba")
const [historyOpen, setHistoryOpen] = useState<string | null>(null);
// Stav stahování nabídek (Bolt Food/Wolt) — při otevření a po změně seznamu obchodů
useEffect(() => {
if (!isOpen) return;
getStoreMenuStatus().then(res => {
setScrapeInfo(Object.fromEntries((res.data ?? []).map(i => [i.store, i])));
}).catch(() => { /* stav je jen informativní */ });
}, [isOpen, stores]);
const handleRefreshMenu = async (name: string, provider: StoreMenuProvider) => {
setError(null);
setRefreshing(`${name}|${provider}`);
try {
const res = await refreshStoreMenu({ body: { name, provider, heslo } });
if (res.data) {
setScrapeInfo(prev => ({ ...prev, [name]: res.data! }));
}
// Znovu načíst případně otevřenou historii (mohla přibýt nová verze)
if (historyOpen === `${name}|${provider}`) {
setHistoryOpen(null);
setTimeout(() => setHistoryOpen(`${name}|${provider}`));
}
} catch (e: any) {
setError(e.message || 'Nastala chyba');
} finally {
setRefreshing(null);
}
};
const addUrlRow = (urls: string[]) => [...urls, ''];
const removeUrlRow = (urls: string[], index: number) =>
urls.length === 1 ? [''] : urls.filter((_, i) => i !== index);
const setUrlAt = (urls: string[], index: number, value: string) =>
urls.map((u, i) => (i === index ? value : u));
/** Zpracuje odpověď API — vrací true při úspěchu. Chybu z API hlásí globální toaster. */
const applyResult = (res: { data?: unknown; error?: unknown }): boolean => {
if (res.error) {
return false;
}
if (res.data) {
onStoresChanged(res.data as Store[]);
return true;
}
return false;
};
const handleAdd = async () => { const handleAdd = async () => {
if (!newName.trim()) return; if (!newName.trim()) return;
setError(null); setError(null);
setLoading(true); setLoading(true);
try { try {
const res = await addStore({ body: { name: newName.trim(), url: newUrl.trim() || undefined, heslo } }); const urls = newUrls.map(u => u.trim()).filter(Boolean);
if (res.error) { const res = await addStore({ body: { name: newName.trim(), urls: urls.length > 0 ? urls : undefined, heslo } });
setError((res.error as any).error || 'Nastala chyba'); if (applyResult(res)) {
} else if (res.data) {
onStoresChanged(res.data as Store[]);
setNewName(''); setNewName('');
setNewUrl(''); setNewUrls(['']);
}
} catch (e: any) {
setError(e.message || 'Nastala chyba');
} finally {
setLoading(false);
}
};
const startEdit = (store: Store) => {
setError(null);
setEdit({ originalName: store.name, name: store.name, urls: toUrlInputs(store.urls ?? []) });
};
const handleSaveEdit = async () => {
if (!edit || !edit.name.trim()) return;
setError(null);
setLoading(true);
try {
const res = await updateStore({
body: {
name: edit.originalName,
newName: edit.name.trim(),
urls: edit.urls.map(u => u.trim()).filter(Boolean),
heslo,
},
});
if (applyResult(res)) {
setEdit(null);
} }
} catch (e: any) { } catch (e: any) {
setError(e.message || 'Nastala chyba'); setError(e.message || 'Nastala chyba');
@@ -44,10 +160,8 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
setLoading(true); setLoading(true);
try { try {
const res = await deleteStore({ body: { name, heslo } }); const res = await deleteStore({ body: { name, heslo } });
if (res.error) { if (applyResult(res) && edit?.originalName === name) {
setError((res.error as any).error || 'Nastala chyba'); setEdit(null);
} else if (res.data) {
onStoresChanged(res.data as Store[]);
} }
} catch (e: any) { } catch (e: any) {
setError(e.message || 'Nastala chyba'); setError(e.message || 'Nastala chyba');
@@ -56,8 +170,39 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
} }
}; };
/** Řádky se vstupy pro URL — používá se pro přidání i pro editaci obchodu. */
const renderUrlInputs = (urls: string[], onChange: (urls: string[]) => void, onSubmit: () => void) => (
<>
{urls.map((url, index) => (
<div key={index} className="d-flex gap-2 mb-2 align-items-center">
<Form.Control
type="url"
placeholder={index === 0
? 'URL na nabídku (volitelné, např. Bolt Food/Wolt)'
: 'Další URL na nabídku (jiná dovozová služba)'}
value={url}
onChange={e => onChange(setUrlAt(urls, index, e.target.value))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') onSubmit(); }}
/>
<Button
variant="outline-secondary"
onClick={() => onChange(removeUrlRow(urls, index))}
disabled={urls.length === 1 && !url}
title="Odebrat tuto URL"
>
<FontAwesomeIcon icon={faXmark} />
</Button>
</div>
))}
<Button variant="link" size="sm" className="p-0" onClick={() => onChange(addUrlRow(urls))}>
<FontAwesomeIcon icon={faPlus} className="me-1" />
Přidat další URL
</Button>
</>
);
return ( return (
<Modal show={isOpen} onHide={onClose}> <Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton> <Modal.Header closeButton>
<Modal.Title><h2>Správa obchodů</h2></Modal.Title> <Modal.Title><h2>Správa obchodů</h2></Modal.Title>
</Modal.Header> </Modal.Header>
@@ -68,6 +213,14 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
</Alert> </Alert>
)} )}
{!adminEnabled && (
<Alert variant="info">
Správa obchodů není na tomto serveru zapnutá (chybí <code>ADMIN_PASSWORD</code>).
Seznam obchodů, stav nabídek a historii cen lze jen prohlížet.
</Alert>
)}
{adminEnabled && <>
<Form.Group className="mb-3"> <Form.Group className="mb-3">
<Form.Label>Admin heslo</Form.Label> <Form.Label>Admin heslo</Form.Label>
<Form.Control <Form.Control
@@ -77,6 +230,9 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
onChange={e => setHeslo(e.target.value)} onChange={e => setHeslo(e.target.value)}
onKeyDown={e => e.stopPropagation()} onKeyDown={e => e.stopPropagation()}
/> />
<Form.Text className="text-muted">
Potřeba pro přidání, úpravu a odebrání obchodu i pro obnovení nabídky (<FontAwesomeIcon icon={faArrowsRotate} />).
</Form.Text>
</Form.Group> </Form.Group>
<hr /> <hr />
@@ -89,34 +245,125 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
onChange={e => setNewName(e.target.value)} onChange={e => setNewName(e.target.value)}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleAdd(); }} onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleAdd(); }}
/> />
<div className="d-flex gap-2 mb-3"> {renderUrlInputs(newUrls, setNewUrls, handleAdd)}
<Form.Control <div className="d-flex justify-content-end mb-3">
type="url"
placeholder="URL na nabídku (volitelné, např. Bolt Food/Wolt)"
value={newUrl}
onChange={e => setNewUrl(e.target.value)}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleAdd(); }}
/>
<Button variant="primary" onClick={handleAdd} disabled={loading || !newName.trim() || !heslo}> <Button variant="primary" onClick={handleAdd} disabled={loading || !newName.trim() || !heslo}>
Přidat Přidat
</Button> </Button>
</div> </div>
</>}
<h6>Aktuální seznam</h6> <h6>Aktuální seznam</h6>
{stores.length === 0 ? ( {stores.length === 0 ? (
<p className="text-muted">Žádné obchody v seznamu</p> <p className="text-muted">Žádné obchody v seznamu</p>
) : ( ) : (
<ListGroup> <ListGroup>
{stores.map(s => ( {stores.map(s => {
<ListGroup.Item key={s.name} className="d-flex justify-content-between align-items-center"> const urls = getStoreUrls(s);
<span> const isEditing = edit?.originalName === s.name;
{s.name}
{s.url && /^https?:\/\//i.test(s.url) && ( if (isEditing) {
<a href={s.url} target="_blank" rel="noopener noreferrer" className="ms-2" title="Otevřít nabídku v nové záložce"> return (
<FontAwesomeIcon icon={faUpRightFromSquare} /> <ListGroup.Item key={s.name}>
<Form.Control
className="mb-2"
type="text"
placeholder="Název obchodu"
value={edit.name}
onChange={e => setEdit({ ...edit, name: e.target.value })}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveEdit(); }}
/>
{renderUrlInputs(edit.urls, next => setEdit({ ...edit, urls: next }), handleSaveEdit)}
<div className="d-flex justify-content-end gap-2 mt-2">
<Button variant="secondary" size="sm" onClick={() => setEdit(null)}>
Zrušit
</Button>
<Button
variant="primary"
size="sm"
onClick={handleSaveEdit}
disabled={loading || !edit.name.trim() || !heslo}
>
Uložit
</Button>
</div>
</ListGroup.Item>
);
}
return (
<ListGroup.Item key={s.name} className="d-flex justify-content-between align-items-start">
<div>
<div>{s.name}</div>
{urls.length > 0 && (
<small className="text-muted d-flex flex-wrap gap-2">
{urls.map(url => (
<a
key={url}
href={url}
target="_blank"
rel="noopener noreferrer"
title={url}
>
{storeUrlLabel(url)}
<FontAwesomeIcon icon={faUpRightFromSquare} className="ms-1" />
</a> </a>
))}
</small>
)} )}
{scrapeInfo[s.name]?.providers.map(p => {
const { text, className, title } = describeScrape(p);
const key = `${s.name}|${p.provider}`;
return (
<div key={p.provider}>
<small className="d-flex align-items-baseline gap-2 mt-1">
<span className="text-nowrap text-muted">Menu {PROVIDER_LABELS[p.provider]}:</span>
<span className={className} title={title}>{text}</span>
{/* Zakázané tlačítko nezobrazí tooltip — obalení do span ho zobrazí i bez hesla */}
{adminEnabled && <OverlayTrigger
placement="top"
overlay={
<Tooltip>
{heslo
? `Znovu stáhnout nabídku z ${PROVIDER_LABELS[p.provider]}`
: 'Pro obnovení nabídky zadejte nahoře admin heslo'}
</Tooltip>
}
>
<span className="d-inline-block" style={{ cursor: heslo ? undefined : 'not-allowed' }}>
<Button
variant="link"
size="sm"
className="p-0"
disabled={!heslo || refreshing != null}
style={heslo ? undefined : { pointerEvents: 'none' }}
aria-label={`Znovu stáhnout nabídku z ${PROVIDER_LABELS[p.provider]}`}
onClick={() => handleRefreshMenu(s.name, p.provider)}
>
<FontAwesomeIcon icon={faArrowsRotate} spin={refreshing === key} />
</Button>
</span> </span>
</OverlayTrigger>}
{p.dishCount > 0 && (
<Button variant="link" size="sm" className="p-0 text-nowrap"
onClick={() => setHistoryOpen(historyOpen === key ? null : key)}>
{historyOpen === key ? 'skrýt historii' : 'historie cen'}
</Button>
)}
</small>
{historyOpen === key && <PriceHistory store={s.name} provider={p.provider} />}
</div>
);
})}
</div>
{adminEnabled && <div className="d-flex gap-3">
<FontAwesomeIcon
icon={faPen}
className="action-icon"
title="Upravit"
onClick={() => startEdit(s)}
style={{ cursor: 'pointer' }}
/>
<FontAwesomeIcon <FontAwesomeIcon
icon={faTrashCan} icon={faTrashCan}
className="action-icon" className="action-icon"
@@ -124,8 +371,10 @@ export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChang
onClick={() => handleRemove(s.name)} onClick={() => handleRemove(s.name)}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
/> />
</div>}
</ListGroup.Item> </ListGroup.Item>
))} );
})}
</ListGroup> </ListGroup>
)} )}
</Modal.Body> </Modal.Body>
+99 -2
View File
@@ -6,9 +6,24 @@ const HIDE_SOUPS_KEY = 'hide_soups';
const THEME_KEY = 'theme_preference'; const THEME_KEY = 'theme_preference';
const ACCENT_HUE_KEY = 'accent_hue'; const ACCENT_HUE_KEY = 'accent_hue';
const LEGACY_COLOR_THEME_KEY = 'color_theme'; const LEGACY_COLOR_THEME_KEY = 'color_theme';
const STYLE_THEME_KEY = 'style_theme';
const SHOW_BUTTERFLIES_KEY = 'show_butterflies';
const AUTO_ORDER_CHOICE_KEY = 'auto_order_choice';
export type ThemePreference = 'system' | 'light' | 'dark'; export type ThemePreference = 'system' | 'light' | 'dark';
/** Vizuální styl aplikace (nezávislý na světlém/tmavém režimu). */
export type StyleTheme = 'simple' | 'umamusume' | 'mlp' | 'hellgirl' | 'kon' | 'bocchi';
export const STYLE_THEMES: { value: StyleTheme, label: string }[] = [
{ value: 'simple', label: 'Jednoduchý' },
{ value: 'umamusume', label: 'Umamusume' },
{ value: 'mlp', label: 'My Little Pony' },
{ value: 'hellgirl', label: 'Hell Girl' },
{ value: 'kon', label: 'K-ON!' },
{ value: 'bocchi', label: 'Bocchi the Rock!' },
];
export type SettingsContextProps = { export type SettingsContextProps = {
bankAccount?: string, bankAccount?: string,
holderName?: string, holderName?: string,
@@ -16,11 +31,17 @@ export type SettingsContextProps = {
themePreference: ThemePreference, themePreference: ThemePreference,
accentHue: number, accentHue: number,
effectiveDark: boolean, effectiveDark: boolean,
styleTheme: StyleTheme,
showButterflies: boolean,
autoOrderChoice: boolean,
setBankAccountNumber: (accountNumber?: string) => void, setBankAccountNumber: (accountNumber?: string) => void,
setBankAccountHolderName: (holderName?: string) => void, setBankAccountHolderName: (holderName?: string) => void,
setHideSoupsOption: (hideSoups?: boolean) => void, setHideSoupsOption: (hideSoups?: boolean) => void,
setThemePreference: (theme: ThemePreference) => void, setThemePreference: (theme: ThemePreference) => void,
setAccentHue: (hue: number) => void, setAccentHue: (hue: number) => void,
setStyleTheme: (theme: StyleTheme) => void,
setShowButterflies: (show: boolean) => void,
setAutoOrderChoice: (enabled: boolean) => void,
} }
type ContextProps = { type ContextProps = {
@@ -50,6 +71,37 @@ function getInitialTheme(): ThemePreference {
return 'system'; return 'system';
} }
function getInitialStyleTheme(): StyleTheme {
try {
const saved = localStorage.getItem(STYLE_THEME_KEY);
const known = STYLE_THEMES.find(t => t.value === saved);
if (known) {
return known.value;
}
} catch {
// localStorage nedostupný
}
return 'simple';
}
function getInitialShowButterflies(): boolean {
try {
return localStorage.getItem(SHOW_BUTTERFLIES_KEY) !== 'false';
} catch {
// localStorage nedostupný
}
return true;
}
function getInitialAutoOrderChoice(): boolean {
try {
return localStorage.getItem(AUTO_ORDER_CHOICE_KEY) === 'true';
} catch {
// localStorage nedostupný
}
return false;
}
function getInitialAccentHue(): number { function getInitialAccentHue(): number {
try { try {
const saved = localStorage.getItem(ACCENT_HUE_KEY); const saved = localStorage.getItem(ACCENT_HUE_KEY);
@@ -90,6 +142,12 @@ function adjustedL(hue: number, sat: number, targetL: number): number {
return l; return l;
} }
const ACCENT_PROPERTIES = ['--luncher-primary', '--luncher-primary-hover', '--luncher-primary-light', '--luncher-action-icon', '--luncher-success'];
function clearAccentColors(): void {
ACCENT_PROPERTIES.forEach(p => document.documentElement.style.removeProperty(p));
}
function applyAccentColors(hue: number, isDark: boolean): void { function applyAccentColors(hue: number, isDark: boolean): void {
const sat = 70; const sat = 70;
const baseL = adjustedL(hue, sat, isDark ? 55 : 38); const baseL = adjustedL(hue, sat, isDark ? 55 : 38);
@@ -110,6 +168,9 @@ function useProvideSettings(): SettingsContextProps {
const [hideSoups, setHideSoups] = useState<boolean | undefined>(); const [hideSoups, setHideSoups] = useState<boolean | undefined>();
const [themePreference, setTheme] = useState<ThemePreference>(getInitialTheme); const [themePreference, setTheme] = useState<ThemePreference>(getInitialTheme);
const [accentHue, setHue] = useState<number>(getInitialAccentHue); const [accentHue, setHue] = useState<number>(getInitialAccentHue);
const [styleTheme, setStyle] = useState<StyleTheme>(getInitialStyleTheme);
const [showButterflies, setShowButterfliesState] = useState<boolean>(getInitialShowButterflies);
const [autoOrderChoice, setAutoOrderChoiceState] = useState<boolean>(getInitialAutoOrderChoice);
const [effectiveDark, setEffectiveDark] = useState<boolean>(() => { const [effectiveDark, setEffectiveDark] = useState<boolean>(() => {
try { try {
const pref = localStorage.getItem(THEME_KEY) as ThemePreference | null; const pref = localStorage.getItem(THEME_KEY) as ThemePreference | null;
@@ -178,11 +239,29 @@ function useProvideSettings(): SettingsContextProps {
} }
}, [themePreference]); }, [themePreference]);
// Aplikuje accent barvy při změně hue nebo přepnutí světlý/tmavý useEffect(() => {
localStorage.setItem(STYLE_THEME_KEY, styleTheme);
document.documentElement.setAttribute('data-luncher-theme', styleTheme);
}, [styleTheme]);
useEffect(() => {
localStorage.setItem(SHOW_BUTTERFLIES_KEY, showButterflies ? 'true' : 'false');
}, [showButterflies]);
useEffect(() => {
localStorage.setItem(AUTO_ORDER_CHOICE_KEY, autoOrderChoice ? 'true' : 'false');
}, [autoOrderChoice]);
// Aplikuje accent barvy při změně hue nebo přepnutí světlý/tmavý.
// Tematické styly (Umamusume, My Little Pony, Hell Girl, K-ON!, Bocchi the Rock!) mají vlastní pevnou paletu, accent barvu proto uplatňuje jen jednoduchý styl.
useEffect(() => { useEffect(() => {
localStorage.setItem(ACCENT_HUE_KEY, String(accentHue)); localStorage.setItem(ACCENT_HUE_KEY, String(accentHue));
if (styleTheme === 'simple') {
applyAccentColors(accentHue, effectiveDark); applyAccentColors(accentHue, effectiveDark);
}, [accentHue, effectiveDark]); } else {
clearAccentColors();
}
}, [accentHue, effectiveDark, styleTheme]);
function setBankAccountNumber(bankAccount?: string) { function setBankAccountNumber(bankAccount?: string) {
setBankAccount(bankAccount); setBankAccount(bankAccount);
@@ -204,6 +283,18 @@ function useProvideSettings(): SettingsContextProps {
setHue(hue); setHue(hue);
} }
function setStyleTheme(theme: StyleTheme) {
setStyle(theme);
}
function setShowButterflies(show: boolean) {
setShowButterfliesState(show);
}
function setAutoOrderChoice(enabled: boolean) {
setAutoOrderChoiceState(enabled);
}
return { return {
bankAccount, bankAccount,
holderName, holderName,
@@ -216,5 +307,11 @@ function useProvideSettings(): SettingsContextProps {
setHideSoupsOption, setHideSoupsOption,
setThemePreference, setThemePreference,
setAccentHue, setAccentHue,
styleTheme,
setStyleTheme,
showButterflies,
setShowButterflies,
autoOrderChoice,
setAutoOrderChoice,
} }
} }
+2 -1
View File
@@ -4,7 +4,8 @@ import socketio from "socket.io-client";
let socketUrl; let socketUrl;
let socketPath; let socketPath;
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
socketUrl = `http://localhost:3001`; // Stejný stroj jako stránka, aby šlo vývojovou verzi zkoušet i z telefonu či tabletu v síti
socketUrl = `http://${globalThis.location.hostname}:3001`;
socketPath = undefined; socketPath = undefined;
} else { } else {
socketUrl = `${globalThis.location.host}`; socketUrl = `${globalThis.location.host}`;
+3
View File
@@ -1,5 +1,8 @@
import { LunchChoice, Restaurant } from "../../types"; import { LunchChoice, Restaurant } from "../../types";
/** Volby, u kterých se nezadává cena oběda — cena je dána objednávkou, Pizza day, nebo se neobědvá. */
export const CHOICES_WITHOUT_PRICE: LunchChoice[] = [LunchChoice.OBJEDNAVAM, LunchChoice.PIZZA, LunchChoice.NEOBEDVAM, LunchChoice.ROZHODUJI];
export function getRestaurantName(restaurant: Restaurant) { export function getRestaurantName(restaurant: Restaurant) {
switch (restaurant) { switch (restaurant) {
case Restaurant.SLADOVNICKA: case Restaurant.SLADOVNICKA:
+33
View File
@@ -0,0 +1,33 @@
import { StyleTheme, useSettings } from "../context/settings";
export type GameId = 'drawing';
/** Ikony miniher podle vizuálního stylu aplikace (ostatní minihry jsou v samostatné aplikaci Lobby). */
const ICONS: Record<StyleTheme, Record<GameId, string>> = {
simple: { drawing: '🎨' },
// Dostihy: trofej
umamusume: { drawing: '🏆' },
// Paleta Rarity
mlp: { drawing: '🖌️' },
// Pekelná korespondence: svitek
hellgirl: { drawing: '📜' },
// Klub lehké hudby: notový zápis
kon: { drawing: '🎼' },
// Kessoku Band: sešit s texty Bocchi
bocchi: { drawing: '📝' },
};
/** Styl z <html data-luncher-theme> — pro místa mimo React kontext nastavení. */
function currentTheme(): StyleTheme {
const theme = document.documentElement.getAttribute('data-luncher-theme') as StyleTheme | null;
return theme && theme in ICONS ? theme : 'simple';
}
/** Ikona hry pro aktuální styl (mimo React, např. výpis do konzole). */
export const gameIcon = (game: GameId, theme: StyleTheme = currentTheme()) => ICONS[theme]?.[game] ?? ICONS.simple[game];
/** Ikona hry pro aktuální styl; stránky bez nastavení (statistiky) použijí atribut na <html>. */
export function useGameIcon(game: GameId) {
const settings = useSettings();
return gameIcon(game, settings?.styleTheme ?? currentTheme());
}
+256
View File
@@ -0,0 +1,256 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
ButterflyStats,
getButterflyStats,
catchButterflies,
repairNet as repairNetApi,
killWasp as killWaspApi,
buyRepellent as buyRepellentApi,
reportNetTorn as reportTearApi,
reportRobbery as reportRobberyApi,
defeatThief as defeatThiefApi,
reportCaterpillarAte as caterpillarAteApi,
defeatCaterpillar as defeatCaterpillarApi,
reportMothHit as mothHitApi,
reportInspectionFail as inspectionFailApi,
buyPremiumNet as buyPremiumApi,
buyWaspSpray as waspSprayApi,
buyInsurance as buyInsuranceApi,
buyUpgrade as buyUpgradeApi,
} from '../../../types';
/** Druhy trvalých vylepšení v obchodě. */
export type UpgradeItem = 'net' | 'scarecrow' | 'reinforced';
/** Výsledek nákupu: úspěch / nedostatek mincí / jiná chyba (např. starý server). */
export type BuyResult = 'ok' | 'poor' | 'error';
/** Jak dlouho se čeká, než se nasbíraná dávka úlovků odešle na server. */
const FLUSH_DEBOUNCE_MS = 1500;
/** Jak často se přenačte stav ze serveru (kvůli přibývajícím škůdcům). */
const POLL_INTERVAL_MS = 30_000;
/** Událost odměny hlášená scéně po úspěšném odeslání dávky. */
export interface RewardEvent {
coinsAwarded: number;
dailyBonusApplied: boolean;
leveledUp: boolean;
premiumUnlocked: boolean;
newLevel: number;
newTitle: string;
}
/**
* Hook spravující serverovou perzistenci chytání motýlků: načtení stavu, dávkové
* odesílání úlovků, hubení vos, plašič ptáků, protržení/opravu síťky, zloděje a
* periodické přenačítání (růst škůdců). Server je zdroj pravdy; počítadlo se
* zobrazuje jako serverová hodnota + zatím neodeslané úlovky.
*/
export function useButterflyStats(onReward?: (e: RewardEvent) => void) {
const [stats, setStats] = useState<ButterflyStats | undefined>();
/** Optimisticky zobrazený přírůstek chycených, který ještě neodešel na server. */
const [pendingShown, setPendingShown] = useState(0);
/** Živá hodnota mincí pro scénu (aby nečetla zastaralý stav přes closure). */
const coinsRef = useRef(0);
const pending = useRef({ normal: 0, golden: 0 });
const flushTimer = useRef<number | null>(null);
const onRewardRef = useRef(onReward);
onRewardRef.current = onReward;
const applyStats = useCallback((incoming: ButterflyStats) => {
coinsRef.current = incoming.coins;
setStats(incoming);
}, []);
const flush = useCallback(async () => {
if (flushTimer.current) {
clearTimeout(flushTimer.current);
flushTimer.current = null;
}
const batch = pending.current;
const batchTotal = batch.normal + batch.golden;
if (batchTotal === 0) return;
pending.current = { normal: 0, golden: 0 };
try {
const res = await catchButterflies({ body: { normal: batch.normal, golden: batch.golden } });
if (res.data) {
applyStats(res.data.stats);
// Odeslané úlovky už jsou v serverové hodnotě → sundáme je z optimistického přírůstku
setPendingShown(p => Math.max(0, p - batchTotal));
onRewardRef.current?.({
coinsAwarded: res.data.coinsAwarded,
dailyBonusApplied: res.data.dailyBonusApplied,
leveledUp: res.data.leveledUp,
premiumUnlocked: res.data.premiumUnlocked,
newLevel: res.data.stats.level,
newTitle: res.data.stats.title,
});
} else {
setPendingShown(p => Math.max(0, p - batchTotal));
}
} catch {
// Při chybě vrátíme dávku zpět (přírůstek necháme zobrazený)
pending.current.normal += batch.normal;
pending.current.golden += batch.golden;
}
}, [applyStats]);
/** Nahlásí chycení jednoho motýla (optimisticky + naplánuje odeslání dávky). */
const reportCatch = useCallback((golden: boolean) => {
if (golden) pending.current.golden += 1;
else pending.current.normal += 1;
setPendingShown(p => p + 1);
if (!flushTimer.current) {
flushTimer.current = window.setTimeout(() => { void flush(); }, FLUSH_DEBOUNCE_MS);
}
}, [flush]);
const repair = useCallback(async (): Promise<boolean> => {
try {
const res = await repairNetApi();
if (res.data) { applyStats(res.data); return true; }
return false;
} catch { return false; }
}, [applyStats]);
const killWasp = useCallback(async () => {
try {
const res = await killWaspApi();
if (res.data) applyStats(res.data);
} catch { /* ignore */ }
}, [applyStats]);
const buyRepellent = useCallback(async (): Promise<BuyResult> => {
try {
const res = await buyRepellentApi();
if (res.data) { applyStats(res.data); return 'ok'; }
return res.response?.status === 402 ? 'poor' : 'error';
} catch { return 'error'; }
}, [applyStats]);
const reportTear = useCallback(async () => {
try {
const res = await reportTearApi();
if (res.data) applyStats(res.data);
} catch { /* ignore */ }
}, [applyStats]);
/** Zloděj se dostal k penězům – server strhne podíl mincí. */
const reportRobbery = useCallback(async () => {
try {
const res = await reportRobberyApi();
if (res.data) applyStats(res.data);
} catch { /* ignore */ }
}, [applyStats]);
/** Hráč porazil zloděje – odměna a statistika. */
const defeatThief = useCallback(async () => {
try {
const res = await defeatThiefApi();
if (res.data) applyStats(res.data);
} catch { /* ignore */ }
}, [applyStats]);
/** Housenka se dostala k úlovkům – server sníží počet chycených. */
const caterpillarAte = useCallback(async () => {
try {
const res = await caterpillarAteApi();
if (res.data) applyStats(res.data);
} catch { /* ignore */ }
}, [applyStats]);
/** Hráč omylem chytil černou můru – server sníží úlovky. */
const mothHit = useCallback(async () => {
try {
const res = await mothHitApi();
if (res.data) applyStats(res.data);
} catch { /* ignore */ }
}, [applyStats]);
/** Selhání anti-bot inspekce – server počítá selhání a případně udělí ban. */
const reportInspectionFail = useCallback(async () => {
try {
const res = await inspectionFailApi();
if (res.data) applyStats(res.data);
} catch { /* ignore */ }
}, [applyStats]);
/** Vyhodnotí výsledek nákupu z odpovědi (402 = nedostatek mincí, jinak chyba). */
const buyResult = useCallback((res: { data?: unknown; response?: Response }): BuyResult => {
if (res.data) { applyStats(res.data as ButterflyStats); return 'ok'; }
return res.response?.status === 402 ? 'poor' : 'error';
}, [applyStats]);
/** Obchod: koupě spotřebního předmětu / vylepšení. */
const buyPremium = useCallback(async (): Promise<BuyResult> => {
try { return buyResult(await buyPremiumApi()); } catch { return 'error'; }
}, [buyResult]);
const waspSpray = useCallback(async (): Promise<BuyResult> => {
try { return buyResult(await waspSprayApi()); } catch { return 'error'; }
}, [buyResult]);
const buyInsurance = useCallback(async (): Promise<BuyResult> => {
try { return buyResult(await buyInsuranceApi()); } catch { return 'error'; }
}, [buyResult]);
const buyUpgrade = useCallback(async (item: UpgradeItem): Promise<BuyResult> => {
try { return buyResult(await buyUpgradeApi({ body: { item } })); } catch { return 'error'; }
}, [buyResult]);
/** Hráč porazil housenku – odměna a statistika. */
const defeatCaterpillar = useCallback(async () => {
try {
const res = await defeatCaterpillarApi();
if (res.data) applyStats(res.data);
} catch { /* ignore */ }
}, [applyStats]);
const refresh = useCallback(async () => {
try {
const res = await getButterflyStats();
if (res.data) applyStats(res.data);
} catch { /* ignore */ }
}, [applyStats]);
// Načtení stavu
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await getButterflyStats();
if (!cancelled && res.data) applyStats(res.data);
} catch { /* server nedostupný – hra jede dál bez perzistence */ }
})();
return () => { cancelled = true; };
}, [applyStats]);
// Periodické přenačítání (růst škůdců) + při návratu na záložku
useEffect(() => {
const id = window.setInterval(() => { void refresh(); }, POLL_INTERVAL_MS);
const onVisible = () => { if (document.visibilityState === 'visible') void refresh(); };
document.addEventListener('visibilitychange', onVisible);
return () => {
window.clearInterval(id);
document.removeEventListener('visibilitychange', onVisible);
};
}, [refresh]);
// Odeslání rozdělané dávky při skrytí/opuštění stránky a při odmontování
useEffect(() => {
const onVisibility = () => { if (document.visibilityState === 'hidden') void flush(); };
const onPageHide = () => { void flush(); };
document.addEventListener('visibilitychange', onVisibility);
window.addEventListener('pagehide', onPageHide);
return () => {
document.removeEventListener('visibilitychange', onVisibility);
window.removeEventListener('pagehide', onPageHide);
void flush();
};
}, [flush]);
const displayCaught = (stats?.caught ?? 0) + pendingShown;
return {
stats, displayCaught, coinsRef, reportCatch, repair, killWasp, buyRepellent,
reportTear, reportRobbery, defeatThief, caterpillarAte, defeatCaterpillar,
mothHit, reportInspectionFail, buyPremium, waspSpray, buyInsurance, buyUpgrade,
};
}
+12
View File
@@ -0,0 +1,12 @@
import { useSyncExternalStore } from "react";
import { getAppConfig, getGroupLimits, subscribeAppConfig } from "../utils/appConfig";
/** Aktuální limity skupinových objednávek (překreslí se po načtení konfigurace ze serveru). */
export function useGroupLimits() {
return useSyncExternalStore(subscribeAppConfig, getGroupLimits);
}
/** Celá runtime konfigurace ze serveru (způsob přihlášení, zapnuté funkce, limity). */
export function useAppConfig() {
return useSyncExternalStore(subscribeAppConfig, getAppConfig);
}
+149
View File
@@ -0,0 +1,149 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { openDrawingGame } from "../components/DrawingGame";
import { GameId, gameIcon } from "../games/themeIcons";
import { useAppConfig } from "./useGroupLimits";
type Minigame = {
id: GameId,
/** Kód, který se napíše po sérii kliknutí na logo. */
code: string,
name: string,
description: string,
open: () => void,
};
/**
* Skryté minihry, které zůstaly v Luncheru (ostatní jsou v samostatné aplikaci Lobby).
* Novou hru stačí přidat sem.
*/
const MINIGAMES: Minigame[] = [
{
id: 'drawing',
code: 'kresleni',
name: 'Kreslení',
description: 'Jeden kreslí jídlo z dnešních menu, ostatní hádají.',
open: openDrawingGame,
},
];
const HELP_CODE = 'help';
const SECRET_CLICKS = 3;
/** Max. pauza mezi kliknutími na logo, aby se počítala do série (ms). */
const SECRET_CLICK_GAP_MS = 400;
/** Jak dlouho po sérii kliknutí (případně po návratu z Lobby do této záložky) lze napsat kód (ms). */
const SECRET_ARM_MS = 8000;
function printHelp(lobbyUrl?: string) {
console.log('%c🎮 Luncher minihry', 'font-size: 16px; font-weight: bold');
if (lobbyUrl) {
console.log(`Klikněte 3× na logo — otevře se Lobby s dalšími minihrami (${lobbyUrl}).`);
console.log('Po návratu do Luncheru lze napsat kód hry:');
} else {
console.log('Klikněte 3× na logo a napište kód hry:');
}
console.table(MINIGAMES.map(g => ({ kód: g.code, hra: `${gameIcon(g.id)} ${g.name}`, popis: g.description })));
}
/** Malá písmena bez diakritiky — česká klávesnice na mobilu může "kresleni" opravit na "Kreslení". */
const normalizeCode = (text: string) => text.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim();
/**
* Skrytý vstup do miniher: 3× rychle kliknout na logo. Je-li nastavena adresa Lobby, otevře se v nové záložce.
* Poté lze (po návratu do Luncheru) napsat kód hry, která zůstala v Luncheru, případně "help" pro výpis do konzole prohlížeče.
* Jedno či dvě kliknutí se chovají jako běžný odkaz (jen s malým zpožděním).
* Bez Lobby se na dotykových zařízeních kód píše do skrytého pole (`inputRef`), které otevře softwarovou klávesnici.
* Po otevření Lobby klávesnici po návratu do záložky otevřít nelze (chce gesto uživatele), na mobilu tak kód zadat nepůjde.
*/
export function useMinigameSecret(href: string) {
const { lobbyUrl } = useAppConfig();
const [armed, setArmed] = useState(false);
const clicks = useRef(0);
const timer = useRef<number | undefined>(undefined);
const inputRef = useRef<HTMLInputElement>(null);
const onLogoClick = useCallback((e: React.MouseEvent<HTMLElement>) => {
// Otevření v novém tabu apod. necháme prohlížeči
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
e.preventDefault();
window.clearTimeout(timer.current);
clicks.current++;
if (clicks.current >= SECRET_CLICKS) {
clicks.current = 0;
if (lobbyUrl) {
// Otevření musí proběhnout přímo v obsluze kliknutí, jinak ho prohlížeč zablokuje jako vyskakovací okno
window.open(lobbyUrl, '_blank', 'noopener');
} else {
// Fokus musí proběhnout přímo v obsluze kliknutí, jinak iOS klávesnici neotevře
const input = inputRef.current;
if (input) {
input.value = '';
input.focus({ preventScroll: true });
}
}
setArmed(true);
return;
}
timer.current = window.setTimeout(() => {
clicks.current = 0;
window.location.assign(href);
}, SECRET_CLICK_GAP_MS);
}, [href, lobbyUrl]);
useEffect(() => {
if (!armed) return;
const input = inputRef.current;
const maxLength = Math.max(HELP_CODE.length, ...MINIGAMES.map(g => g.code.length));
let typed = '';
const check = (text: string) => {
if (text.endsWith(HELP_CODE)) {
setArmed(false);
printHelp(lobbyUrl);
return;
}
const game = MINIGAMES.find(g => text.endsWith(g.code));
if (game) {
setArmed(false);
game.open();
}
};
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
if (target?.closest('input, textarea, select, [contenteditable="true"]') || e.key.length !== 1) return;
typed = (typed + normalizeCode(e.key)).slice(-maxLength);
check(typed);
};
// Softwarová klávesnice (Android) posílá v keydown jen "Unidentified", proto se čte celý text pole
const onInput = () => check(normalizeCode(input?.value ?? ''));
// Limit běží, jen když je záložka vidět — po odchodu do Lobby se zastaví a po návratu začne znovu
let disarm: number | undefined;
const startDisarm = () => {
window.clearTimeout(disarm);
disarm = window.setTimeout(() => setArmed(false), SECRET_ARM_MS);
};
const onVisibility = () => {
if (document.visibilityState === 'visible') {
startDisarm();
} else {
window.clearTimeout(disarm);
}
};
startDisarm();
window.addEventListener('keydown', onKey);
input?.addEventListener('input', onInput);
document.addEventListener('visibilitychange', onVisibility);
return () => {
window.clearTimeout(disarm);
window.removeEventListener('keydown', onKey);
input?.removeEventListener('input', onInput);
document.removeEventListener('visibilitychange', onVisibility);
if (input) {
input.value = '';
input.blur();
}
};
}, [armed, lobbyUrl]);
useEffect(() => () => window.clearTimeout(timer.current), []);
return { armed, onLogoClick, inputRef };
}
+5 -4
View File
@@ -27,9 +27,10 @@ async function pushApiFetch(path: string, options: RequestInit = {}): Promise<Re
/** /**
* Zaregistruje service worker, přihlásí se k push notifikacím * Zaregistruje service worker, přihlásí se k push notifikacím
* a odešle subscription na server. * a odešle subscription na server. `reminderTime` je volitelný — bez něj
* uživatel odebírá jen ostatní push notifikace (např. doručení objednávky).
*/ */
export async function subscribeToPush(reminderTime: string): Promise<boolean> { export async function subscribeToPush(reminderTime?: string): Promise<boolean> {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) { if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
console.warn('Push notifikace nejsou v tomto prohlížeči podporovány'); console.warn('Push notifikace nejsou v tomto prohlížeči podporovány');
return false; return false;
@@ -75,7 +76,7 @@ export async function subscribeToPush(reminderTime: string): Promise<boolean> {
return false; return false;
} }
console.log('Push notifikace: úspěšně přihlášeno k připomínkám v', reminderTime); console.log('Push notifikace: úspěšně přihlášeno', reminderTime ? `(připomínka v ${reminderTime})` : '(bez připomínky oběda)');
return true; return true;
} catch (error) { } catch (error) {
console.error('Push notifikace: chyba při registraci', error); console.error('Push notifikace: chyba při registraci', error);
@@ -101,7 +102,7 @@ export async function unsubscribeFromPush(): Promise<void> {
} }
await pushApiFetch('/unsubscribe', { method: 'POST' }); await pushApiFetch('/unsubscribe', { method: 'POST' });
console.log('Push notifikace: úspěšně odhlášeno z připomínek'); console.log('Push notifikace: úspěšně odhlášeno');
} catch (error) { } catch (error) {
console.error('Push notifikace: chyba při odhlášení', error); console.error('Push notifikace: chyba při odhlášení', error);
} }
+38 -1
View File
@@ -6,20 +6,49 @@ import './index.css';
import AppRoutes from './AppRoutes'; import AppRoutes from './AppRoutes';
import { BrowserRouter } from 'react-router'; import { BrowserRouter } from 'react-router';
import { client } from '../../types/gen/client.gen'; import { client } from '../../types/gen/client.gen';
import { getConfig } from '../../types/gen/sdk.gen';
import { getToken } from './Utils'; import { getToken } from './Utils';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import * as Sentry from '@sentry/react';
import { setAppConfig } from './utils/appConfig';
client.setConfig({ client.setConfig({
auth: () => getToken(), auth: () => getToken(),
baseUrl: '/api', // openapi-ts si to z nějakého důvodu neumí převzít z api.yml baseUrl: '/api', // openapi-ts si to z nějakého důvodu neumí převzít z api.yml
}); });
// Sentry se inicializuje až podle runtime konfigurace ze serveru (bez DSN zůstává vypnuté).
// Klient je statický build, takže DSN ani limity nejde zapéct při buildu — server je zná z env.
getConfig().then(({ data }) => {
setAppConfig({
loaded: true,
trustedHeaders: data?.auth?.trustedHeaders,
storeAdmin: data?.features?.storeAdmin,
lobbyUrl: data?.links?.lobbyUrl ?? undefined,
groupLimits: data?.limits?.group,
});
if (data?.sentry.dsn) {
Sentry.init({
dsn: data.sentry.dsn,
environment: data.sentry.environment,
});
}
}).catch(() => {
// Config endpoint nedostupný — běžíme bez Sentry, s výchozími limity a původním chováním přihlášení
setAppConfig({ loaded: true });
});
// Interceptor na vyhození toasteru při chybě // Interceptor na vyhození toasteru při chybě
client.interceptors.response.use(async response => { 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 // 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")) { if (!response.ok && !response.url.includes("/login")) {
const json = await response.json(); // Čteme z klonu, aby tělo odpovědi zůstalo k dispozici i generovanému SDK
const json = await response.clone().json();
toast.error(json.error, { theme: "colored" }); toast.error(json.error, { theme: "colored" });
// Serverové chyby hlásíme do Sentry; 4xx jsou očekávané (chyby uživatele)
if (response.status >= 500) {
Sentry.captureMessage(`API ${response.status}: ${new URL(response.url).pathname} — ${json.error}`, 'error');
}
} }
return response; return response;
}); });
@@ -29,10 +58,18 @@ const root = ReactDOM.createRoot(
); );
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<Sentry.ErrorBoundary fallback={
<div className="text-center p-5">
<h4>Něco se pokazilo 😕</h4>
<p>Chyba byla nahlášena. Zkuste stránku načíst znovu.</p>
<button className="btn btn-primary" onClick={() => window.location.reload()}>Načíst znovu</button>
</div>
}>
<BrowserRouter> <BrowserRouter>
<ProvideAuth> <ProvideAuth>
<AppRoutes /> <AppRoutes />
</ProvideAuth> </ProvideAuth>
</BrowserRouter> </BrowserRouter>
</Sentry.ErrorBoundary>
</React.StrictMode> </React.StrictMode>
); );
+253 -51
View File
@@ -2,15 +2,19 @@ import { useCallback, useContext, useEffect, useRef, useState } from 'react';
import { Alert, Badge, Button, Card, Form, Modal, OverlayTrigger, Table, Tooltip } from 'react-bootstrap'; import { Alert, Badge, Button, Card, Form, Modal, OverlayTrigger, Table, Tooltip } from 'react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTrashCan } from '@fortawesome/free-regular-svg-icons'; import { faTrashCan } from '@fortawesome/free-regular-svg-icons';
import { faBasketShopping, faChevronLeft, faChevronRight, faCircleCheck, faClockRotateLeft, faGear, faLock, faLockOpen, faPen, faSearch, faUserPlus } from '@fortawesome/free-solid-svg-icons'; import { faBasketShopping, faChevronLeft, faChevronRight, faCircleCheck, faClockRotateLeft, faGear, faLock, faLockOpen, faPen, faSearch, faTruck, faUserPlus } from '@fortawesome/free-solid-svg-icons';
import DatePicker, { registerLocale } from 'react-datepicker'; import DatePicker, { registerLocale } from 'react-datepicker';
import { cs } from 'date-fns/locale'; import { cs } from 'date-fns/locale';
import 'react-datepicker/dist/react-datepicker.css'; import 'react-datepicker/dist/react-datepicker.css';
import { import {
ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember, PendingQr, ClientData, GroupState, LunchChoice, MealSlot, OrderGroup, OrderGroupMember, PendingQr, TrackingProvider,
getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, setBoltTracking, getOrderDates, StoreMenuDish, getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, setTracking, getOrderDates, getStoreMenu, addChoice,
} from '../../../types'; } from '../../../types';
import MenuHintCell from '../components/MenuHintCell';
import EaterBadge from '../components/EaterBadge';
import { addItemLimitError, addNoteItem, countNoteItems, removeNoteItem } from '../utils/menuMatch';
import { computeFeeShare, computeMemberTotal, countActiveMembers } from '../utils/groupFees'; import { computeFeeShare, computeMemberTotal, countActiveMembers } from '../utils/groupFees';
import { getStoreUrls, isHttpUrl, storeUrlLabel } from '../utils/storeUrls';
import { EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from '../context/socket'; import { EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from '../context/socket';
import { useAuth } from '../context/auth'; import { useAuth } from '../context/auth';
import { useSettings } from '../context/settings'; import { useSettings } from '../context/settings';
@@ -23,27 +27,60 @@ import StoreAdminModal from '../components/modals/StoreAdminModal';
import PayForGroupModal from '../components/modals/PayForGroupModal'; import PayForGroupModal from '../components/modals/PayForGroupModal';
import EditGroupFeesModal from '../components/modals/EditGroupFeesModal'; import EditGroupFeesModal from '../components/modals/EditGroupFeesModal';
import BoltSimulationModal from '../components/modals/BoltSimulationModal'; import BoltSimulationModal from '../components/modals/BoltSimulationModal';
import GenerateMockOrdersModal from '../components/modals/GenerateMockOrdersModal';
import PendingPayments from '../components/PendingPayments'; import PendingPayments from '../components/PendingPayments';
import BoltOrderProgress from '../components/BoltOrderProgress'; import OrderProgress, { PROVIDER_LABEL } from '../components/OrderProgress';
import { isArrowNavigationBlocked } from '../utils/arrowNavigation';
const SLOT = MealSlot.EXTRA; const SLOT = MealSlot.EXTRA;
const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/; const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/;
const BOLT_SHARE_URL_PREFIX = 'https://food.bolt.eu/sharedActiveOrder/';
const BOLT_TOKEN_REGEX = /^[0-9a-f]{64}$/i;
const IS_DEV = process.env.NODE_ENV === 'development'; const IS_DEV = process.env.NODE_ENV === 'development';
/** Vytáhne sledovací token ze sdílecí URL Bolt Food, nebo přijme samotný token. Null = neplatný vstup. */ /** Předpony sdílecích odkazů jednotlivých služeb (pro sestavení odkazu z uloženého kódu). */
function extractBoltToken(input: string): string | null { const TRACKING_URL_PREFIX: Record<TrackingProvider, string> = {
bolt: 'https://food.bolt.eu/sharedActiveOrder/',
wolt: 'https://track.wolt.com/',
};
/** Tvar kódu sledování u jednotlivých služeb — musí odpovídat serveru (trackingProviders.ts). */
const TRACKING_CODE_REGEX: Record<TrackingProvider, RegExp> = {
bolt: /^[0-9a-f]{64}$/i,
wolt: /^[A-Za-z0-9_-]{20,32}$/,
};
const TRACKING_HOST: Record<TrackingProvider, string> = {
bolt: 'bolt.eu',
wolt: 'wolt.com',
};
/** Sestaví odkaz na sledování objednávky ze služby a kódu. */
function trackingUrlFor(provider: TrackingProvider, code: string): string {
return `${TRACKING_URL_PREFIX[provider] ?? ''}${code}`;
}
/**
* Rozpozná rozvozovou službu podle odkazu na sledování (nebo podle samotného kódu)
* a vytáhne z něj kód sledování. Null = vstup nepatří žádné podporované službě.
*/
function extractTracking(input: string): { provider: TrackingProvider; code: string } | null {
const trimmed = input.trim(); const trimmed = input.trim();
if (!trimmed) return null; if (!trimmed) return null;
if (BOLT_TOKEN_REGEX.test(trimmed)) return trimmed; let hostname = '';
let lastSegment = '';
try { try {
const segments = new URL(trimmed).pathname.split('/').filter(Boolean); const url = new URL(trimmed);
const last = segments[segments.length - 1]; hostname = url.hostname.toLowerCase();
return last && BOLT_TOKEN_REGEX.test(last) ? last : null; const segments = url.pathname.split('/').filter(Boolean);
lastSegment = segments[segments.length - 1] ?? '';
} catch { } catch {
return null; // Vstup není URL — zkusíme ho vzít jako samotný kód
} }
for (const provider of Object.values(TrackingProvider)) {
const regex = TRACKING_CODE_REGEX[provider];
if (!hostname && regex.test(trimmed)) return { provider, code: trimmed };
const host = TRACKING_HOST[provider];
const hostMatches = hostname === host || hostname.endsWith(`.${host}`);
if (hostMatches && regex.test(lastSegment)) return { provider, code: lastSegment };
}
return null;
} }
/** Zkrátí dlouhý odkaz pro zobrazení v řádku (zachová začátek i konec). */ /** Zkrátí dlouhý odkaz pro zobrazení v řádku (zachová začátek i konec). */
@@ -69,11 +106,17 @@ function isoToDate(iso?: string): Date | null {
return iso ? new Date(`${iso}T00:00:00`) : null; return iso ? new Date(`${iso}T00:00:00`) : null;
} }
/** Stavy, ve kterých je objednávka již odeslána do podniku (objednáno/doručeno) — skupina se nesmí upravovat. */
function isSubmittedState(state: GroupState): boolean {
return state === GroupState.ORDERED || state === GroupState.DELIVERED;
}
function stateBadge(state: GroupState) { function stateBadge(state: GroupState) {
const map: Record<GroupState, { bg: string; label: string }> = { const map: Record<GroupState, { bg: string; label: string }> = {
[GroupState.OPEN]: { bg: 'success', label: 'Otevřeno' }, [GroupState.OPEN]: { bg: 'success', label: 'Otevřeno' },
[GroupState.LOCKED]: { bg: 'warning', label: 'Uzamčeno' }, [GroupState.LOCKED]: { bg: 'warning', label: 'Uzamčeno' },
[GroupState.ORDERED]: { bg: 'secondary', label: 'Objednáno' }, [GroupState.ORDERED]: { bg: 'secondary', label: 'Objednáno' },
[GroupState.DELIVERED]: { bg: 'info', label: 'Doručeno' },
}; };
const { bg, label } = map[state] ?? { bg: 'light', label: state }; const { bg, label } = map[state] ?? { bg: 'light', label: state };
return <Badge bg={bg}>{label}</Badge>; return <Badge bg={bg}>{label}</Badge>;
@@ -94,17 +137,22 @@ export default function OrderGroupsPage() {
// ISO data dnů, ve kterých existuje aspoň jedna objednávka (pro zvýraznění v date pickeru) // ISO data dnů, ve kterých existuje aspoň jedna objednávka (pro zvýraznění v date pickeru)
const [orderDates, setOrderDates] = useState<string[]>([]); const [orderDates, setOrderDates] = useState<string[]>([]);
const [newGroupName, setNewGroupName] = useState(''); const [newGroupName, setNewGroupName] = useState('');
// Vybraná URL na nabídku — podnik může být dostupný přes více dovozových služeb
const [newGroupUrl, setNewGroupUrl] = useState('');
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [adminModalOpen, setAdminModalOpen] = useState(false); const [adminModalOpen, setAdminModalOpen] = useState(false);
const [editAmounts, setEditAmounts] = useState<Record<string, string>>({}); const [editAmounts, setEditAmounts] = useState<Record<string, string>>({});
const [editNotes, setEditNotes] = useState<Record<string, string>>({}); const [editNotes, setEditNotes] = useState<Record<string, string>>({});
const [editSurcharges, setEditSurcharges] = useState<Record<string, { text: string; amount: string }>>({}); const [editSurcharges, setEditSurcharges] = useState<Record<string, { text: string; amount: string }>>({});
const [editTimes, setEditTimes] = useState<Record<string, { orderedAt: string; deliveryAt: string; boltUrl: string }>>({}); const [editTimes, setEditTimes] = useState<Record<string, { orderedAt: string; deliveryAt: string; trackingUrl: string }>>({});
const [payModal, setPayModal] = useState<OrderGroup | null>(null); const [payModal, setPayModal] = useState<OrderGroup | null>(null);
const [feesModal, setFeesModal] = useState<OrderGroup | null>(null); const [feesModal, setFeesModal] = useState<OrderGroup | null>(null);
const [boltSimModal, setBoltSimModal] = useState<OrderGroup | null>(null); const [boltSimModal, setBoltSimModal] = useState<OrderGroup | null>(null);
const [mockOrdersModalOpen, setMockOrdersModalOpen] = useState(false);
const [confirmOrderGroup, setConfirmOrderGroup] = useState<OrderGroup | null>(null); const [confirmOrderGroup, setConfirmOrderGroup] = useState<OrderGroup | null>(null);
const [pageError, setPageError] = useState<string | null>(null); const [pageError, setPageError] = useState<string | null>(null);
// Stažené nabídky podniků (dle názvu obchodu) pro sloupec „Z nabídky"
const [storeMenus, setStoreMenus] = useState<Record<string, StoreMenuDish[] | null>>({});
const fetchData = async (date?: string) => { const fetchData = async (date?: string) => {
try { try {
@@ -177,9 +225,8 @@ export default function OrderGroupsPage() {
// Navigace mezi dny pomocí klávesových šipek (←/→), obdobně jako na hlavní stránce // Navigace mezi dny pomocí klávesových šipek (←/→), obdobně jako na hlavní stránce
const handleKeyDown = useCallback((e: KeyboardEvent) => { const handleKeyDown = useCallback((e: KeyboardEvent) => {
// Ignorujeme, pokud uživatel právě píše do formulářového pole // Ignorujeme, pokud uživatel právě píše do formulářového pole nebo je otevřený dialog (např. minihra)
const tag = (e.target as HTMLElement)?.tagName; if (isArrowNavigationBlocked(e)) return;
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return;
const currentIso = data?.isoDate; const currentIso = data?.isoDate;
if (!currentIso) return; if (!currentIso) return;
if (e.keyCode === 37) { if (e.keyCode === 37) {
@@ -214,16 +261,43 @@ export default function OrderGroupsPage() {
return true; return true;
}; };
/**
* Pokud to má uživatel zapnuté v nastavení, přepne jeho dnešní volbu stravování na "Budu objednávat".
* Volbu nemění, pokud již objednává, nebo je přihlášen na Pizza day (změnou by přišel o objednané pizzy).
*/
const switchChoiceToOrdering = async () => {
if (!settings?.autoOrderChoice || !auth?.login) return;
const lunch = await getData({ query: { slot: MealSlot.OBED } });
const choices = lunch.data?.choices;
if (!choices) return;
if (auth.login in (choices[LunchChoice.OBJEDNAVAM] ?? {}) || auth.login in (choices[LunchChoice.PIZZA] ?? {})) return;
const result = await addChoice({ body: { locationKey: LunchChoice.OBJEDNAVAM, slot: MealSlot.OBED } });
if (result.error) {
setPageError(`Nepodařilo se přepnout volbu stravování: ${(result.error as any).error || 'Nastala chyba'}`);
}
};
const handleCreate = async () => { const handleCreate = async () => {
if (!newGroupName || !auth?.login) return; if (!newGroupName || !auth?.login) return;
setCreating(true); setCreating(true);
const ok = await refresh(() => createGroup({ body: { name: newGroupName } })); // Pojistka, kdyby se seznam obchodů mezitím změnil (socket) — vezmeme první URL podniku
if (ok) setNewGroupName(''); const urls = getStoreUrls((data?.stores ?? []).find(s => s.name === newGroupName));
const url = urls.includes(newGroupUrl) ? newGroupUrl : urls[0];
const ok = await refresh(() => createGroup({ body: { name: newGroupName, url } }));
if (ok) {
setNewGroupName('');
setNewGroupUrl('');
await switchChoiceToOrdering();
}
setCreating(false); setCreating(false);
}; };
const handleJoin = (groupId: string) =>
refresh(() => addGroupMember({ body: { id: groupId } })); const handleJoin = async (groupId: string) => {
if (await refresh(() => addGroupMember({ body: { id: groupId } }))) {
await switchChoiceToOrdering();
}
};
const handleToggleLock = (group: OrderGroup) => { const handleToggleLock = (group: OrderGroup) => {
const next = group.state === GroupState.OPEN ? GroupState.LOCKED : GroupState.OPEN; const next = group.state === GroupState.OPEN ? GroupState.LOCKED : GroupState.OPEN;
@@ -238,6 +312,12 @@ export default function OrderGroupsPage() {
const handleRevertOrdered = (group: OrderGroup) => const handleRevertOrdered = (group: OrderGroup) =>
refresh(() => setGroupState({ body: { id: group.id, state: GroupState.LOCKED } })); refresh(() => setGroupState({ body: { id: group.id, state: GroupState.LOCKED } }));
const handleSetDelivered = (group: OrderGroup) =>
refresh(() => setGroupState({ body: { id: group.id, state: GroupState.DELIVERED } }));
const handleRevertDelivered = (group: OrderGroup) =>
refresh(() => setGroupState({ body: { id: group.id, state: GroupState.ORDERED } }));
const handleDelete = (groupId: string) => const handleDelete = (groupId: string) =>
refresh(() => deleteGroup({ body: { id: groupId } })); refresh(() => deleteGroup({ body: { id: groupId } }));
@@ -253,6 +333,49 @@ export default function OrderGroupsPage() {
if (ok) setEditAmounts(prev => { const next = { ...prev }; delete next[key]; return next; }); if (ok) setEditAmounts(prev => { const next = { ...prev }; delete next[key]; return next; });
}; };
// Nabídky podniků zobrazených skupin — jen pro obchody ze seznamu, každou jednou
const groupStoreNames = (data?.groups ?? [])
.map(g => (data?.stores ?? []).find(s => s.name.toLowerCase() === g.name.toLowerCase())?.name)
.filter((n): n is string => !!n);
const groupStoreKey = [...new Set(groupStoreNames)].sort().join('|');
useEffect(() => {
for (const name of groupStoreKey ? groupStoreKey.split('|') : []) {
if (name in storeMenus) continue;
setStoreMenus(prev => ({ ...prev, [name]: null }));
getStoreMenu({ query: { name } })
.then(r => setStoreMenus(prev => ({ ...prev, [name]: r.data?.dishes ?? [] })))
.catch(() => setStoreMenus(prev => ({ ...prev, [name]: [] })));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [groupStoreKey]);
const getGroupMenu = (group: OrderGroup): StoreMenuDish[] | undefined =>
Object.entries(storeMenus).find(([name]) => name.toLowerCase() === group.name.toLowerCase())?.[1] ?? undefined;
/** Výběr položky z nabídky — připojí ji k poznámce (opakovaný výběr zvýší počet „2x") a cenu přičte k částce. */
const handlePickDish = async (group: OrderGroup, login: string, member: OrderGroupMember, dish: StoreMenuDish) => {
const limitError = addItemLimitError(member.note, dish.name, getGroupMenu(group) ?? []);
if (limitError) {
setPageError(limitError);
return false;
}
const note = addNoteItem(member.note, dish.name, getGroupMenu(group) ?? []);
const amount = dish.price != null ? (member.amount ?? 0) + Math.round(dish.price * 100) : member.amount;
return refresh(() => updateGroupMember({ body: { id: group.id, login, note, ...(amount != null && { amount }) } }));
};
/** Odebere jeden kus položky z poznámky a odečte její cenu z částky (nikdy pod nulu). */
const handleRemoveDish = (group: OrderGroup, login: string, member: OrderGroupMember, index: number) => {
const { note, removedPrice } = removeNoteItem(member.note, index, getGroupMenu(group) ?? []);
const amount = removedPrice != null && member.amount != null
? Math.max(0, member.amount - Math.round(removedPrice * 100))
: member.amount;
return refresh(() => updateGroupMember({ body: { id: group.id, login, note, ...(amount != null && { amount }) } }));
};
const handleUseMenuPrice = (group: OrderGroup, login: string, priceKc: number) =>
refresh(() => updateGroupMember({ body: { id: group.id, login, amount: Math.round(priceKc * 100) } }));
const handleSaveNote = async (groupId: string, login: string) => { const handleSaveNote = async (groupId: string, login: string) => {
const key = `${groupId}:${login}`; const key = `${groupId}:${login}`;
const note = editNotes[key] ?? ''; const note = editNotes[key] ?? '';
@@ -276,7 +399,7 @@ export default function OrderGroupsPage() {
const handleSaveTimes = async (group: OrderGroup) => { const handleSaveTimes = async (group: OrderGroup) => {
const times = editTimes[group.id]; const times = editTimes[group.id];
if (!times) return; if (!times) return;
const { orderedAt, deliveryAt, boltUrl } = times; const { orderedAt, deliveryAt, trackingUrl } = times;
if (orderedAt && !TIME_REGEX.test(orderedAt)) { if (orderedAt && !TIME_REGEX.test(orderedAt)) {
setPageError('Čas objednání musí být ve formátu HH:MM'); setPageError('Čas objednání musí být ve formátu HH:MM');
return; return;
@@ -285,16 +408,16 @@ export default function OrderGroupsPage() {
setPageError('Čas doručení musí být ve formátu HH:MM'); setPageError('Čas doručení musí být ve formátu HH:MM');
return; return;
} }
// Bolt odkaz se odesílá jen při změně oproti aktuálnímu tokenu skupiny // Odkaz na sledování se odesílá jen při změně oproti aktuálnímu kódu skupiny
const boltToken = boltUrl.trim() ? extractBoltToken(boltUrl) : null; const tracking = trackingUrl.trim() ? extractTracking(trackingUrl) : null;
if (boltUrl.trim() && !boltToken) { if (trackingUrl.trim() && !tracking) {
setPageError('Neplatný odkaz Bolt (očekávána URL sdílení objednávky)'); setPageError('Neplatný odkaz pro sledování (očekáván odkaz Bolt Food nebo Wolt)');
return; return;
} }
const boltChanged = (boltToken ?? undefined) !== group.boltTrackingToken; const trackingChanged = tracking?.code !== group.trackingCode || tracking?.provider !== group.trackingProvider;
let ok = await refresh(() => updateGroupTimes({ body: { id: group.id, orderedAt, deliveryAt } })); let ok = await refresh(() => updateGroupTimes({ body: { id: group.id, orderedAt, deliveryAt } }));
if (ok && boltChanged) { if (ok && trackingChanged) {
ok = await refresh(() => setBoltTracking({ body: { id: group.id, shareUrl: boltUrl.trim() } })); ok = await refresh(() => setTracking({ body: { id: group.id, shareUrl: trackingUrl.trim() } }));
} }
if (ok) setEditTimes(prev => { const next = { ...prev }; delete next[group.id]; return next; }); if (ok) setEditTimes(prev => { const next = { ...prev }; delete next[group.id]; return next; });
}; };
@@ -303,7 +426,7 @@ export default function OrderGroupsPage() {
// Historie (jiný než aktuální den) je vždy read-only. // Historie (jiný než aktuální den) je vždy read-only.
const canEditMember = (group: OrderGroup, targetLogin: string) => { const canEditMember = (group: OrderGroup, targetLogin: string) => {
if (selectedDate) return false; if (selectedDate) return false;
if (group.state === GroupState.ORDERED) return false; if (isSubmittedState(group.state)) return false;
if (auth?.login === group.creatorLogin) return true; if (auth?.login === group.creatorLogin) return true;
if (auth?.login === targetLogin && group.state === GroupState.OPEN) return true; if (auth?.login === targetLogin && group.state === GroupState.OPEN) return true;
return false; return false;
@@ -311,7 +434,7 @@ export default function OrderGroupsPage() {
const canManageMembers = (group: OrderGroup) => { const canManageMembers = (group: OrderGroup) => {
if (selectedDate) return false; if (selectedDate) return false;
if (group.state === GroupState.ORDERED) return false; if (isSubmittedState(group.state)) return false;
if (auth?.login === group.creatorLogin) return true; if (auth?.login === group.creatorLogin) return true;
return group.state === GroupState.OPEN; return group.state === GroupState.OPEN;
}; };
@@ -328,6 +451,8 @@ export default function OrderGroupsPage() {
const stores = data.stores ?? []; const stores = data.stores ?? [];
const groups = data.groups ?? []; const groups = data.groups ?? [];
// URL na nabídku vybíraného podniku — výběr nabízíme, jen když jich je víc
const newStoreUrls = getStoreUrls(stores.find(s => s.name === newGroupName));
// Zobrazené datum a režim historie (vše read-only, pokud nejde o aktuální den) // Zobrazené datum a režim historie (vše read-only, pokud nejde o aktuální den)
const displayedIso = data.isoDate; const displayedIso = data.isoDate;
@@ -358,11 +483,18 @@ export default function OrderGroupsPage() {
<div className="wrapper"> <div className="wrapper">
<div className="d-flex align-items-center justify-content-between mb-1"> <div className="d-flex align-items-center justify-content-between mb-1">
<h1 className="title mb-0">Objednání</h1> <h1 className="title mb-0">Objednání</h1>
<div className="d-flex gap-2">
{IS_DEV && !isReadOnly && (
<Button variant="outline-warning" size="sm" onClick={() => setMockOrdersModalOpen(true)} title="Generovat mock objednávky (DEV)">
🔧 Mock objednávky
</Button>
)}
<Button variant="outline-primary" size="sm" onClick={() => setAdminModalOpen(true)} title="Správa obchodů"> <Button variant="outline-primary" size="sm" onClick={() => setAdminModalOpen(true)} title="Správa obchodů">
<FontAwesomeIcon icon={faGear} className="me-1" /> <FontAwesomeIcon icon={faGear} className="me-1" />
Obchody Obchody
</Button> </Button>
</div> </div>
</div>
<p style={{ color: 'var(--luncher-text-muted)' }}>Skupinové objednávky z obchodů a restaurací</p> <p style={{ color: 'var(--luncher-text-muted)' }}>Skupinové objednávky z obchodů a restaurací</p>
{/* Navigace mezi dny – šipky kolem výběru data (i klávesami ←/→) */} {/* Navigace mezi dny – šipky kolem výběru data (i klávesami ←/→) */}
@@ -425,12 +557,29 @@ export default function OrderGroupsPage() {
<div className="d-flex gap-2 align-items-center flex-wrap"> <div className="d-flex gap-2 align-items-center flex-wrap">
<Form.Select <Form.Select
value={newGroupName} value={newGroupName}
onChange={e => setNewGroupName(e.target.value)} onChange={e => {
const name = e.target.value;
setNewGroupName(name);
// Výběr nabídky se resetuje na první URL nově vybraného podniku
setNewGroupUrl(getStoreUrls(stores.find(s => s.name === name))[0] ?? '');
}}
style={{ maxWidth: 260 }} style={{ maxWidth: 260 }}
> >
<option value="">— vyberte obchod —</option> <option value="">— vyberte obchod —</option>
{stores.map(s => <option key={s.name} value={s.name}>{s.name}</option>)} {stores.map(s => <option key={s.name} value={s.name}>{s.name}</option>)}
</Form.Select> </Form.Select>
{newStoreUrls.length > 1 && (
<Form.Select
value={newStoreUrls.includes(newGroupUrl) ? newGroupUrl : newStoreUrls[0]}
onChange={e => setNewGroupUrl(e.target.value)}
style={{ maxWidth: 220 }}
title="Odkud se bude objednávat"
>
{newStoreUrls.map(url => (
<option key={url} value={url}>{storeUrlLabel(url)}</option>
))}
</Form.Select>
)}
<Button variant="primary" onClick={handleCreate} disabled={creating || !newGroupName}> <Button variant="primary" onClick={handleCreate} disabled={creating || !newGroupName}>
Vytvořit skupinu Vytvořit skupinu
</Button> </Button>
@@ -451,13 +600,19 @@ export default function OrderGroupsPage() {
const isCreator = login === group.creatorLogin; const isCreator = login === group.creatorLogin;
const isMember = login in group.members; const isMember = login in group.members;
const isOrdered = group.state === GroupState.ORDERED; const isOrdered = group.state === GroupState.ORDERED;
const isDelivered = group.state === GroupState.DELIVERED;
// Objednáno i Doručeno = objednávka je odeslána do podniku, skupina se už needituje
const isSubmitted = isOrdered || isDelivered;
const isLocked = group.state === GroupState.LOCKED; const isLocked = group.state === GroupState.LOCKED;
const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][]; const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][];
const editingTimes = group.id in editTimes; const editingTimes = group.id in editTimes;
// URL na nabídku podniku (pokud ji má dohledatelný obchod vyplněnou). // URL na nabídku podniku — přednostně ta, kterou zakladatel vybral při vytvoření
// skupiny. Skupiny z doby před výběrem URL ji nemají, pak zkusíme jedinou URL obchodu.
// Povolíme jen http(s), aby odkaz nemohl být zneužit (např. javascript:). // Povolíme jen http(s), aby odkaz nemohl být zneužit (např. javascript:).
const rawStoreUrl = stores.find(s => s.name === group.name)?.url; const groupStoreUrls = getStoreUrls(stores.find(s => s.name === group.name));
const storeUrl = rawStoreUrl && /^https?:\/\//i.test(rawStoreUrl) ? rawStoreUrl : undefined; const storeUrl = isHttpUrl(group.url)
? group.url
: (groupStoreUrls.length === 1 ? groupStoreUrls[0] : undefined);
const totalFees = (group.fees ?? 0) + (group.shipping ?? 0) + (group.tip ?? 0); const totalFees = (group.fees ?? 0) + (group.shipping ?? 0) + (group.tip ?? 0);
// Poplatky se dělí jen mezi aktivní strávníky (kdo si reálně něco objednal). // Poplatky se dělí jen mezi aktivní strávníky (kdo si reálně něco objednal).
@@ -484,7 +639,7 @@ export default function OrderGroupsPage() {
<small className="text-muted">zakladatel: {group.creatorLogin}</small> <small className="text-muted">zakladatel: {group.creatorLogin}</small>
</div> </div>
<div className="d-flex gap-2"> <div className="d-flex gap-2">
{!isReadOnly && isCreator && !isOrdered && ( {!isReadOnly && isCreator && !isSubmitted && (
<> <>
<Button variant="outline-info" size="sm" onClick={() => setFeesModal(group)} title="Upravit poplatky a slevu"> <Button variant="outline-info" size="sm" onClick={() => setFeesModal(group)} title="Upravit poplatky a slevu">
Poplatky Poplatky
@@ -502,20 +657,32 @@ export default function OrderGroupsPage() {
</Button> </Button>
</> </>
)} )}
{!isReadOnly && isCreator && isOrdered && ( {!isReadOnly && isCreator && isSubmitted && (
<> <>
{isOrdered && (
<Button variant="outline-success" size="sm" onClick={() => handleSetDelivered(group)} title="Označit objednávku jako doručenou">
<FontAwesomeIcon icon={faTruck} className="me-1" />
Doručeno
</Button>
)}
{settings?.bankAccount && settings?.holderName && !group.qrGenerated && ( {settings?.bankAccount && settings?.holderName && !group.qrGenerated && (
<Button variant="primary" size="sm" onClick={() => setPayModal(group)}> <Button variant="primary" size="sm" onClick={() => setPayModal(group)}>
<FontAwesomeIcon icon={faBasketShopping} className="me-1" /> <FontAwesomeIcon icon={faBasketShopping} className="me-1" />
Generovat QR Generovat QR
</Button> </Button>
)} )}
{isDelivered ? (
<Button variant="outline-warning" size="sm" onClick={() => handleRevertDelivered(group)} title="Vrátit na Objednáno">
<FontAwesomeIcon icon={faClockRotateLeft} />
</Button>
) : (
<Button variant="outline-warning" size="sm" onClick={() => handleRevertOrdered(group)} title="Vrátit na Uzamčeno (smaže QR kódy)"> <Button variant="outline-warning" size="sm" onClick={() => handleRevertOrdered(group)} title="Vrátit na Uzamčeno (smaže QR kódy)">
<FontAwesomeIcon icon={faLockOpen} /> <FontAwesomeIcon icon={faLockOpen} />
</Button> </Button>
)}
</> </>
)} )}
{!isReadOnly && !isMember && !isOrdered && !isLocked && ( {!isReadOnly && !isMember && !isSubmitted && !isLocked && (
<Button variant="outline-success" size="sm" onClick={() => handleJoin(group.id)}> <Button variant="outline-success" size="sm" onClick={() => handleJoin(group.id)}>
<FontAwesomeIcon icon={faUserPlus} className="me-1" /> <FontAwesomeIcon icon={faUserPlus} className="me-1" />
Přidat se Přidat se
@@ -531,6 +698,7 @@ export default function OrderGroupsPage() {
<th style={{ width: 180 }}>Částka (bez slev)</th> <th style={{ width: 180 }}>Částka (bez slev)</th>
<th style={{ width: 220 }}>Příplatek</th> <th style={{ width: 220 }}>Příplatek</th>
<th>Poznámka</th> <th>Poznámka</th>
{getGroupMenu(group)?.length ? <th style={{ width: 240 }}>Z nabídky</th> : null}
<th style={{ width: 160 }}>Celkem (s poplatky)</th> <th style={{ width: 160 }}>Celkem (s poplatky)</th>
<th style={{ width: 40 }}></th> <th style={{ width: 40 }}></th>
</tr> </tr>
@@ -547,6 +715,7 @@ export default function OrderGroupsPage() {
<tr key={memberLogin}> <tr key={memberLogin}>
<td> <td>
<span className="user-info"> <span className="user-info">
<EaterBadge items={countNoteItems(member.note, getGroupMenu(group) ?? [])} />
<strong>{memberLogin}</strong> <strong>{memberLogin}</strong>
{memberLogin === group.creatorLogin && ( {memberLogin === group.creatorLogin && (
<OverlayTrigger placement="top" overlay={<Tooltip>Zakladatel / objednávající</Tooltip>}> <OverlayTrigger placement="top" overlay={<Tooltip>Zakladatel / objednávající</Tooltip>}>
@@ -645,6 +814,19 @@ export default function OrderGroupsPage() {
</span> </span>
)} )}
</td> </td>
{getGroupMenu(group)?.length ? (
<td>
<MenuHintCell
menu={getGroupMenu(group)}
note={member.note}
amount={member.amount}
canEdit={canEdit}
onPick={dish => handlePickDish(group, memberLogin, member, dish)}
onUsePrice={price => handleUseMenuPrice(group, memberLogin, price)}
onRemoveItem={index => handleRemoveDish(group, memberLogin, member, index)}
/>
</td>
) : null}
<td className="text-end"> <td className="text-end">
<small className={memberTotal > 0 ? 'fw-bold' : 'text-muted'}> <small className={memberTotal > 0 ? 'fw-bold' : 'text-muted'}>
{memberTotal > 0 ? `${memberTotal / 100} Kč` : '—'} {memberTotal > 0 ? `${memberTotal / 100} Kč` : '—'}
@@ -676,7 +858,7 @@ export default function OrderGroupsPage() {
return groupTotal > 0 ? ( return groupTotal > 0 ? (
<tfoot> <tfoot>
<tr style={{ fontWeight: 700, borderTop: '2px solid var(--luncher-border)' }}> <tr style={{ fontWeight: 700, borderTop: '2px solid var(--luncher-border)' }}>
<td colSpan={4} className="text-end" style={{ fontSize: '0.9em' }}>Celkem za skupinu:</td> <td colSpan={getGroupMenu(group)?.length ? 5 : 4} className="text-end" style={{ fontSize: '0.9em' }}>Celkem za skupinu:</td>
<td className="text-end">{groupTotal / 100} Kč</td> <td className="text-end">{groupTotal / 100} Kč</td>
<td></td> <td></td>
</tr> </tr>
@@ -701,7 +883,7 @@ export default function OrderGroupsPage() {
)} )}
{/* Časy objednání a doručení */} {/* Časy objednání a doručení */}
{isOrdered && ( {isSubmitted && (
<div className="px-3 py-2 border-top"> <div className="px-3 py-2 border-top">
{!isReadOnly && isCreator && editingTimes ? ( {!isReadOnly && isCreator && editingTimes ? (
<div className="d-flex align-items-center gap-3 flex-wrap"> <div className="d-flex align-items-center gap-3 flex-wrap">
@@ -731,13 +913,13 @@ export default function OrderGroupsPage() {
/> />
</div> </div>
<div className="d-flex align-items-center gap-1"> <div className="d-flex align-items-center gap-1">
<small className="text-muted text-nowrap">Bolt odkaz pro sledování:</small> <small className="text-muted text-nowrap">Odkaz pro sledování (Bolt Food / Wolt):</small>
<Form.Control <Form.Control
type="text" type="text"
size="sm" size="sm"
placeholder={`${BOLT_SHARE_URL_PREFIX}…`} placeholder={`${TRACKING_URL_PREFIX.wolt}…`}
value={editTimes[group.id]?.boltUrl ?? ''} value={editTimes[group.id]?.trackingUrl ?? ''}
onChange={e => setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], boltUrl: e.target.value } }))} onChange={e => setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], trackingUrl: e.target.value } }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveTimes(group); }} onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveTimes(group); }}
style={{ width: 260 }} style={{ width: 260 }}
/> />
@@ -750,8 +932,10 @@ export default function OrderGroupsPage() {
{(() => { {(() => {
const canEdit = !isReadOnly && isCreator; const canEdit = !isReadOnly && isCreator;
// Aktivace editačního režimu – stejné chování jako tlačítko s tužkou // Aktivace editačního režimu – stejné chování jako tlačítko s tužkou
const startEdit = () => canEdit && setEditTimes(prev => ({ ...prev, [group.id]: { orderedAt: group.orderedAt ?? '', deliveryAt: group.deliveryAt ?? '', boltUrl: group.boltTrackingToken ? `${BOLT_SHARE_URL_PREFIX}${group.boltTrackingToken}` : '' } })); const trackingUrl = group.trackingProvider && group.trackingCode
const trackingUrl = group.boltTrackingToken ? `${BOLT_SHARE_URL_PREFIX}${group.boltTrackingToken}` : null; ? trackingUrlFor(group.trackingProvider, group.trackingCode)
: null;
const startEdit = () => canEdit && setEditTimes(prev => ({ ...prev, [group.id]: { orderedAt: group.orderedAt ?? '', deliveryAt: group.deliveryAt ?? '', trackingUrl: trackingUrl ?? '' } }));
return ( return (
<> <>
{canEdit && ( {canEdit && (
@@ -775,9 +959,9 @@ export default function OrderGroupsPage() {
onClick={startEdit} onClick={startEdit}
> >
Doručení v: <strong>{group.deliveryAt ?? '—'}</strong> Doručení v: <strong>{group.deliveryAt ?? '—'}</strong>
{group.boltTrackingToken && ( {group.trackingCode && group.trackingProvider && (
<OverlayTrigger overlay={<Tooltip>Čas doručení se aktualizuje automaticky z Bolt Food</Tooltip>}> <OverlayTrigger overlay={<Tooltip>Čas doručení se aktualizuje automaticky z {PROVIDER_LABEL[group.trackingProvider]}</Tooltip>}>
<Badge bg="success" className="ms-1">Bolt</Badge> <Badge bg="success" className="ms-1">{PROVIDER_LABEL[group.trackingProvider]}</Badge>
</OverlayTrigger> </OverlayTrigger>
)} )}
</small> </small>
@@ -802,9 +986,14 @@ export default function OrderGroupsPage() {
})()} })()}
</div> </div>
)} )}
{group.boltOrderState && ( {group.trackingOrderState && group.trackingProvider && (
<div className="mt-2"> <div className="mt-2">
<BoltOrderProgress state={group.boltOrderState} courierState={group.boltCourierState} tracking={!!group.boltTrackingToken} /> <OrderProgress
provider={group.trackingProvider}
state={group.trackingOrderState}
courierState={group.trackingCourierState}
tracking={!!group.trackingCode}
/>
</div> </div>
)} )}
{IS_DEV && ( {IS_DEV && (
@@ -885,6 +1074,19 @@ export default function OrderGroupsPage() {
}} }}
/> />
)} )}
{IS_DEV && (
<GenerateMockOrdersModal
isOpen={mockOrdersModalOpen}
onClose={() => {
setMockOrdersModalOpen(false);
// Data přijdou i websocketem, po založení skupiny se ale mění i dny s objednávkou
fetchData();
fetchOrderDates();
}}
hasGroups={groups.length > 0}
hasStores={stores.length > 0}
/>
)}
{IS_DEV && boltSimModal && ( {IS_DEV && boltSimModal && (
<BoltSimulationModal <BoltSimulationModal
isOpen={!!boltSimModal} isOpen={!!boltSimModal}
+41
View File
@@ -46,6 +46,47 @@
} }
} }
.export-panel {
margin-top: 32px;
padding: 24px;
width: 100%;
max-width: 560px;
background: var(--luncher-bg-card);
border: 1px solid var(--luncher-border-light);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow-sm);
text-align: center;
h3 {
font-size: 1.1rem;
font-weight: 600;
color: var(--luncher-text);
margin-bottom: 8px;
}
.export-hint {
font-size: 0.85rem;
color: var(--luncher-text-secondary);
margin-bottom: 16px;
}
.export-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 12px;
input {
max-width: 180px;
}
select {
max-width: 160px;
}
}
}
// Chart container // Chart container
.recharts-wrapper { .recharts-wrapper {
background: var(--luncher-bg-card); background: var(--luncher-bg-card);
+91 -5
View File
@@ -4,13 +4,15 @@ import Header from "../components/Header";
import { useAuth } from "../context/auth"; import { useAuth } from "../context/auth";
import Login from "../Login"; import Login from "../Login";
import { formatDate, getFirstWorkDayOfWeek, getHumanDate, getLastWorkDayOfWeek } from "../Utils"; import { formatDate, getFirstWorkDayOfWeek, getHumanDate, getLastWorkDayOfWeek } from "../Utils";
import { WeeklyStats, LunchChoice, getStats } from "../../../types"; import { WeeklyStats, LunchChoice, getStats, getStatsExport } from "../../../types";
import Loader from "../components/Loader"; import Loader from "../components/Loader";
import { faChevronLeft, faChevronRight, faGear } from "@fortawesome/free-solid-svg-icons"; import { faChevronLeft, faChevronRight, faGear, faFileExcel, faFileCsv, faFileCode } from "@fortawesome/free-solid-svg-icons";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts"; import { Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { getLunchChoiceName } from "../enums"; import { getLunchChoiceName } from "../enums";
import './StatsPage.scss'; import './StatsPage.scss';
import { isArrowNavigationBlocked } from '../utils/arrowNavigation';
const CHART_WIDTH = 1400; const CHART_WIDTH = 1400;
const CHART_HEIGHT = 700; const CHART_HEIGHT = 700;
@@ -28,10 +30,35 @@ const COLORS = [
'#7c7c7c', '#7c7c7c',
] ]
/** Podporované formáty exportu přehledu (viz GET /api/stats/export). */
const EXPORT_FORMATS = ['xlsx', 'csv', 'json'] as const;
type ExportFormat = typeof EXPORT_FORMATS[number];
const EXPORT_FORMAT_LABELS: Record<ExportFormat, string> = {
xlsx: 'Excel (.xlsx)',
csv: 'CSV (.csv)',
json: 'JSON (.json)',
};
const EXPORT_FORMAT_ICONS: Record<ExportFormat, IconDefinition> = {
xlsx: faFileExcel,
csv: faFileCsv,
json: faFileCode,
};
/** Vrátí měsíc předaného data ve formátu YYYY-MM (hodnota pro input type="month"). */
function getMonthValue(date: Date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
}
export default function StatsPage() { export default function StatsPage() {
const auth = useAuth(); const auth = useAuth();
const [dateRange, setDateRange] = useState<Date[]>(); const [dateRange, setDateRange] = useState<Date[]>();
const [data, setData] = useState<WeeklyStats>(); const [data, setData] = useState<WeeklyStats>();
const [exportMonth, setExportMonth] = useState<string>(() => getMonthValue(new Date()));
const [exportFormat, setExportFormat] = useState<ExportFormat>('xlsx');
const [exporting, setExporting] = useState(false);
// Prvotní nastavení aktuálního týdne // Prvotní nastavení aktuálního týdne
useEffect(() => { useEffect(() => {
@@ -53,6 +80,31 @@ export default function StatsPage() {
return <Line key={location} name={getLunchChoiceName(location)} type="monotone" dataKey={data => data.locations[location] ?? 0} stroke={COLORS[index]} strokeWidth={STROKE_WIDTH} /> return <Line key={location} name={getLunchChoiceName(location)} type="monotone" dataKey={data => data.locations[location] ?? 0} stroke={COLORS[index]} strokeWidth={STROKE_WIDTH} />
} }
/** Stáhne přehled stravování přihlášeného uživatele za vybraný měsíc ve vybraném formátu. */
const handleExport = async () => {
const [year, month] = exportMonth.split('-').map(Number);
if (!year || !month) {
return;
}
setExporting(true);
try {
// parseAs 'blob' — odpověď chceme stáhnout tak, jak přišla, bez parsování dle typu
const { data: file } = await getStatsExport({ query: { year, month, format: exportFormat }, parseAs: 'blob' });
// Chyby řeší globální interceptor (toaster), stahujeme jen při úspěchu
if (!(file instanceof Blob)) {
return;
}
const url = URL.createObjectURL(file);
const link = document.createElement('a');
link.href = url;
link.download = `luncher-${auth?.login ?? 'prehled'}-${exportMonth}.${exportFormat}`;
link.click();
URL.revokeObjectURL(url);
} finally {
setExporting(false);
}
}
const handlePreviousWeek = () => { const handlePreviousWeek = () => {
if (dateRange) { if (dateRange) {
const previousStartDate = new Date(dateRange[0]); const previousStartDate = new Date(dateRange[0]);
@@ -75,12 +127,12 @@ export default function StatsPage() {
const isCurrentOrFutureWeek = useMemo(() => { const isCurrentOrFutureWeek = useMemo(() => {
if (!dateRange) return true; if (!dateRange) return true;
const currentWeekEnd = getLastWorkDayOfWeek(new Date()); // Porovnáváme jen dny (obě data nesou čas svého vzniku), dopředu jde jít nejvýše na aktuální týden
currentWeekEnd.setHours(23, 59, 59, 999); return formatDate(dateRange[1]) >= formatDate(getLastWorkDayOfWeek(new Date()));
return dateRange[1] >= currentWeekEnd;
}, [dateRange]); }, [dateRange]);
const handleKeyDown = useCallback((e: any) => { const handleKeyDown = useCallback((e: any) => {
if (isArrowNavigationBlocked(e)) return;
if (e.keyCode === 37) { if (e.keyCode === 37) {
handlePreviousWeek(); handlePreviousWeek();
} else if (e.keyCode === 39 && !isCurrentOrFutureWeek) { } else if (e.keyCode === 39 && !isCurrentOrFutureWeek) {
@@ -128,6 +180,40 @@ export default function StatsPage() {
<Tooltip /> <Tooltip />
<Legend /> <Legend />
</LineChart> </LineChart>
<div className="export-panel">
<h3>Můj přehled</h3>
<p className="export-hint">
Přehled vašich záznamů za vybraný měsíc — datum, kde jste jedli, vybrané jídlo, poznámka
a u objednávek i částka po slevě.
</p>
<div className="export-controls">
<input
type="month"
className="form-control"
value={exportMonth}
max={getMonthValue(new Date())}
onChange={e => setExportMonth(e.target.value)}
/>
<select
className="form-select"
value={exportFormat}
onChange={e => setExportFormat(e.target.value as ExportFormat)}
>
{EXPORT_FORMATS.map(format => (
<option key={format} value={format}>{EXPORT_FORMAT_LABELS[format]}</option>
))}
</select>
<button
type="button"
className="btn btn-primary"
disabled={exporting || !exportMonth}
onClick={handleExport}
>
<FontAwesomeIcon icon={EXPORT_FORMAT_ICONS[exportFormat]} />{' '}
{exporting ? 'Generuji...' : 'Stáhnout přehled'}
</button>
</div>
</div>
</div> </div>
<Footer /> <Footer />
</> </>
+466
View File
@@ -0,0 +1,466 @@
// ============================================
// STYL "BOCCHI THE ROCK!" (volitelný, výchozí je jednoduchý styl)
// Inspirováno anime Bocchi the Rock! (ぼっち・ざ・ろっく!) a kapelou Kessoku Band (結束バンド).
// Růžová tepláková bunda Bocchi, žlutá a modrá kostička z jejích vlasů,
// černý Les Paul, flyery z live house STARRY — tlusté černé linky, tvrdé stíny,
// rastr jako z kopírky a samolepky. Občas se nápis „zasekne" Bocchiinou úzkostí.
// Tmavý režim = koncert v live house STARRY pod barevnými světly.
// Aktivní, pokud má <html> atribut data-luncher-theme="bocchi".
// ============================================
$bocchi: 'html[data-luncher-theme="bocchi"]';
// Kessoku Band: Bocchi (růžová), Nijika (žlutá), Ryo (modrá), Kita (červená)
$bocchi-band: linear-gradient(90deg, #e0457b 0 25%, #ffcf33 25% 50%, #3a63c9 50% 75%, #d93a30 75% 100%);
#{$bocchi} {
--bocchi-font: 'M PLUS Rounded 1c', 'Nunito', -apple-system, sans-serif;
--bocchi-display: 'Dela Gothic One', 'M PLUS Rounded 1c', sans-serif;
--bocchi-pink: #cc3570; // tepláková bunda Bocchi
--bocchi-pink-deep: #a8245a;
--bocchi-pink-light: #ffd6e5;
--bocchi-nijika: #ffcf33; // kostička ve vlasech / Nijika
--bocchi-ryo: #3a63c9; // kostička ve vlasech / Ryo
--bocchi-kita: #d93a30;
--bocchi-ink: #1d1a22; // černý Les Paul, linky flyeru
--bocchi-line: var(--bocchi-ink);
--bocchi-hard-shadow: rgb(29 26 34 / 0.9);
--bocchi-halftone: rgb(224 69 123 / 0.13);
--luncher-primary: var(--bocchi-pink);
--luncher-primary-hover: var(--bocchi-pink-deep);
--luncher-primary-light: #ffe4ee;
--luncher-bg: #fff6f9;
--luncher-bg-card: #ffffff;
--luncher-bg-hover: #fff0f5;
--luncher-text: var(--bocchi-ink);
--luncher-text-secondary: #4a4252;
--luncher-text-muted: #8f8597;
--luncher-border: #e9d9e1;
--luncher-border-light: #f5e9ef;
--luncher-navbar-bg: var(--bocchi-ink);
--luncher-navbar-text: #ffffff;
--luncher-action-icon: var(--bocchi-pink);
--luncher-buyer-icon: var(--bocchi-ryo);
--luncher-success: #2e8a4f;
--luncher-warning: #d08f00;
--luncher-danger: var(--bocchi-kita);
--luncher-radius-sm: 6px;
--luncher-radius: 8px;
--luncher-radius-lg: 10px;
--luncher-radius-xl: 12px;
--luncher-shadow: 4px 4px 0 var(--bocchi-hard-shadow);
--luncher-shadow-lg: 6px 6px 0 var(--bocchi-pink);
&[data-bs-theme="dark"] {
// Live house STARRY — tma, kouř a barevná světla nad pódiem
--bocchi-pink: #ff6fa0;
--bocchi-pink-deep: #e0457b;
--bocchi-pink-light: #4a1f33;
--bocchi-ryo: #7d9cff;
--bocchi-kita: #ff6a5c;
--bocchi-line: #5a4d66;
--bocchi-hard-shadow: #8c2350;
--bocchi-halftone: rgb(255 111 160 / 0.10);
--luncher-primary: #ff7aa8;
--luncher-primary-hover: #ff9cbf;
--luncher-primary-light: #3a1a2b;
--luncher-bg: #121016;
--luncher-bg-card: #1d1a24;
--luncher-bg-hover: #282231;
--luncher-text: #f5eff8;
--luncher-text-secondary: #c9bfd2;
--luncher-text-muted: #8f8499;
--luncher-border: #3a3246;
--luncher-border-light: #2a2433;
--luncher-navbar-bg: #0a090c;
--luncher-action-icon: #ff7aa8;
--luncher-buyer-icon: #ffcf33;
--luncher-success: #6fd59a;
--luncher-warning: #ffcf33;
--luncher-danger: #ff6a5c;
--luncher-shadow: 4px 4px 0 var(--bocchi-hard-shadow);
--luncher-shadow-lg: 6px 6px 0 var(--bocchi-ryo);
}
body {
font-family: var(--bocchi-font);
background-color: var(--luncher-bg);
// Rastr jako z kopírovaného flyeru, nahoře růžový nádech
background-image:
radial-gradient(circle, var(--bocchi-halftone) 0 1.4px, transparent 1.9px),
linear-gradient(180deg, #ffe0ec 0, var(--luncher-bg) 360px);
background-size: 12px 12px, 100% 100%;
background-attachment: fixed;
}
&[data-bs-theme="dark"] body {
// Růžové a modré světlo z rampy nad pódiem
background-image:
radial-gradient(circle, var(--bocchi-halftone) 0 1.4px, transparent 1.9px),
radial-gradient(ellipse 40% 55% at 15% -10%, rgb(255 111 160 / 0.22), transparent 70%),
radial-gradient(ellipse 40% 55% at 85% -10%, rgb(125 156 255 / 0.20), transparent 70%),
linear-gradient(180deg, #1c1622 0, var(--luncher-bg) 420px);
background-size: 12px 12px, 100% 100%, 100% 100%, 100% 100%;
}
.app-container {
background: transparent;
}
h1, h2, h3, h4, .navbar-brand {
font-family: var(--bocchi-display);
font-weight: 400;
}
.btn {
font-family: var(--bocchi-font);
}
// ---------- Navbar: černá jako Les Paul, pod ním pruh Kessoku Band ----------
.navbar {
background:
#{$bocchi-band} bottom / 100% 5px no-repeat,
var(--luncher-navbar-bg) !important;
padding-bottom: calc(var(--bs-navbar-padding-y, 0.5rem) + 5px);
.navbar-brand {
letter-spacing: 0.5px;
animation: bocchi-glitch 7s steps(1, end) infinite;
// Žlutá a modrá kostička z vlasů Bocchi
&::after {
content: '';
display: inline-block;
width: 0.42em;
height: 0.42em;
margin-left: 8px;
margin-right: 0.6em;
vertical-align: super;
background: var(--bocchi-nijika);
box-shadow: 0.55em 0.18em 0 var(--bocchi-ryo);
transform: rotate(-12deg);
}
}
.dropdown-menu {
border: 2px solid var(--bocchi-line);
border-radius: var(--luncher-radius);
box-shadow: var(--luncher-shadow);
}
}
// ---------- Titulek dne: žlutá samolepka z flyeru ----------
.day-navigator .title {
position: relative;
display: inline-block;
padding: 6px 24px 8px;
font-family: var(--bocchi-display);
font-weight: 400;
font-size: 1.6rem;
color: var(--bocchi-ink) !important;
background: var(--bocchi-nijika);
border: 3px solid var(--bocchi-ink);
border-radius: var(--luncher-radius-sm);
box-shadow: 5px 5px 0 var(--bocchi-pink);
transform: rotate(1.2deg);
&::before, &::after {
content: '★';
font-size: 0.7em;
margin: 0 10px;
vertical-align: middle;
color: var(--bocchi-pink-deep);
}
&.text-muted {
background: #e6e0e8;
color: #6b6273 !important;
box-shadow: 5px 5px 0 #b9afbf;
&::before, &::after {
color: #8f8597;
}
}
}
// ---------- Karty podniků: flyery s tvrdým stínem ----------
.restaurant-card {
border: 2px solid var(--bocchi-line);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
&:hover {
transform: translate(-2px, -2px);
border-color: var(--bocchi-line);
box-shadow: var(--luncher-shadow-lg);
}
.restaurant-header {
position: relative;
background:
#{$bocchi-band} bottom / 100% 4px no-repeat,
var(--bocchi-ink);
&:hover {
background:
#{$bocchi-band} bottom / 100% 4px no-repeat,
linear-gradient(90deg, var(--bocchi-pink-deep), var(--bocchi-pink));
}
h3 {
letter-spacing: 0.3px;
}
small {
color: rgb(255 255 255 / 0.85);
}
}
}
&[data-bs-theme="dark"] .restaurant-card .restaurant-header:not(:hover) {
background:
#{$bocchi-band} bottom / 100% 4px no-repeat,
#0a090c;
}
.food-table tbody tr:hover > td {
background: #fff0f5;
}
&[data-bs-theme="dark"] .food-table tbody tr:hover > td {
background: #2e2236;
}
.food-price {
color: var(--bocchi-pink);
font-weight: 800;
}
// ---------- Panely a tabulka voleb ----------
.choice-section,
.choices-table,
.pizza-section,
.no-votes {
border: 2px solid var(--bocchi-line);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
}
// <table> s border-collapse: collapse ignoruje border-radius — kulaté rohy jen se „separate"
.choices-table {
border-collapse: separate;
border-spacing: 0;
}
// Sloupec s podnikem: světle žlutý jako Nijika, oddělený černou linkou
.choices-table > tbody > tr > td:first-child {
background: #fff4c7;
color: var(--bocchi-ink);
font-weight: 800;
border-right: 2px solid var(--bocchi-line);
}
&[data-bs-theme="dark"] .choices-table > tbody > tr > td:first-child {
background: #2a2416;
color: var(--bocchi-nijika);
}
// Bootstrap karty (skupinové objednávky apod.) a seznamy — stejné linky a stíny jako ostatní panely
.card {
border: 2px solid var(--bocchi-line);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
overflow: hidden;
background: var(--luncher-bg-card);
// Rozbalovací výběr z nabídky nesmí karta oříznout
&:has(.select-search-container) {
overflow: visible;
}
> .card-header {
background: var(--luncher-primary-light);
border-bottom: 2px solid var(--bocchi-line);
font-weight: 800;
&:first-child {
border-radius: calc(var(--luncher-radius-lg) - 2px) calc(var(--luncher-radius-lg) - 2px) 0 0;
}
}
// Průhledná tabulka — rohy pak zaobluje karta sama (i když nesmí ořezávat obsah)
.table {
--bs-table-bg: transparent;
margin-bottom: 0;
// Spodní linka posledního řádku by přečnívala přes zaoblené rohy
> :last-child > tr:last-child > * {
border-bottom-width: 0;
}
}
}
.list-group {
border-radius: var(--luncher-radius);
overflow: hidden;
border: 2px solid var(--bocchi-line);
> .list-group-item {
border-left: none;
border-right: none;
&:first-child {
border-top: none;
}
&:last-child {
border-bottom: none;
}
}
}
.form-select, .form-control,
input[type="text"], input[type="email"], input[type="password"] {
border-radius: var(--luncher-radius-sm);
border: 2px solid var(--bocchi-line);
&:focus {
border-color: var(--bocchi-pink);
box-shadow: 3px 3px 0 var(--bocchi-pink);
}
}
// ---------- Tlačítka: samolepky s černou linkou, po stisku „zaklapnou" do stínu ----------
.btn {
border-radius: var(--luncher-radius-sm);
font-weight: 800;
letter-spacing: 0.2px;
transition: transform 0.1s ease, box-shadow 0.1s ease, background 0.2s ease;
&:active {
transform: translate(3px, 3px) !important;
box-shadow: none !important;
}
}
.btn-primary {
background: var(--bocchi-pink);
border: 2px solid var(--bocchi-ink);
color: #fff;
box-shadow: 3px 3px 0 var(--bocchi-ink);
&:hover {
background: var(--bocchi-pink-deep);
border-color: var(--bocchi-ink);
box-shadow: 3px 3px 0 var(--bocchi-ink), 0 0 0 3px rgb(255 207 51 / 0.6);
}
&:disabled {
background: #cfc6d3;
border-color: #8f8597;
box-shadow: 3px 3px 0 #8f8597;
}
}
&[data-bs-theme="dark"] .btn-primary {
color: #1d1a22;
&:hover {
color: #fff;
}
}
.btn-outline-primary {
background: var(--luncher-bg-card);
border: 2px solid var(--bocchi-line);
color: var(--luncher-primary);
box-shadow: 3px 3px 0 var(--bocchi-hard-shadow);
&:hover {
background: var(--luncher-primary-light);
border-color: var(--bocchi-line);
color: var(--luncher-primary);
}
}
.btn-secondary {
background: var(--luncher-bg-card);
border: 2px solid var(--bocchi-line);
color: var(--luncher-text);
box-shadow: 3px 3px 0 var(--bocchi-hard-shadow);
&:hover {
background: var(--luncher-bg-hover);
border-color: var(--bocchi-line);
color: var(--luncher-text);
}
}
.btn-danger {
background: var(--bocchi-kita);
border: 2px solid var(--bocchi-ink);
box-shadow: 3px 3px 0 var(--bocchi-ink);
}
.action-icon:hover {
background: var(--luncher-primary-light);
color: var(--bocchi-pink);
}
// ---------- Modaly ----------
.modal-content {
border: 3px solid var(--bocchi-line);
border-radius: var(--luncher-radius-xl);
box-shadow: 8px 8px 0 var(--bocchi-pink);
overflow: hidden;
}
.modal-header {
background:
#{$bocchi-band} bottom / 100% 5px no-repeat,
#0f0d12;
border-bottom: none;
padding-bottom: calc(var(--bs-modal-header-padding-y, 1rem) + 5px);
.modal-title, .modal-title h2 {
color: #fff;
font-family: var(--bocchi-display);
font-weight: 400;
}
.btn-close {
filter: invert(1) grayscale(1) brightness(2);
}
}
.footer {
border-top: none;
background-image: #{$bocchi-band};
background-size: 100% 4px;
background-repeat: no-repeat;
background-position: top;
}
}
// Bocchiina úzkost: nápis se na okamžik „rozpadne" do růžového a modrého posunu
@keyframes bocchi-glitch {
0%, 92%, 100% { text-shadow: none; transform: none; }
93% { text-shadow: -3px 0 #ff6fa0, 3px 0 #7d9cff; transform: translateX(1px) skewX(-6deg); }
94% { text-shadow: 2px 0 #ff6fa0, -2px 0 #7d9cff; transform: translateX(-2px); }
95% { text-shadow: -1px 0 #ff6fa0, 1px 0 #7d9cff; transform: translateX(1px) skewX(4deg); }
96% { text-shadow: none; transform: none; }
}
@media (max-width: 575.98px) {
#{$bocchi} .day-navigator .title {
font-size: 1.3rem;
}
}
@media (prefers-reduced-motion: reduce) {
#{$bocchi} .navbar .navbar-brand {
animation: none;
}
}
+445
View File
@@ -0,0 +1,445 @@
// ============================================
// STYL "HELL GIRL" (volitelný, výchozí je jednoduchý styl)
// Inspirováno anime Jigoku Shōjo (地獄少女).
// Věčný soumrak Ai Enmy (purpurovo-rudé nebe), papír washi, karmínová rudá
// nit ze slaměné panenky, květy lilie pavoukovité (higanbana) a ostré,
// tradičně japonské tvary s písmem mincho.
// Tmavý režim = půlnoc, kdy se otevírá Pekelná korespondence (地獄通信).
// Aktivní, pokud má <html> atribut data-luncher-theme="hellgirl".
// ============================================
$hg: 'html[data-luncher-theme="hellgirl"]';
// Nebe věčného soumraku (fialová → karmínová → oranžová)
$hg-twilight: linear-gradient(90deg, #1e0a1c, #4a0f2a 35%, #8e0f24 70%, #c8452a);
// Rudá nit — tenká linka s jemnou září
$hg-thread: linear-gradient(90deg, transparent, #c8102e 12%, #e8344f 50%, #c8102e 88%, transparent);
#{$hg} {
--hg-font: 'Shippori Mincho', 'Noto Serif JP', 'Georgia', serif;
--hg-crimson: #a3122a; // kimono Ai / rudá nit
--hg-crimson-deep: #7a0c1f;
--hg-crimson-shadow: #4a0612;
--hg-higanbana: #d4203c; // lilie pavoukovitá
--hg-dusk: #c8452a; // oranžové světlo soumraku
--hg-ink: #1c1012; // černé vlasy / tuš
--hg-washi: #f4ece0; // papír washi
--hg-card-border: #dccbbb;
--hg-petal: rgb(163 18 42 / 0.10);
--hg-petal-2: rgb(200 69 42 / 0.08);
--luncher-primary: var(--hg-crimson);
--luncher-primary-hover: var(--hg-crimson-deep);
--luncher-primary-light: #f7e3e2;
--luncher-bg: var(--hg-washi);
--luncher-bg-card: #fffbf5;
--luncher-bg-hover: #f8ebe6;
--luncher-text: var(--hg-ink);
--luncher-text-secondary: #4f3a3c;
--luncher-text-muted: #8c7672;
--luncher-border: var(--hg-card-border);
--luncher-border-light: #eadfd2;
--luncher-navbar-bg: var(--hg-crimson-deep);
--luncher-navbar-text: #fff4ee;
--luncher-action-icon: var(--hg-crimson);
--luncher-buyer-icon: var(--hg-dusk);
--luncher-success: #3d7a4a;
--luncher-warning: #c07a1a;
--luncher-danger: #c8102e;
// Ostré, tradiční tvary — téměř bez zaoblení
--luncher-radius-sm: 2px;
--luncher-radius: 3px;
--luncher-radius-lg: 4px;
--luncher-radius-xl: 6px;
--luncher-shadow: 0 3px 12px -4px rgb(74 6 18 / 0.25);
--luncher-shadow-lg: 0 10px 26px -8px rgb(74 6 18 / 0.38);
&[data-bs-theme="dark"] {
// Půlnoc — Pekelná korespondence je otevřena
--hg-crimson: #e0334f;
--hg-crimson-deep: #b81c38;
--hg-crimson-shadow: #3a0510;
--hg-higanbana: #ff4d63;
--hg-dusk: #ff7a4a;
--hg-card-border: #3d1c22;
--hg-petal: rgb(224 51 79 / 0.22);
--hg-petal-2: rgb(255 122 74 / 0.12);
--luncher-primary: #ec4a63;
--luncher-primary-hover: #ff6b80;
--luncher-primary-light: #2e1015;
--luncher-bg: #0c0608;
--luncher-bg-card: #170c0f;
--luncher-bg-hover: #221216;
--luncher-text: #f3e6e2;
--luncher-text-secondary: #c9aeaa;
--luncher-text-muted: #8a6c6a;
--luncher-border: #3d1c22;
--luncher-border-light: #2a1418;
--luncher-navbar-bg: #12070a;
--luncher-action-icon: #ec4a63;
--luncher-buyer-icon: #ff9a6b;
--luncher-success: #6fbf7e;
--luncher-warning: #f0b45a;
--luncher-danger: #ff4d63;
--luncher-shadow: 0 4px 16px -4px rgb(0 0 0 / 0.7);
--luncher-shadow-lg: 0 12px 30px -8px rgb(0 0 0 / 0.8), 0 0 18px -6px rgb(224 51 79 / 0.25);
}
body {
font-family: var(--hg-font);
background-color: var(--luncher-bg);
// Rozfoukané okvětní lístky higanbany nad papírem, nahoře záře soumraku
background-image:
radial-gradient(ellipse 3px 1.5px at 20% 30%, var(--hg-petal) 0 100%, transparent 101%),
radial-gradient(ellipse 2px 4px at 70% 75%, var(--hg-petal-2) 0 100%, transparent 101%),
radial-gradient(ellipse 2.5px 1.2px at 85% 15%, var(--hg-petal) 0 100%, transparent 101%),
linear-gradient(180deg, #f3d9cc 0, var(--luncher-bg) 380px);
background-size: 150px 150px, 210px 210px, 260px 260px, 100% 100%;
background-attachment: fixed;
}
&[data-bs-theme="dark"] body {
// Půlnoční tma s rudou září pekelného ohně odspodu
background-image:
radial-gradient(ellipse 3px 1.5px at 20% 30%, var(--hg-petal) 0 100%, transparent 101%),
radial-gradient(ellipse 2px 4px at 70% 75%, var(--hg-petal-2) 0 100%, transparent 101%),
radial-gradient(ellipse 2.5px 1.2px at 85% 15%, var(--hg-petal) 0 100%, transparent 101%),
radial-gradient(ellipse 120% 45% at 50% 115%, rgb(140 12 36 / 0.35), transparent 70%),
linear-gradient(180deg, #1c0a12 0, var(--luncher-bg) 420px);
background-size: 150px 150px, 210px 210px, 260px 260px, 100% 100%, 100% 100%;
}
.app-container {
background: transparent;
}
h1, h2, h3, h4, .navbar-brand, .btn {
font-family: var(--hg-font);
}
// ---------- Navbar: nebe soumraku s rudou nití dole ----------
.navbar {
background:
#{$hg-thread} bottom / 100% 2px no-repeat,
#{$hg-twilight} !important;
padding-bottom: calc(var(--bs-navbar-padding-y, 0.5rem) + 2px);
box-shadow: 0 3px 14px -2px rgb(74 6 18 / 0.5);
.navbar-brand {
font-weight: 800;
letter-spacing: 1.5px;
text-shadow: 0 0 12px rgb(232 52 79 / 0.6);
// Pekelná korespondence — otevírá se jen o půlnoci
&::after {
content: '地獄通信';
display: inline-block;
margin-left: 8px;
font-size: 0.55em;
font-weight: 600;
letter-spacing: 2px;
vertical-align: middle;
color: #ffb3a8;
opacity: 0.85;
animation: hg-flicker 5s ease-in-out infinite;
}
}
.dropdown-menu {
border: 1px solid var(--hg-crimson);
border-radius: var(--luncher-radius);
}
}
&[data-bs-theme="dark"] .navbar {
background:
#{$hg-thread} bottom / 100% 2px no-repeat,
linear-gradient(90deg, #0a0406, #1f0810 55%, #3d0a18) !important;
}
// ---------- Titulek dne: černá lakovaná deska s rudým lemem ----------
.day-navigator .title {
position: relative;
display: inline-block;
padding: 6px 28px 8px;
font-weight: 800;
font-size: 1.7rem;
letter-spacing: 1px;
color: #fff4ee !important;
background: linear-gradient(180deg, #2a1216, #12070a);
border: 1px solid var(--hg-crimson);
outline: 1px solid rgb(163 18 42 / 0.35);
outline-offset: 3px;
border-radius: var(--luncher-radius-sm);
text-shadow: 0 0 10px rgb(232 52 79 / 0.55);
box-shadow: var(--luncher-shadow);
// Květy higanbany po stranách
&::before, &::after {
content: '✿';
font-size: 0.6em;
margin: 0 10px;
vertical-align: middle;
color: var(--hg-higanbana);
text-shadow: 0 0 6px rgb(212 32 60 / 0.7);
}
&.text-muted {
background: linear-gradient(180deg, #6b5a5a, #4a3d3e);
border-color: #8c7672;
outline-color: rgb(140 118 114 / 0.35);
text-shadow: none;
&::before, &::after {
color: #b3a19d;
text-shadow: none;
}
}
}
// Symbol ohně ze znělky místo květů (masku dodá HellGirlFlames, až se obrázek načte)
&[data-hg-symbol] .day-navigator .title {
&::before, &::after {
content: '';
display: inline-block;
font-size: 1em;
width: 0.85em;
height: 0.85em;
margin: 0 14px;
vertical-align: -0.08em;
background: linear-gradient(180deg, var(--hg-dusk), var(--hg-higanbana) 45%, var(--hg-crimson-deep));
-webkit-mask: var(--hg-symbol) center / contain no-repeat;
mask: var(--hg-symbol) center / contain no-repeat;
}
&.text-muted::before, &.text-muted::after {
background: #b3a19d;
}
}
// ---------- Karty podniků ----------
.restaurant-card {
border: 1px solid var(--hg-card-border);
border-top: 3px solid var(--hg-crimson);
border-radius: var(--luncher-radius-lg);
&:hover {
transform: translateY(-2px);
border-color: var(--hg-crimson);
box-shadow: var(--luncher-shadow-lg), 0 0 0 1px rgb(200 16 46 / 0.25);
}
.restaurant-header {
position: relative;
background:
#{$hg-thread} bottom / 100% 2px no-repeat,
linear-gradient(90deg, #1c0a10, var(--hg-crimson-deep));
&:hover {
background:
#{$hg-thread} bottom / 100% 2px no-repeat,
#{$hg-twilight};
}
h3 {
font-weight: 800;
letter-spacing: 0.5px;
text-shadow: 0 1px 0 rgb(0 0 0 / 0.4);
}
small {
color: rgb(255 244 238 / 0.85);
}
}
}
.food-table tbody tr:hover > td {
background: #f8e6e2;
}
&[data-bs-theme="dark"] .food-table tbody tr:hover > td {
background: #2a1218;
}
.food-price {
color: var(--hg-crimson);
font-weight: 700;
}
// ---------- Panely a tabulka voleb ----------
.choice-section,
.choices-table,
.pizza-section,
.no-votes {
border: 1px solid var(--hg-card-border);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
}
// Sloupec s podnikem: černá tuš s rudou nití na okraji
.choices-table > tbody > tr > td:first-child {
background: linear-gradient(90deg, #1c1012, #2e1418);
color: #fff4ee;
font-weight: 700;
border-right: 2px solid var(--hg-crimson);
}
&[data-bs-theme="dark"] .choices-table > tbody > tr > td:first-child {
background: linear-gradient(90deg, #22090f, #170c0f);
color: var(--hg-higanbana);
}
// Bootstrap karty (skupinové objednávky apod.) a seznamy
.card {
border: 1px solid var(--hg-card-border);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
background: var(--luncher-bg-card);
> .card-header {
background: var(--luncher-primary-light);
border-bottom: 2px solid var(--hg-crimson);
font-weight: 700;
}
.table {
--bs-table-bg: transparent;
margin-bottom: 0;
}
}
.list-group {
border-radius: var(--luncher-radius);
border: 1px solid var(--hg-card-border);
}
.form-select, .form-control,
input[type="text"], input[type="email"], input[type="password"] {
border-radius: var(--luncher-radius-sm);
&:focus {
border-color: var(--hg-crimson);
box-shadow: 0 0 0 3px rgb(200 16 46 / 0.2);
}
}
// ---------- Tlačítka: ostré, karmínová s dvojitou linkou jako pás obi ----------
.btn {
border-radius: var(--luncher-radius-sm);
font-weight: 700;
letter-spacing: 0.4px;
transition: transform 0.12s ease, box-shadow 0.2s ease, background 0.2s ease;
&:active {
transform: translateY(1px) !important;
}
}
.btn-primary {
background: linear-gradient(180deg, #c01733, var(--hg-crimson-deep));
border: 1px solid var(--hg-crimson-shadow);
color: #fff4ee;
box-shadow: inset 0 0 0 2px rgb(255 255 255 / 0.12);
// Rudá záře pekelného ohně při najetí
&:hover {
background: linear-gradient(180deg, #d41d3b, #8e0f24);
box-shadow: inset 0 0 0 2px rgb(255 255 255 / 0.18), 0 0 14px rgb(212 32 60 / 0.5);
}
&:disabled {
background: #b9a3a0;
border-color: #9c8582;
box-shadow: none;
}
}
.btn-outline-primary {
background: transparent;
border: 1px solid var(--hg-crimson);
color: var(--luncher-primary);
&:hover {
background: var(--hg-crimson);
color: #fff4ee;
}
}
.btn-secondary {
background: var(--luncher-bg-card);
border: 1px solid var(--luncher-border);
color: var(--luncher-text);
&:hover {
border-color: var(--hg-crimson);
background: var(--luncher-bg-hover);
color: var(--luncher-text);
}
}
.btn-danger {
background: linear-gradient(180deg, #e0243f, #8e0f24);
border: 1px solid var(--hg-crimson-shadow);
}
.action-icon:hover {
background: var(--luncher-primary-light);
color: var(--hg-higanbana);
}
// ---------- Modaly ----------
.modal-content {
border: 1px solid var(--hg-crimson);
border-radius: var(--luncher-radius-xl);
overflow: hidden;
}
.modal-header {
background:
#{$hg-thread} bottom / 100% 2px no-repeat,
#{$hg-twilight};
border-bottom: none;
.modal-title, .modal-title h2 {
color: #fff4ee;
font-weight: 800;
letter-spacing: 0.5px;
text-shadow: 0 0 10px rgb(232 52 79 / 0.5);
}
.btn-close {
filter: invert(1) grayscale(1) brightness(2);
}
}
.footer {
border-top: none;
background-image: #{$hg-thread};
background-size: 100% 1px;
background-repeat: no-repeat;
background-position: top;
}
}
// Mihotání nápisu jako plamen svíčky
@keyframes hg-flicker {
0%, 100% { opacity: 0.85; }
45% { opacity: 0.85; }
48% { opacity: 0.35; }
50% { opacity: 0.9; }
53% { opacity: 0.5; }
56% { opacity: 0.85; }
}
@media (max-width: 575.98px) {
#{$hg} .navbar .navbar-brand::after {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
#{$hg} .navbar .navbar-brand::after {
animation: none;
}
}
+483
View File
@@ -0,0 +1,483 @@
// ============================================
// STYL "K-ON!" (volitelný, výchozí je jednoduchý styl)
// Inspirováno anime K-ON! (けいおん!) — klub lehké hudby na dívčí škole Sakuragaoka.
// Tmavě modré školní sako, kytara Gitah v barvě cherry sunburst, mléčný čaj
// a dortíky z odpoledních dýchánků (Houkago Tea Time), notová osnova
// a ručně psané nápisy jako ze školního sešitu.
// Tmavý režim = večerní koncert v klubu (live house) pod reflektory.
// Aktivní, pokud má <html> atribut data-luncher-theme="kon".
// ============================================
$kon: 'html[data-luncher-theme="kon"]';
// Pruh v barvách členek Houkago Tea Time: Yui (červená Gitah), Mio (modrá),
// Ritsu (žlutá), Mugi (zlatavá) a Azusa (fialová)
$kon-members: linear-gradient(90deg, #d43f62 0 20%, #3d6fc4 20% 40%, #f2b81d 40% 60%, #e8cf7a 60% 80%, #8a6bc7 80% 100%);
// Notová osnova (pět linek) — opakuje se na pozadí po 220 px
$kon-staff-stops: transparent 0 100px,
var(--kon-staff) 100px 101px, transparent 101px 109px,
var(--kon-staff) 109px 110px, transparent 110px 118px,
var(--kon-staff) 118px 119px, transparent 119px 127px,
var(--kon-staff) 127px 128px, transparent 128px 136px,
var(--kon-staff) 136px 137px, transparent 137px;
// Rozeseté noty ♪ ♫ (ve dne červená a modrá, v noci světlejší)
$kon-notes: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Ctext x='34' y='95' font-size='26' fill='%23d43f62' fill-opacity='0.16'%3E%E2%99%AA%3C/text%3E%3Ctext x='150' y='128' font-size='30' fill='%233d6fc4' fill-opacity='0.14'%3E%E2%99%AB%3C/text%3E%3Ctext x='96' y='210' font-size='18' fill='%23e8a10f' fill-opacity='0.18'%3E%E2%99%AA%3C/text%3E%3C/svg%3E");
$kon-notes-dark: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Ctext x='34' y='95' font-size='26' fill='%23ff8fa8' fill-opacity='0.22'%3E%E2%99%AA%3C/text%3E%3Ctext x='150' y='128' font-size='30' fill='%238fb2ff' fill-opacity='0.2'%3E%E2%99%AB%3C/text%3E%3Ctext x='96' y='210' font-size='18' fill='%23ffd166' fill-opacity='0.22'%3E%E2%99%AA%3C/text%3E%3C/svg%3E");
#{$kon} {
--kon-font: 'Zen Maru Gothic', 'M PLUS Rounded 1c', 'Nunito', -apple-system, sans-serif;
--kon-hand: 'Yusei Magic', 'Zen Maru Gothic', 'M PLUS Rounded 1c', sans-serif;
--kon-blazer: #27315f; // tmavě modré sako Sakuragaoka
--kon-blazer-light: #3a4782;
--kon-blazer-shadow: #171d3d;
--kon-cherry: #c43358; // Gitah — cherry sunburst
--kon-cherry-deep: #a3234a;
--kon-sunburst: #f2a33a; // jantarový střed sunburstu
--kon-ritsu: #f2b81d;
--kon-tea: #fbf3e4; // mléčný čaj
--kon-paper: #fffaf0; // notový sešit
--kon-card-border: #e6d8c0;
--kon-staff: rgb(39 49 95 / 0.07);
--luncher-primary: var(--kon-cherry);
--luncher-primary-hover: var(--kon-cherry-deep);
--luncher-primary-light: #fbe4ea;
--luncher-bg: var(--kon-tea);
--luncher-bg-card: var(--kon-paper);
--luncher-bg-hover: #f8ecd8;
--luncher-text: #222849;
--luncher-text-secondary: #4a5179;
--luncher-text-muted: #8a8aa3;
--luncher-border: var(--kon-card-border);
--luncher-border-light: #f0e6d3;
--luncher-navbar-bg: var(--kon-blazer);
--luncher-navbar-text: #ffffff;
--luncher-action-icon: var(--kon-cherry);
--luncher-buyer-icon: #e8a10f;
--luncher-success: #2f8a57;
--luncher-warning: #d98a0b;
--luncher-danger: #d0304a;
--luncher-radius-sm: 10px;
--luncher-radius: 14px;
--luncher-radius-lg: 18px;
--luncher-radius-xl: 22px;
--luncher-shadow: 0 4px 14px -6px rgb(39 49 95 / 0.25);
--luncher-shadow-lg: 0 10px 26px -8px rgb(39 49 95 / 0.35);
&[data-bs-theme="dark"] {
// Večerní koncert v klubu — reflektory nad pódiem
--kon-blazer: #1a2150;
--kon-blazer-light: #2b367a;
--kon-blazer-shadow: #0a0e26;
--kon-cherry: #ff7a9a;
--kon-cherry-deep: #e85a7e;
--kon-ritsu: #ffd166;
--kon-card-border: #2c3566;
--kon-staff: rgb(180 196 255 / 0.06);
--luncher-primary: #ff86a3;
--luncher-primary-hover: #ffa8bd;
--luncher-primary-light: #3a1d35;
--luncher-bg: #0e1230;
--luncher-bg-card: #171c42;
--luncher-bg-hover: #212858;
--luncher-text: #f1f0ff;
--luncher-text-secondary: #bfc2e6;
--luncher-text-muted: #8085b3;
--luncher-border: #2c3566;
--luncher-border-light: #232a58;
--luncher-navbar-bg: #141a44;
--luncher-action-icon: #ff86a3;
--luncher-buyer-icon: #ffd166;
--luncher-success: #6fd59a;
--luncher-warning: #ffd166;
--luncher-danger: #ff6f86;
--luncher-shadow: 0 4px 16px -4px rgb(0 0 0 / 0.55);
--luncher-shadow-lg: 0 12px 30px -8px rgb(0 0 0 / 0.65);
}
body {
font-family: var(--kon-font);
background-color: var(--luncher-bg);
// Notový sešit: osnova s notami, nahoře teplé odpolední světlo klubovny
background-image:
#{$kon-notes},
linear-gradient(180deg, #{$kon-staff-stops}),
linear-gradient(180deg, #fde4d6 0, var(--luncher-bg) 360px);
background-size: 220px 220px, 100% 220px, 100% 100%;
background-attachment: fixed;
}
&[data-bs-theme="dark"] body {
// Dva reflektory (růžový a jantarový) svítí z horních rohů na pódium
background-image:
#{$kon-notes-dark},
linear-gradient(180deg, #{$kon-staff-stops}),
radial-gradient(ellipse 45% 60% at 10% -10%, rgb(255 122 154 / 0.18), transparent 70%),
radial-gradient(ellipse 45% 60% at 90% -10%, rgb(255 209 102 / 0.14), transparent 70%),
linear-gradient(180deg, #1a1f4d 0, var(--luncher-bg) 420px);
background-size: 220px 220px, 100% 220px, 100% 100%, 100% 100%, 100% 100%;
}
.app-container {
background: transparent;
}
h1, h2, h3, h4, .navbar-brand, .btn {
font-family: var(--kon-font);
}
// ---------- Navbar: školní sako s pruhem v barvách kapely ----------
.navbar {
background:
#{$kon-members} bottom / 100% 4px no-repeat,
linear-gradient(90deg, var(--kon-blazer-shadow), var(--kon-blazer) 40%, var(--kon-blazer-light)) !important;
padding-bottom: calc(var(--bs-navbar-padding-y, 0.5rem) + 4px);
box-shadow: 0 3px 10px -2px rgb(23 29 61 / 0.4);
.navbar-brand {
font-weight: 900;
letter-spacing: 0.5px;
text-shadow: 0 2px 0 var(--kon-blazer-shadow);
// Nota, která poskakuje do rytmu
&::after {
content: '♪';
display: inline-block;
margin-left: 6px;
color: var(--kon-ritsu);
font-size: 0.85em;
vertical-align: super;
animation: kon-bounce 1.2s ease-in-out infinite;
}
}
.dropdown-menu {
border: 2px solid var(--kon-card-border);
border-radius: var(--luncher-radius);
}
}
// ---------- Titulek dne: ručně psaná cedulka z dveří klubovny ----------
.day-navigator .title {
position: relative;
display: inline-block;
padding: 4px 26px 6px;
font-family: var(--kon-hand);
font-weight: 400;
font-size: 1.8rem;
color: var(--kon-blazer) !important;
background:
repeating-linear-gradient(180deg, transparent 0 11px, rgb(61 111 196 / 0.12) 11px 12px),
var(--kon-paper);
border: 3px solid var(--kon-blazer);
border-radius: 12px;
box-shadow: 4px 4px 0 var(--kon-cherry);
transform: rotate(-1.5deg);
&::before {
content: '♪';
margin-right: 10px;
color: var(--kon-cherry);
}
&::after {
content: '♫';
margin-left: 10px;
color: #3d6fc4;
}
&.text-muted {
color: #8a8aa3 !important;
border-color: #a9a9be;
box-shadow: 4px 4px 0 #cfcfdc;
&::before, &::after {
color: #a9a9be;
}
}
}
&[data-bs-theme="dark"] .day-navigator .title {
color: #f1f0ff !important;
background:
repeating-linear-gradient(180deg, transparent 0 11px, rgb(143 178 255 / 0.12) 11px 12px),
var(--luncher-bg-card);
border-color: #8fb2ff;
&::after {
color: #8fb2ff;
}
&.text-muted {
color: #8085b3 !important;
border-color: #4a5288;
box-shadow: 4px 4px 0 #2c3566;
}
}
// ---------- Karty podniků ----------
.restaurant-card {
border: 2px solid var(--kon-card-border);
border-radius: var(--luncher-radius-lg);
&:hover {
transform: translateY(-3px) rotate(-0.3deg);
border-color: var(--kon-cherry);
box-shadow: var(--luncher-shadow-lg);
}
.restaurant-header {
position: relative;
background:
#{$kon-members} bottom / 100% 4px no-repeat,
linear-gradient(90deg, var(--kon-blazer), var(--kon-blazer-light));
// Gitah: jantarový střed přechází do třešňové
&:hover {
background:
#{$kon-members} bottom / 100% 4px no-repeat,
radial-gradient(ellipse 80% 160% at 30% 50%, var(--kon-sunburst), var(--kon-cherry) 55%, #6e1530);
}
h3 {
font-weight: 900;
text-shadow: 0 2px 0 rgb(0 0 0 / 0.2);
}
small {
color: rgb(255 255 255 / 0.88);
}
}
}
.food-table tbody tr:hover > td {
background: #fdeee0;
}
&[data-bs-theme="dark"] .food-table tbody tr:hover > td {
background: #232a5c;
}
.food-price {
color: var(--kon-cherry);
font-weight: 700;
}
// ---------- Panely a tabulka voleb ----------
.choice-section,
.choices-table,
.pizza-section,
.no-votes {
border: 2px solid var(--kon-card-border);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
}
// <table> s border-collapse: collapse ignoruje border-radius — kulaté rohy jen se „separate"
.choices-table {
border-collapse: separate;
border-spacing: 0;
}
// Sloupec s podnikem: linkovaný papír se svislou okrajovou linkou jako v sešitě
.choices-table > tbody > tr > td:first-child {
background: #fdf1dc;
color: var(--kon-blazer);
font-weight: 700;
border-right: 2px solid rgb(196 51 88 / 0.45);
}
&[data-bs-theme="dark"] .choices-table > tbody > tr > td:first-child {
background: #1f2552;
color: #c9d6ff;
border-right-color: rgb(255 134 163 / 0.5);
}
// Bootstrap karty (skupinové objednávky apod.) a seznamy — stejné zaoblení a lem jako ostatní panely
.card {
border: 2px solid var(--kon-card-border);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
overflow: hidden;
background: var(--luncher-bg-card);
// Rozbalovací výběr z nabídky nesmí karta oříznout
&:has(.select-search-container) {
overflow: visible;
}
> .card-header {
background: var(--luncher-primary-light);
border-bottom: 2px solid var(--kon-card-border);
font-weight: 700;
&:first-child {
border-radius: calc(var(--luncher-radius-lg) - 2px) calc(var(--luncher-radius-lg) - 2px) 0 0;
}
}
// Průhledná tabulka — rohy pak zaobluje karta sama (i když nesmí ořezávat obsah)
.table {
--bs-table-bg: transparent;
margin-bottom: 0;
// Spodní linka posledního řádku by přečnívala přes zaoblené rohy
> :last-child > tr:last-child > * {
border-bottom-width: 0;
}
}
}
.list-group {
border-radius: var(--luncher-radius);
overflow: hidden;
border: 2px solid var(--kon-card-border);
> .list-group-item {
border-left: none;
border-right: none;
&:first-child {
border-top: none;
}
&:last-child {
border-bottom: none;
}
}
}
.form-select, .form-control,
input[type="text"], input[type="email"], input[type="password"] {
border-radius: var(--luncher-radius-sm);
border-width: 2px;
&:focus {
border-color: var(--kon-cherry);
box-shadow: 0 0 0 4px rgb(196 51 88 / 0.18);
}
}
// ---------- Tlačítka: zaoblené jako pedály efektů, s tmavě modrým „podstavcem" ----------
.btn {
border-radius: var(--luncher-radius-sm);
font-weight: 700;
letter-spacing: 0.2px;
transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.2s ease;
&:active {
transform: translateY(3px) !important;
box-shadow: 0 1px 0 var(--kon-btn-shadow, transparent) !important;
}
}
.btn-primary {
--kon-btn-shadow: var(--kon-blazer-shadow);
background: linear-gradient(180deg, #e0567a, var(--kon-cherry-deep));
border: none;
color: #fff;
box-shadow: 0 4px 0 var(--kon-blazer);
text-shadow: 0 1px 0 rgb(0 0 0 / 0.2);
// Sunburst při najetí
&:hover {
background: radial-gradient(ellipse 90% 140% at 50% 40%, var(--kon-sunburst), var(--kon-cherry) 60%, var(--kon-cherry-deep));
box-shadow: 0 4px 0 var(--kon-blazer), 0 0 12px rgb(242 163 58 / 0.45);
}
&:disabled {
background: #c8c3cf;
box-shadow: 0 4px 0 #a5a1b0;
}
}
.btn-outline-primary {
--kon-btn-shadow: var(--kon-card-border);
background: var(--luncher-bg-card);
border: 2px solid var(--kon-blazer);
color: var(--kon-blazer);
box-shadow: 0 4px 0 var(--kon-card-border);
&:hover {
background: var(--kon-blazer);
color: #fff;
}
}
&[data-bs-theme="dark"] .btn-outline-primary {
border-color: #8fb2ff;
color: #c9d6ff;
&:hover {
background: #2b367a;
color: #fff;
}
}
.btn-secondary {
--kon-btn-shadow: var(--luncher-border);
background: var(--luncher-bg-card);
border: 2px solid var(--luncher-border);
color: var(--luncher-text);
box-shadow: 0 4px 0 var(--luncher-border);
}
.btn-danger {
--kon-btn-shadow: #8e1d33;
background: linear-gradient(180deg, #ef5a74, var(--luncher-danger));
border: none;
box-shadow: 0 4px 0 #8e1d33;
}
.action-icon:hover {
background: var(--luncher-primary-light);
color: var(--kon-cherry);
}
// ---------- Modaly ----------
.modal-content {
border: 2px solid var(--kon-card-border);
border-radius: var(--luncher-radius-xl);
overflow: hidden;
}
.modal-header {
background:
#{$kon-members} bottom / 100% 4px no-repeat,
linear-gradient(90deg, var(--kon-blazer-shadow), var(--kon-blazer) 40%, var(--kon-blazer-light));
border-bottom: none;
padding-bottom: calc(var(--bs-modal-header-padding-y, 1rem) + 4px);
.modal-title, .modal-title h2 {
color: #fff;
font-weight: 900;
text-shadow: 0 2px 0 rgb(0 0 0 / 0.2);
}
.btn-close {
filter: invert(1) grayscale(1) brightness(2);
}
}
.footer {
border-top: none;
background-image: #{$kon-members};
background-size: 100% 3px;
background-repeat: no-repeat;
background-position: top;
}
}
@keyframes kon-bounce {
0%, 100% { transform: translateY(0) rotate(0deg); }
25% { transform: translateY(-4px) rotate(-12deg); }
50% { transform: translateY(0) rotate(0deg); }
75% { transform: translateY(-2px) rotate(10deg); }
}
@media (prefers-reduced-motion: reduce) {
#{$kon} .navbar .navbar-brand::after {
animation: none;
}
}
+442
View File
@@ -0,0 +1,442 @@
// ============================================
// STYL "MY LITTLE PONY" (volitelný, výchozí je jednoduchý styl)
// Inspirováno 4. generací — My Little Pony: Friendship is Magic.
// Fialová Twilight Sparkle, růžová Pinkie Pie, pastelová obloha Ponyville,
// duhový pruh Rainbow Dash (Sonic Rainboom) a hvězdička z cutie marku Twilight.
// Tmavý režim = noční obloha princezny Luny.
// Aktivní, pokud má <html> atribut data-luncher-theme="mlp".
// ============================================
$mlp: 'html[data-luncher-theme="mlp"]';
// Duha Rainbow Dash (červená, oranžová, žlutá, zelená, modrá, fialová)
$mlp-rainbow: linear-gradient(90deg, #ee4144 0 16.6%, #f37033 16.6% 33.3%, #fdf6af 33.3% 50%, #62bc4d 50% 66.6%, #1e98d3 66.6% 83.3%, #672f89 83.3% 100%);
$mlp-rainbow-soft: linear-gradient(90deg, #ee4144, #f37033, #fdf6af, #62bc4d, #1e98d3, #672f89);
#{$mlp} {
--mlp-font: 'Fredoka', 'Nunito', 'Inter', -apple-system, sans-serif;
--mlp-twilight: #8e4fd1; // srst Twilight (sytější kvůli kontrastu)
--mlp-twilight-deep: #6a35b0;
--mlp-twilight-shadow: #4b2287;
--mlp-twilight-light: #d9b8f5;
--mlp-pinkie: #f06aa6; // Pinkie Pie
--mlp-pinkie-deep: #d6417f;
--mlp-star: #ec3d8f; // cutie mark Twilight
--mlp-fluttershy: #fff3b0; // Fluttershy
--mlp-sky: #5ec8f2; // Rainbow Dash
--mlp-card-border: #e5d4f7;
--mlp-sparkle: rgb(142 79 209 / 0.10);
--mlp-sparkle-2: rgb(240 106 166 / 0.10);
--luncher-primary: var(--mlp-twilight-deep);
--luncher-primary-hover: var(--mlp-twilight-shadow);
--luncher-primary-light: #f1e6fc;
--luncher-bg: #faf5ff;
--luncher-bg-card: #ffffff;
--luncher-bg-hover: #f6edff;
--luncher-text: #2e1f47;
--luncher-text-secondary: #5d4a7a;
--luncher-text-muted: #9a8ab3;
--luncher-border: #e5d4f7;
--luncher-border-light: #f0e6fb;
--luncher-navbar-bg: var(--mlp-twilight-deep);
--luncher-navbar-text: #ffffff;
--luncher-action-icon: var(--mlp-twilight-deep);
--luncher-buyer-icon: #f37033;
--luncher-success: #3f9d3a;
--luncher-warning: #e8891c;
--luncher-danger: #e0395c;
--luncher-radius-sm: 12px;
--luncher-radius: 16px;
--luncher-radius-lg: 22px;
--luncher-radius-xl: 26px;
--luncher-shadow: 0 4px 14px -4px rgb(106 53 176 / 0.22);
--luncher-shadow-lg: 0 10px 28px -8px rgb(106 53 176 / 0.32);
&[data-bs-theme="dark"] {
// Noční obloha princezny Luny
--mlp-twilight: #b78af0;
--mlp-twilight-deep: #9b6be0;
--mlp-twilight-shadow: #3d2380;
--mlp-twilight-light: #3a2b6e;
--mlp-pinkie: #ff8cc0;
--mlp-pinkie-deep: #f06aa6;
--mlp-card-border: #362c6e;
--mlp-sparkle: rgb(255 255 255 / 0.55);
--mlp-sparkle-2: rgb(183 198 255 / 0.35);
--luncher-primary: #c4a0f7;
--luncher-primary-hover: #d9c0fb;
--luncher-primary-light: #2b2160;
--luncher-bg: #120e2e;
--luncher-bg-card: #1d1745;
--luncher-bg-hover: #282058;
--luncher-text: #f2edff;
--luncher-text-secondary: #c2b6e6;
--luncher-text-muted: #8a7fb5;
--luncher-border: #362c6e;
--luncher-border-light: #2b2360;
--luncher-navbar-bg: #2a1f66;
--luncher-action-icon: #c4a0f7;
--luncher-buyer-icon: #ffb36b;
--luncher-success: #7fd67a;
--luncher-warning: #ffcf5c;
--luncher-danger: #ff6f8f;
--luncher-shadow: 0 4px 16px -4px rgb(0 0 0 / 0.55);
--luncher-shadow-lg: 0 12px 30px -8px rgb(0 0 0 / 0.65);
}
body {
font-family: var(--mlp-font);
background-color: var(--luncher-bg);
// Rozeseté jiskřičky (ve dne pastelové, v noci hvězdy)
background-image:
radial-gradient(circle at 20% 30%, var(--mlp-sparkle) 0 2px, transparent 3px),
radial-gradient(circle at 70% 80%, var(--mlp-sparkle-2) 0 2px, transparent 3px),
radial-gradient(circle at 85% 15%, var(--mlp-sparkle) 0 1.5px, transparent 2.5px),
linear-gradient(180deg, #fde9f4 0, var(--luncher-bg) 360px);
background-size: 140px 140px, 190px 190px, 230px 230px, 100% 100%;
background-attachment: fixed;
}
&[data-bs-theme="dark"] body {
background-image:
radial-gradient(circle at 20% 30%, var(--mlp-sparkle) 0 1.5px, transparent 2.5px),
radial-gradient(circle at 70% 80%, var(--mlp-sparkle-2) 0 1px, transparent 2px),
radial-gradient(circle at 85% 15%, var(--mlp-sparkle) 0 1px, transparent 2px),
linear-gradient(180deg, #221a55 0, var(--luncher-bg) 420px);
}
.app-container {
background: transparent;
}
h1, h2, h3, h4, .navbar-brand, .btn {
font-family: var(--mlp-font);
}
// ---------- Navbar: fialová Twilight s duhovým pruhem dole ----------
.navbar {
background:
#{$mlp-rainbow} bottom / 100% 5px no-repeat,
linear-gradient(90deg, var(--mlp-twilight-deep), var(--mlp-twilight) 60%, var(--mlp-pinkie)) !important;
padding-bottom: calc(var(--bs-navbar-padding-y, 0.5rem) + 5px);
box-shadow: 0 3px 10px -2px rgb(106 53 176 / 0.35);
.navbar-brand {
font-weight: 700;
letter-spacing: 0.4px;
text-shadow: 0 2px 0 var(--mlp-twilight-shadow), 0 0 14px rgb(255 255 255 / 0.3);
// Hvězdička z cutie marku Twilight Sparkle
&::after {
content: '✦';
display: inline-block;
margin-left: 6px;
color: #ffd1ea;
font-size: 0.8em;
vertical-align: super;
text-shadow: 0 0 8px var(--mlp-star), 0 0 2px #fff;
animation: mlp-twinkle 2.4s ease-in-out infinite;
}
}
.dropdown-menu {
border: 2px solid var(--mlp-card-border);
}
}
&[data-bs-theme="dark"] .navbar {
background:
#{$mlp-rainbow} bottom / 100% 5px no-repeat,
linear-gradient(90deg, #1f1650, #2f2276 60%, #3d2d8a) !important;
}
// ---------- Titulek dne: pilulka s duhovou linkou a jiskřičkami ----------
.day-navigator .title {
position: relative;
display: inline-block;
padding: 6px 30px 10px;
font-weight: 700;
font-size: 1.7rem;
color: #fff !important;
border-radius: 999px;
background:
#{$mlp-rainbow-soft} bottom / 100% 5px no-repeat,
linear-gradient(90deg, var(--mlp-twilight-deep), var(--mlp-pinkie));
text-shadow: 0 2px 0 rgb(0 0 0 / 0.15);
box-shadow: 0 4px 0 var(--mlp-twilight-shadow);
overflow: hidden;
&::before, &::after {
content: '✨';
font-size: 0.7em;
margin: 0 6px;
vertical-align: middle;
}
&.text-muted {
background: linear-gradient(90deg, #a597bd, #c3b8d6);
box-shadow: 0 4px 0 #8a7ca3;
}
}
// ---------- Karty podniků ----------
.restaurant-card {
border: 3px solid var(--mlp-card-border);
border-radius: var(--luncher-radius-lg);
&:hover {
transform: translateY(-3px);
border-color: var(--mlp-twilight-light);
box-shadow: var(--luncher-shadow-lg), 0 0 0 4px rgb(240 106 166 / 0.18);
}
.restaurant-header {
position: relative;
background:
#{$mlp-rainbow} bottom / 100% 4px no-repeat,
linear-gradient(90deg, var(--mlp-twilight-deep), var(--mlp-twilight));
&:hover {
background:
#{$mlp-rainbow} bottom / 100% 4px no-repeat,
linear-gradient(90deg, var(--mlp-pinkie-deep), var(--mlp-pinkie));
}
h3 {
font-weight: 700;
text-shadow: 0 2px 0 rgb(0 0 0 / 0.18);
}
small {
color: rgb(255 255 255 / 0.88);
}
}
}
.food-table tbody tr:hover > td {
background: #fff0f7;
}
&[data-bs-theme="dark"] .food-table tbody tr:hover > td {
background: #2e2160;
}
.food-price {
color: var(--mlp-pinkie-deep);
font-weight: 700;
}
&[data-bs-theme="dark"] .food-price {
color: var(--mlp-pinkie);
}
// ---------- Panely a tabulka voleb ----------
.choice-section,
.choices-table,
.pizza-section,
.no-votes {
border: 3px solid var(--mlp-card-border);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
}
// <table> s border-collapse: collapse ignoruje border-radius — kulaté rohy jen se „separate"
.choices-table {
border-collapse: separate;
border-spacing: 0;
}
// Sloupec s podnikem: jemná žlutá Fluttershy přecházející do růžové
.choices-table > tbody > tr > td:first-child {
background: linear-gradient(90deg, var(--mlp-fluttershy), #ffe6f2);
color: var(--mlp-twilight-deep);
font-weight: 700;
border-right: 3px solid var(--mlp-card-border);
}
&[data-bs-theme="dark"] .choices-table > tbody > tr > td:first-child {
background: linear-gradient(90deg, #2f2468, #241c55);
color: var(--mlp-twilight);
}
// Bootstrap karty (skupinové objednávky apod.) a seznamy — stejné zaoblení a lem jako ostatní panely
.card {
border: 3px solid var(--mlp-card-border);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
overflow: hidden;
background: var(--luncher-bg-card);
// Rozbalovací výběr z nabídky nesmí karta oříznout
&:has(.select-search-container) {
overflow: visible;
}
> .card-header {
background: var(--luncher-primary-light);
border-bottom: 3px solid var(--mlp-card-border);
&:first-child {
border-radius: calc(var(--luncher-radius-lg) - 3px) calc(var(--luncher-radius-lg) - 3px) 0 0;
}
}
// Průhledná tabulka — rohy pak zaobluje karta sama (i když nesmí ořezávat obsah)
.table {
--bs-table-bg: transparent;
margin-bottom: 0;
// Spodní linka posledního řádku by přečnívala přes zaoblené rohy
> :last-child > tr:last-child > * {
border-bottom-width: 0;
}
}
}
.list-group {
border-radius: var(--luncher-radius);
overflow: hidden;
border: 2px solid var(--mlp-card-border);
> .list-group-item {
border-left: none;
border-right: none;
&:first-child {
border-top: none;
}
&:last-child {
border-bottom: none;
}
}
}
.form-select, .form-control,
input[type="text"], input[type="email"], input[type="password"] {
border-radius: 999px;
border-width: 2px;
&:focus {
border-color: var(--mlp-twilight);
box-shadow: 0 0 0 4px rgb(240 106 166 / 0.25);
}
}
// ---------- Tlačítka: kulaté „bonbony" s magickou září ----------
.btn {
border-radius: 999px;
font-weight: 600;
letter-spacing: 0.2px;
transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.2s ease, filter 0.2s ease;
&:active {
transform: translateY(3px) !important;
box-shadow: 0 1px 0 var(--mlp-btn-shadow, transparent) !important;
}
}
.btn-primary {
--mlp-btn-shadow: var(--mlp-twilight-shadow);
background: linear-gradient(180deg, #b07ae6, var(--mlp-twilight-deep));
border: none;
color: #fff;
box-shadow: 0 4px 0 var(--mlp-twilight-shadow);
text-shadow: 0 1px 0 rgb(0 0 0 / 0.2);
// Magická aura Twilight při najetí
&:hover {
background: linear-gradient(180deg, #bf8cf0, #7a42c4);
box-shadow: 0 4px 0 var(--mlp-twilight-shadow), 0 0 14px rgb(236 61 143 / 0.45);
}
&:disabled {
background: #c9bdd9;
box-shadow: 0 4px 0 #a99cbc;
}
}
.btn-outline-primary {
--mlp-btn-shadow: var(--mlp-card-border);
background: var(--luncher-bg-card);
border: 2px solid var(--mlp-twilight);
color: var(--luncher-primary);
box-shadow: 0 4px 0 var(--mlp-card-border);
&:hover {
background: var(--luncher-primary-light);
color: var(--luncher-primary);
}
}
.btn-secondary {
--mlp-btn-shadow: var(--luncher-border);
background: var(--luncher-bg-card);
border: 2px solid var(--luncher-border);
color: var(--luncher-text);
box-shadow: 0 4px 0 var(--luncher-border);
}
.btn-danger {
--mlp-btn-shadow: #a8264a;
background: linear-gradient(180deg, #ff7e9a, var(--luncher-danger));
border: none;
box-shadow: 0 4px 0 #a8264a;
}
.action-icon:hover {
background: #ffe3f0;
color: var(--mlp-pinkie-deep);
}
&[data-bs-theme="dark"] .action-icon:hover {
background: #3a2462;
color: var(--mlp-pinkie);
}
// ---------- Modaly ----------
.modal-content {
border: 3px solid var(--mlp-card-border);
border-radius: var(--luncher-radius-xl);
overflow: hidden;
}
.modal-header {
background:
#{$mlp-rainbow} bottom / 100% 5px no-repeat,
linear-gradient(90deg, var(--mlp-twilight-deep), var(--mlp-twilight) 60%, var(--mlp-pinkie));
border-bottom: none;
padding-bottom: calc(var(--bs-modal-header-padding-y, 1rem) + 5px);
.modal-title, .modal-title h2 {
color: #fff;
font-weight: 700;
text-shadow: 0 2px 0 rgb(0 0 0 / 0.18);
}
.btn-close {
filter: invert(1) grayscale(1) brightness(2);
}
}
.footer {
border-top: none;
background-image: #{$mlp-rainbow};
background-size: 100% 5px;
background-repeat: no-repeat;
background-position: top;
}
}
@keyframes mlp-twinkle {
0%, 100% { transform: scale(1) rotate(0deg); opacity: 1; }
50% { transform: scale(1.35) rotate(20deg); opacity: 0.75; }
}
@media (prefers-reduced-motion: reduce) {
#{$mlp} .navbar .navbar-brand::after {
animation: none;
}
}
+371
View File
@@ -0,0 +1,371 @@
// ============================================
// STYL "UMAMUSUME" (volitelný, výchozí je jednoduchý styl)
// Inspirováno Umamusume: Pretty Derby — trávník dostihové dráhy, růžové
// a nebesky modré akcenty, zlaté lemy, zaoblené tučné písmo a „tlačítka z her".
// Aktivní, pokud má <html> atribut data-luncher-theme="umamusume".
// Jednoduchý styl (data-luncher-theme="simple") zůstává beze změny v App.scss.
// ============================================
$uma: 'html[data-luncher-theme="umamusume"]';
#{$uma} {
--uma-font: 'M PLUS Rounded 1c', 'Nunito', 'Inter', -apple-system, sans-serif;
--uma-turf: #5cc23d;
--uma-turf-deep: #2e9a3a;
--uma-turf-shadow: #1f7a2c;
--uma-pink: #ff6fa3;
--uma-pink-deep: #e84d86;
--uma-sky: #43b8f0;
--uma-gold: #ffc531;
--uma-card-border: #c6ebb2;
--uma-stripe: rgb(92 194 61 / 0.06);
--luncher-primary: var(--uma-turf-deep);
--luncher-primary-hover: #268a31;
--luncher-primary-light: #e7f8dc;
--luncher-bg: #f4fbef;
--luncher-bg-card: #ffffff;
--luncher-bg-hover: #eef8e7;
--luncher-text: #1d3322;
--luncher-text-secondary: #4a6450;
--luncher-text-muted: #8aa290;
--luncher-border: #d7ecca;
--luncher-border-light: #e6f4dc;
--luncher-navbar-bg: var(--uma-turf-deep);
--luncher-navbar-text: #ffffff;
--luncher-action-icon: var(--uma-turf-deep);
--luncher-buyer-icon: #f08c00;
--luncher-success: var(--uma-turf-deep);
--luncher-warning: #f08c00;
--luncher-danger: #e8436a;
--luncher-radius-sm: 10px;
--luncher-radius: 14px;
--luncher-radius-lg: 18px;
--luncher-radius-xl: 22px;
--luncher-shadow: 0 4px 0 rgb(46 154 58 / 0.12), 0 8px 18px -8px rgb(29 51 34 / 0.18);
--luncher-shadow-lg: 0 6px 0 rgb(46 154 58 / 0.14), 0 14px 28px -10px rgb(29 51 34 / 0.25);
&[data-bs-theme="dark"] {
// Noční dostih pod reflektory
--uma-turf: #58c93a;
--uma-turf-deep: #3cae3f;
--uma-turf-shadow: #1c5e25;
--uma-card-border: #2c3a66;
--uma-stripe: rgb(255 255 255 / 0.025);
--luncher-primary: #6fdc4a;
--luncher-primary-hover: #8ae86a;
--luncher-primary-light: #1d3a24;
--luncher-bg: #10172b;
--luncher-bg-card: #19223d;
--luncher-bg-hover: #222d4f;
--luncher-text: #f3f6ff;
--luncher-text-secondary: #b8c2e0;
--luncher-text-muted: #7d89ad;
--luncher-border: #2c3a66;
--luncher-border-light: #232e52;
--luncher-navbar-bg: #1b6b2c;
--luncher-action-icon: #6fdc4a;
--luncher-buyer-icon: var(--uma-gold);
--luncher-success: #6fdc4a;
--luncher-warning: var(--uma-gold);
--luncher-danger: #ff6f93;
--luncher-shadow: 0 4px 0 rgb(0 0 0 / 0.35), 0 10px 20px -8px rgb(0 0 0 / 0.5);
--luncher-shadow-lg: 0 6px 0 rgb(0 0 0 / 0.4), 0 16px 30px -10px rgb(0 0 0 / 0.6);
}
body {
font-family: var(--uma-font);
background-color: var(--luncher-bg);
background-image: repeating-linear-gradient(135deg, var(--uma-stripe) 0 18px, transparent 18px 36px);
background-attachment: fixed;
}
.app-container {
background: transparent;
}
h1, h2, h3, h4, .navbar-brand, .btn {
font-family: var(--uma-font);
}
// ---------- Navbar: zelený pruh se šikmými proužky a zlatým lemem ----------
.navbar {
background:
repeating-linear-gradient(115deg, rgb(255 255 255 / 0.10) 0 14px, transparent 14px 28px),
linear-gradient(90deg, var(--uma-turf-deep), var(--uma-turf)) !important;
border-bottom: 4px solid var(--uma-gold);
box-shadow: 0 3px 0 rgb(0 0 0 / 0.08);
.navbar-brand {
font-weight: 800;
letter-spacing: 0.5px;
text-shadow: 0 2px 0 var(--uma-turf-shadow), 0 0 12px rgb(255 255 255 / 0.25);
&::after {
content: '';
display: inline-block;
width: 10px;
height: 10px;
margin-left: 6px;
border-radius: 50%;
background: var(--uma-pink);
box-shadow: 0 0 0 3px rgb(255 255 255 / 0.9);
vertical-align: super;
}
}
.dropdown-menu {
border: 2px solid var(--uma-card-border);
}
}
// ---------- Titulek dne: šikmá stuha jako název dostihu ----------
.day-navigator .title {
position: relative;
display: inline-block;
padding: 6px 28px;
font-weight: 800;
font-size: 1.7rem;
color: #fff !important;
background: linear-gradient(90deg, var(--uma-pink-deep), var(--uma-pink));
clip-path: polygon(14px 0, 100% 0, calc(100% - 14px) 100%, 0 100%);
text-shadow: 0 2px 0 rgb(0 0 0 / 0.15);
&.text-muted {
background: linear-gradient(90deg, #8aa290, #a9bfae);
}
}
// ---------- Karty podniků ----------
.restaurant-card {
border: 3px solid var(--uma-card-border);
border-radius: var(--luncher-radius-lg);
&:hover {
transform: translateY(-3px);
border-color: var(--uma-turf);
}
.restaurant-header {
position: relative;
background:
repeating-linear-gradient(115deg, rgb(255 255 255 / 0.12) 0 10px, transparent 10px 20px),
linear-gradient(90deg, var(--uma-turf-deep), var(--uma-turf));
border-bottom: 3px solid var(--uma-gold);
&:hover {
background:
repeating-linear-gradient(115deg, rgb(255 255 255 / 0.12) 0 10px, transparent 10px 20px),
linear-gradient(90deg, var(--uma-pink-deep), var(--uma-pink));
}
h3 {
font-weight: 800;
text-shadow: 0 2px 0 rgb(0 0 0 / 0.18);
}
small {
color: rgb(255 255 255 / 0.85);
}
}
}
.food-table tbody tr:hover > td {
background: #fff0f6;
}
&[data-bs-theme="dark"] .food-table tbody tr:hover > td {
background: #2a2344;
}
.food-price {
color: var(--uma-pink-deep);
font-weight: 800;
}
&[data-bs-theme="dark"] .food-price {
color: var(--uma-pink);
}
// ---------- Panely a tabulka voleb ----------
.choice-section,
.choices-table,
.pizza-section,
.no-votes {
border: 3px solid var(--uma-card-border);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
}
// <table> s border-collapse: collapse ignoruje border-radius — kulaté rohy jen se „separate"
.choices-table {
border-collapse: separate;
border-spacing: 0;
}
.choices-table > tbody > tr > td:first-child {
background: linear-gradient(90deg, #ffe3ee, #fff3f8);
color: var(--uma-pink-deep);
font-weight: 800;
border-right: 3px solid var(--uma-card-border);
}
&[data-bs-theme="dark"] .choices-table > tbody > tr > td:first-child {
background: linear-gradient(90deg, #3b2446, #2a2140);
color: var(--uma-pink);
}
// Bootstrap karty (skupinové objednávky apod.) a seznamy — stejné zaoblení a lem jako ostatní panely
.card {
border: 3px solid var(--uma-card-border);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
overflow: hidden;
background: var(--luncher-bg-card);
// Rozbalovací výběr z nabídky nesmí karta oříznout
&:has(.select-search-container) {
overflow: visible;
}
> .card-header {
background: var(--luncher-primary-light);
border-bottom: 3px solid var(--uma-card-border);
&:first-child {
border-radius: calc(var(--luncher-radius-lg) - 3px) calc(var(--luncher-radius-lg) - 3px) 0 0;
}
}
// Průhledná tabulka — rohy pak zaobluje karta sama (i když nesmí ořezávat obsah)
.table {
--bs-table-bg: transparent;
margin-bottom: 0;
// Spodní linka posledního řádku by přečnívala přes zaoblené rohy
> :last-child > tr:last-child > * {
border-bottom-width: 0;
}
}
}
.list-group {
border-radius: var(--luncher-radius);
overflow: hidden;
border: 2px solid var(--uma-card-border);
> .list-group-item {
border-left: none;
border-right: none;
&:first-child {
border-top: none;
}
&:last-child {
border-bottom: none;
}
}
}
.form-select, .form-control,
input[type="text"], input[type="email"], input[type="password"] {
border-radius: 999px;
border-width: 2px;
}
// ---------- Tlačítka „ze hry": pilulka s vystouplým spodním okrajem ----------
.btn {
border-radius: 999px;
font-weight: 800;
letter-spacing: 0.2px;
transition: transform 0.08s ease, box-shadow 0.08s ease, background 0.2s ease;
&:active {
transform: translateY(3px) !important;
box-shadow: 0 1px 0 var(--uma-btn-shadow, transparent) !important;
}
}
.btn-primary {
--uma-btn-shadow: var(--uma-turf-shadow);
background: linear-gradient(180deg, #7ad85a, var(--uma-turf-deep));
box-shadow: 0 4px 0 var(--uma-turf-shadow);
text-shadow: 0 1px 0 rgb(0 0 0 / 0.2);
&:hover {
background: linear-gradient(180deg, #8be26b, #35a940);
}
&:disabled {
background: #b9cbbd;
box-shadow: 0 4px 0 #97aa9b;
}
}
.btn-outline-primary {
--uma-btn-shadow: var(--uma-card-border);
background: var(--luncher-bg-card);
border: 2px solid var(--uma-turf);
color: var(--luncher-primary);
box-shadow: 0 4px 0 var(--uma-card-border);
&:hover {
background: var(--luncher-primary-light);
color: var(--luncher-primary);
}
}
.btn-secondary {
--uma-btn-shadow: #c9d3cb;
background: var(--luncher-bg-card);
border: 2px solid var(--luncher-border);
box-shadow: 0 4px 0 var(--luncher-border);
}
.btn-danger {
--uma-btn-shadow: #b3264b;
background: linear-gradient(180deg, #ff7f9d, var(--luncher-danger));
box-shadow: 0 4px 0 #b3264b;
}
.action-icon:hover {
background: #ffe3ee;
color: var(--uma-pink-deep);
}
&[data-bs-theme="dark"] .action-icon:hover {
background: #3b2446;
color: var(--uma-pink);
}
// ---------- Modaly ----------
.modal-content {
border: 3px solid var(--uma-card-border);
border-radius: var(--luncher-radius-xl);
overflow: hidden;
}
.modal-header {
background:
repeating-linear-gradient(115deg, rgb(255 255 255 / 0.12) 0 12px, transparent 12px 24px),
linear-gradient(90deg, var(--uma-turf-deep), var(--uma-turf));
border-bottom: 3px solid var(--uma-gold);
.modal-title, .modal-title h2 {
color: #fff;
font-weight: 800;
text-shadow: 0 2px 0 rgb(0 0 0 / 0.18);
}
.btn-close {
filter: invert(1) grayscale(1) brightness(2);
}
}
.footer {
border-top: 4px solid var(--uma-gold);
}
}
+55
View File
@@ -0,0 +1,55 @@
import type { GroupLimits } from "../../../types";
/**
* Výchozí limity skupinových objednávek — stejné jako výchozí hodnoty na serveru
* (server/src/limits.ts). Platí do načtení konfigurace nebo když endpoint neodpoví.
*/
export const DEFAULT_GROUP_LIMITS: GroupLimits = {
maxNoteLength: 300,
maxSurchargeTextLength: 100,
maxNameLength: 100,
maxUrlLength: 500,
maxAmountKc: 10_000,
maxItemQuantity: 10,
maxItems: 20,
};
/** Runtime konfigurace ze serveru (GET /api/config). */
export type AppRuntimeConfig = {
/** Pokus o načtení konfigurace skončil (úspěšně i neúspěšně) */
loaded: boolean,
/** Přihlášení přes důvěryhodné hlavičky z proxy; undefined = nevíme (konfigurace se nenačetla) */
trustedHeaders?: boolean,
/** Správa obchodů je zapnutá (server má admin heslo); undefined = nevíme */
storeAdmin?: boolean,
/** Adresa aplikace Lobby s minihrami; undefined = není nastavena nebo nevíme */
lobbyUrl?: string,
groupLimits: GroupLimits,
};
let config: AppRuntimeConfig = { loaded: false, groupLimits: DEFAULT_GROUP_LIMITS };
const listeners = new Set<() => void>();
export function getAppConfig(): AppRuntimeConfig {
return config;
}
export function getGroupLimits(): GroupLimits {
return config.groupLimits;
}
/** Uloží konfiguraci ze serveru (chybějící limity doplní výchozími) a upozorní odběratele. */
export function setAppConfig(patch: Partial<Omit<AppRuntimeConfig, 'groupLimits'>> & { groupLimits?: Partial<GroupLimits> }): void {
const { groupLimits, ...rest } = patch;
config = {
...config,
...rest,
groupLimits: groupLimits ? { ...DEFAULT_GROUP_LIMITS, ...groupLimits } : config.groupLimits,
};
listeners.forEach(l => l());
}
export function subscribeAppConfig(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
+11
View File
@@ -0,0 +1,11 @@
/**
* Zda se má stisk klávesy ignorovat pro navigaci šipkami mezi dny/týdny na pozadí stránky.
* Navigace se nemá provádět, pokud uživatel píše do formulářového pole, nebo pokud je otevřený
* modální dialog (např. minihra, která klávesnici používá k ovládání) –
* jinak by se stránka na pozadí neustále přepínala a zbytečně přenačítala data ze serveru.
*/
export function isArrowNavigationBlocked(e: KeyboardEvent): boolean {
const target = e.target as HTMLElement | null;
if (target?.closest?.('input, textarea, select, [contenteditable="true"]')) return true;
return document.body.classList.contains('modal-open') || document.querySelector('.modal.show') != null;
}
+48
View File
@@ -0,0 +1,48 @@
/** Obrázky stylu Hell Girl (plamen + symbol ohně) — server je cachuje, viz server/src/confetti.ts. */
const HELLGIRL_IMAGES_URL = '/api/confetti/hellgirl';
export type HellGirlImages = { flame: string, symbol: string };
let imagesPromise: Promise<HellGirlImages | undefined> | undefined;
/** Načte URL obrázků (jednou za běh aplikace). Při chybě vrátí undefined a příště to zkusí znovu. */
export const loadHellGirlImages = (): Promise<HellGirlImages | undefined> => {
imagesPromise ??= fetch(HELLGIRL_IMAGES_URL)
.then(r => r.ok ? r.json() : undefined)
.then(images => images?.flame && images?.symbol ? images as HellGirlImages : undefined)
.catch(() => undefined)
.then(images => {
if (!images) imagesPromise = undefined;
return images;
});
return imagesPromise;
};
/**
* Z obrázku symbolu (rudý tvar na bílém pozadí) udělá masku — průhlednost podle toho, jak
* moc pixel není bílý (zelený kanál). Barvu pak symbolu dodá CSS gradientem v paletě stylu.
*/
export const createSymbolMask = (src: string, size = 96): Promise<string | undefined> => new Promise(resolve => {
const img = new Image();
img.onload = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx) return resolve(undefined);
ctx.drawImage(img, 0, 0, size, size);
const pixels = ctx.getImageData(0, 0, size, size);
const data = pixels.data;
for (let i = 0; i < data.length; i += 4) {
data[i + 3] = 255 - data[i + 1];
data[i] = data[i + 1] = data[i + 2] = 0;
}
ctx.putImageData(pixels, 0, 0);
resolve(canvas.toDataURL('image/png'));
} catch {
resolve(undefined);
}
};
img.onerror = () => resolve(undefined);
img.src = src;
});
+147
View File
@@ -0,0 +1,147 @@
import type { GroupLimits, StoreMenuDish } from "../../../types";
import { getGroupLimits } from "./appConfig";
/** Normalizace pro porovnání: malá písmena, bez diakritiky, emoji, čísel položek („28." / „13A.") a interpunkce. */
export function normalizeDishName(text: string): string {
return text
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/^\s*\*?\s*\d+[a-z]?\s*[.)]\s*/, '')
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
export type MatchedPart = {
/** Původní text části poznámky */
text: string,
quantity: number,
dish?: StoreMenuDish,
};
export type NoteMatch = {
parts: MatchedPart[],
/** Cena v Kč dle nabídky — jen pokud byly nalezeny všechny části a mají cenu */
total?: number,
};
/** Množství zapsané jako „2x burger", „2 × burger" nebo „burger 2x". */
function parseQuantity(text: string): { quantity: number, rest: string } {
const prefix = text.match(/^\s*(\d+)\s*[x×]\s*(.+)$/i);
if (prefix) return { quantity: Number(prefix[1]), rest: prefix[2] };
const suffix = text.match(/^(.+?)\s*[x×]?\s*(\d+)\s*[x×]\s*$/i);
if (suffix) return { quantity: Number(suffix[2]), rest: suffix[1] };
return { quantity: 1, rest: text };
}
/** Najde položku nabídky k textu — přesná shoda, jinak nejbližší položka obsahující text (či naopak). */
function findDish(text: string, dishes: { dish: StoreMenuDish, norm: string }[]): StoreMenuDish | undefined {
const norm = normalizeDishName(text);
if (norm.length < 3) return undefined;
const exact = dishes.find(d => d.norm === norm);
if (exact) return exact.dish;
const candidates = dishes.filter(d => d.norm.length >= 3 && (d.norm.includes(norm) || norm.includes(d.norm)));
// Nejbližší délkou = nejspecifičtější shoda („Kebab" vs. „Kebab box s hranolky")
candidates.sort((a, b) => Math.abs(a.norm.length - norm.length) - Math.abs(b.norm.length - norm.length));
return candidates[0]?.dish;
}
/**
* Rozpozná v poznámce člena objednávky položky z nabídky podniku („Twister speciál + 2x Hranolky")
* a spočítá jejich cenu. Nejprve zkusí celou poznámku (názvy jídel mohou obsahovat „+").
*/
export function matchNoteToMenu(note: string | undefined, menu: StoreMenuDish[]): NoteMatch | undefined {
if (!note?.trim() || menu.length === 0) return undefined;
const dishes = menu.map(dish => ({ dish, norm: normalizeDishName(dish.name) }));
const parts: MatchedPart[] = splitNoteItems(note, menu).map(text => {
const exact = dishes.find(d => d.norm === normalizeDishName(text));
if (exact) return { text, quantity: 1, dish: exact.dish };
const { quantity, rest } = parseQuantity(text);
return { text, quantity, dish: findDish(rest, dishes) };
});
if (!parts.some(p => p.dish)) return undefined;
const complete = parts.every(p => p.dish?.price != null);
return {
parts,
total: complete ? parts.reduce((sum, p) => sum + p.dish!.price! * p.quantity, 0) : undefined,
};
}
/** Počet položek v poznámce objednávky — části oddělené „+", včetně množství („2x Hranolky" = 2). */
export function countNoteItems(note: string | undefined, menu: StoreMenuDish[] = []): number {
return splitNoteItems(note, menu).reduce((sum, part) => sum + parseQuantity(part).quantity, 0);
}
/**
* Části poznámky oddělené „+" (tak, jak je uživatel zapsal). Pokud celá poznámka
* odpovídá jednomu jídlu z nabídky (název může obsahovat „+"), je to jediná položka.
*/
export function splitNoteItems(note: string | undefined, menu: StoreMenuDish[] = []): string[] {
const tokens = (note ?? '').split('+').map(p => p.trim()).filter(Boolean);
if (menu.length === 0 || tokens.length < 2) return tokens;
const names = new Set(menu.map(d => normalizeDishName(d.name)));
const isDish = (text: string) => names.has(normalizeDishName(text)) || names.has(normalizeDishName(parseQuantity(text).rest));
// Sousední části, které dohromady tvoří název jídla s „+" („Stripsy 6 Ks + Coleslaw"), spojíme zpět
const result: string[] = [];
for (let i = 0; i < tokens.length;) {
let j = tokens.length;
while (j > i + 1 && !isDish(tokens.slice(i, j).join(' + '))) j--;
result.push(tokens.slice(i, j).join(' + '));
i = j;
}
return result;
}
/**
* Odebere z poznámky jeden kus položky na daném indexu: „2x Hranolky" → „Hranolky",
* jinak celou část. Vrátí novou poznámku a cenu odebraného kusu dle nabídky (Kč), je-li známa.
*/
export function removeNoteItem(note: string | undefined, index: number, menu: StoreMenuDish[]): { note: string, removedPrice?: number } {
const parts = splitNoteItems(note, menu);
const part = parts[index];
if (part == null) return { note: note ?? '' };
const { quantity, rest } = parseQuantity(part);
const dishes = menu.map(dish => ({ dish, norm: normalizeDishName(dish.name) }));
const dish = dishes.find(d => d.norm === normalizeDishName(part))?.dish ?? findDish(rest, dishes);
if (quantity > 2) parts[index] = `${quantity - 1}x ${rest.trim()}`;
else if (quantity === 2) parts[index] = rest.trim();
else parts.splice(index, 1);
return { note: parts.join(' + '), removedPrice: dish?.price };
}
/**
* Přidá do poznámky jeden kus jídla. Pokud už v poznámce je (i s počtem, např. „2x Hranolky"),
* zvýší jeho počet („Hranolky" → „2x Hranolky"), jinak ho připojí za „+".
*/
export function addNoteItem(note: string | undefined, dishName: string, menu: StoreMenuDish[] = []): string {
const parts = splitNoteItems(note, menu);
const target = normalizeDishName(dishName);
const index = parts.findIndex(p => normalizeDishName(p) === target || normalizeDishName(parseQuantity(p).rest) === target);
if (index < 0) return [...parts, dishName].join(' + ');
const existing = normalizeDishName(parts[index]) === target ? { quantity: 1, rest: parts[index] } : parseQuantity(parts[index]);
parts[index] = `${existing.quantity + 1}x ${existing.rest.trim()}`;
return parts.join(' + ');
}
/** Kolik kusů daného jídla už poznámka obsahuje (0 = není tam). */
export function noteItemQuantity(note: string | undefined, dishName: string, menu: StoreMenuDish[] = []): number {
const target = normalizeDishName(dishName);
const part = splitNoteItems(note, menu).find(p => normalizeDishName(p) === target || normalizeDishName(parseQuantity(p).rest) === target);
if (!part) return 0;
return normalizeDishName(part) === target ? 1 : parseQuantity(part).quantity;
}
/**
* Vrátí důvod, proč jídlo nejde přidat (limit kusů/položek — pojistky proti překlepům,
* hodnoty z konfigurace serveru), jinak undefined.
*/
export function addItemLimitError(note: string | undefined, dishName: string, menu: StoreMenuDish[] = [], limits: GroupLimits = getGroupLimits()): string | undefined {
if (noteItemQuantity(note, dishName, menu) >= limits.maxItemQuantity) {
return `Jedné položky lze přidat nejvýše ${limits.maxItemQuantity} kusů`;
}
if (countNoteItems(note, menu) >= limits.maxItems) {
return `Objednávka může mít nejvýše ${limits.maxItems} položek`;
}
return undefined;
}
+50
View File
@@ -0,0 +1,50 @@
import type { StorePriceHistoryEntry } from "../../../types";
export type SinceStart = {
/** Od kdy se počítá (první známá verze) */
since: string,
changed: { name: string, start: number, current: number }[],
added: { name: string, price?: number }[],
removed: { name: string, price?: number }[],
};
/**
* Souhrn „od začátku": počáteční cena položky = „before" její nejstarší změny,
* aktuální = „after" nejnovější. Položka přidaná a později zase odebraná se vynechá.
*/
export function computeSinceStart(entries: StorePriceHistoryEntry[]): SinceStart | undefined {
if (entries.length === 0) return undefined;
const chronological = [...entries].reverse();
const items = new Map<string, { name: string, start?: number, current?: number, addedLater: boolean, removed: boolean }>();
for (const entry of chronological) {
for (const c of entry.changes) {
const key = c.name.trim().toLowerCase();
let item = items.get(key);
if (!item) {
item = { name: c.name, start: c.before, addedLater: c.before == null, removed: false };
items.set(key, item);
}
item.name = c.name;
item.current = c.after;
item.removed = c.after == null;
}
}
const result: SinceStart = { since: chronological[0].at, changed: [], added: [], removed: [] };
for (const item of items.values()) {
if (item.addedLater && item.removed) continue;
if (item.addedLater) result.added.push({ name: item.name, price: item.current });
else if (item.removed) result.removed.push({ name: item.name, price: item.start });
else if (item.start != null && item.current != null && item.start !== item.current) {
result.changed.push({ name: item.name, start: item.start, current: item.current });
}
}
// Největší změny nahoře
result.changed.sort((a, b) => Math.abs(b.current - b.start) - Math.abs(a.current - a.start));
return result;
}
/** Český plurál: 1 změna, 2–4 změny, 5+ změn. */
export function plural(n: number, one: string, few: string, many: string): string {
const form = n === 1 ? one : n >= 2 && n <= 4 ? few : many;
return `${n} ${form}`;
}
+37
View File
@@ -0,0 +1,37 @@
import { Store } from '../../../types';
/** Známé dovozové služby — hostname (bez www) → čitelný název pro nabídku. */
const KNOWN_SERVICES: { match: string; label: string }[] = [
{ match: 'bolt.eu', label: 'Bolt Food' },
{ match: 'wolt.com', label: 'Wolt' },
{ match: 'foodora.cz', label: 'Foodora' },
{ match: 'damejidlo.cz', label: 'Dáme jídlo' },
];
/**
* Ověří, že jde o odkaz s protokolem http(s) — jiné protokoly (např. javascript:)
* by mohly být zneužity, proto je jako odkaz nezobrazujeme.
*/
export function isHttpUrl(url?: string): boolean {
return !!url && /^https?:\/\//i.test(url);
}
/** Vrátí platné URL na nabídku daného obchodu. */
export function getStoreUrls(store?: Store): string[] {
return (store?.urls ?? []).filter(isHttpUrl);
}
/**
* Popisek URL pro výběr nabídky — u známých dovozových služeb jejich název,
* jinak hostname odkazu (např. "restaurace-u-nas.cz").
*/
export function storeUrlLabel(url: string): string {
let hostname: string;
try {
hostname = new URL(url).hostname.toLowerCase().replace(/^www\./, '');
} catch {
return url;
}
const known = KNOWN_SERVICES.find(s => hostname === s.match || hostname.endsWith(`.${s.match}`));
return known ? known.label : hostname;
}
+23
View File
@@ -0,0 +1,23 @@
/** Postavy Umamusume (chibi obrázky / portréty + hláška) — server obrázky cachuje, viz server/src/confetti.ts. */
const UMA_CHARACTERS_URL = '/api/confetti/umamusume';
/** `round` = čtvercový portrét, zobrazí se jako kulatá samolepka (chibi obrázky jsou průhledné). */
export type UmaCharacter = { name: string, quote?: string, round?: boolean, images: string[], /** portrét pro malé odznaky */ icon?: string };
let umaCharactersPromise: Promise<UmaCharacter[]> | undefined;
/**
* Načte seznam postav (jednou za běh aplikace). Při chybě vrátí prázdný seznam
* a výsledek si nepamatuje — příště se zkusí znovu (např. po restartu serveru).
*/
export function loadUmaCharacters(): Promise<UmaCharacter[]> {
umaCharactersPromise ??= fetch(UMA_CHARACTERS_URL)
.then(r => r.ok ? r.json() : [])
.then(list => Array.isArray(list) ? list.filter((c: UmaCharacter) => Array.isArray(c?.images)) : [])
.catch(() => [])
.then((list: UmaCharacter[]) => {
if (list.length === 0) umaCharactersPromise = undefined;
return list;
});
return umaCharactersPromise;
}
+4
View File
@@ -6,6 +6,10 @@ export default defineConfig({
// depending on your application, base can also be "/" // depending on your application, base can also be "/"
base: '', base: '',
plugins: [react(), viteTsconfigPaths()], plugins: [react(), viteTsconfigPaths()],
build: {
// Veřejné sourcemapy, aby Sentry umělo přeložit minifikované stack traces
sourcemap: true,
},
server: { server: {
open: true, open: true,
host: '0.0.0.0', host: '0.0.0.0',
+693 -598
View File
File diff suppressed because it is too large. Load diff
+15 -20
View File
@@ -1,36 +1,31 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
// Tento test záměrně NEPOUŽÍVÁ trusted-header – testuje reálný login formulář. // Tento soubor záměrně NEPOUŽÍVÁ výchozí trusted-header — testuje chování přihlašovací stránky.
test.use({ extraHTTPHeaders: {} }); test.use({ extraHTTPHeaders: {} });
test('uživatel se přihlásí formulářem a uvidí obsah aplikace', async ({ page }) => { test('při přihlášení přes proxy se formulář nezobrazí — bez hlavičky hláška, opakování s hlavičkou přihlásí', async ({ page }) => {
// Server běží s HTTP_REMOTE_USER_ENABLED=true, takže POST /api/login vždy vyžaduje // Server běží s HTTP_REMOTE_USER_ENABLED=true (přihlašuje proxy), takže formulář se jménem
// hlavičku remote-user. Zachytíme požadavky z formuláře (mají tělo s polem login) // nemá smysl. Bez hlavičky remote-user se zobrazí hláška s tlačítkem „Zkusit znovu".
// a přidáme hlavičku; požadavek auto-loginu (bez těla) projde bez hlavičky a selže, let sendHeader = false;
// čímž formulář zůstane viditelný.
await page.route('**/api/login', async (route) => { 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({ await route.continue({
headers: login headers: sendHeader
? { ...route.request().headers(), 'remote-user': login } ? { ...route.request().headers(), 'remote-user': 'testuser' }
: route.request().headers(), : route.request().headers(),
}); });
}); });
await page.goto('/'); await page.goto('/');
// Formulář musí být viditelný – auto-login selhal (nepřišla hlavička) const retry = page.getByRole('button', { name: 'Zkusit znovu' });
const loginInput = page.locator('#login-input'); await expect(retry).toBeVisible({ timeout: 10_000 });
await expect(loginInput).toBeVisible({ timeout: 10_000 }); await expect(page.getByText('Přihlášení probíhá přes firemní přihlášení (proxy)')).toBeVisible();
await expect(page.locator('#login-input')).toHaveCount(0);
// Vyplnění loginu a odeslání Enterem // Proxy teď identitu pošle — opakování přihlásí
await loginInput.fill('testuser'); sendHeader = true;
await loginInput.press('Enter'); await retry.click();
await expect(retry).not.toBeVisible({ timeout: 10_000 });
// 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 // JWT musí být uloženo v localStorage jako 3-dílný token
const token = await page.evaluate(() => localStorage.getItem('token')); const token = await page.evaluate(() => localStorage.getItem('token'));
+77
View File
@@ -0,0 +1,77 @@
import { test, expect } from '@playwright/test';
import { loginViaApi } from './helpers';
const DELIVERY_LABEL = 'Upozornění na doručení objednávky (Bolt Food / Wolt)';
test.beforeEach(async ({ page }) => {
await loginViaApi(page, 'e2e-user');
await page.reload();
await page.waitForLoadState('networkidle');
});
async function openSettings(page: import('@playwright/test').Page) {
await page.locator('#basic-nav-dropdown').click();
await page.locator('text=Nastavení').click();
await expect(page.locator('.modal-title')).toContainText('Nastavení', { timeout: 5_000 });
}
test('Přepínač doručení objednávky je pod výběrem času připomínky', async ({ page }) => {
await openSettings(page);
const reminderGroup = page.locator('.modal-body .mb-3').filter({ hasText: 'Připomínka výběru oběda' });
const deliveryGroup = page.locator('.modal-body .mb-3').filter({ hasText: DELIVERY_LABEL });
await expect(reminderGroup).toBeVisible();
await expect(deliveryGroup).toBeVisible();
// Přepínač musí v DOM následovat až za skupinou s časem připomínky
const order = await page.evaluate((label) => {
const groups = Array.from(document.querySelectorAll('.modal-body .mb-3'));
return {
reminder: groups.findIndex(g => g.textContent?.includes('Připomínka výběru oběda')),
delivery: groups.findIndex(g => g.textContent?.includes(label)),
};
}, DELIVERY_LABEL);
expect(order.reminder).toBeGreaterThanOrEqual(0);
expect(order.delivery).toBe(order.reminder + 1);
// Výchozí stav je vypnuto
await expect(page.locator('#boltDeliveredCheckbox')).not.toBeChecked();
});
test('Zapnutí přepínače se uloží na server a přežije znovuotevření', async ({ page }) => {
await openSettings(page);
await page.locator('#boltDeliveredCheckbox').check();
await page.locator('.modal-footer button', { hasText: 'Uložit' }).click();
await expect(page.locator('.modal')).not.toBeVisible({ timeout: 5_000 });
// Server má nastavení uložené
const token = await page.evaluate(() => localStorage.getItem('token'));
const resp = await page.request.get('/api/notifications/settings', {
headers: { Authorization: `Bearer ${token}` },
});
expect(await resp.json()).toMatchObject({ boltDeliveredPush: true });
// Po znovunačtení stránky je přepínač stále zapnutý
await page.reload();
await page.waitForLoadState('networkidle');
await openSettings(page);
await expect(page.locator('#boltDeliveredCheckbox')).toBeChecked();
});
test('Vypnutí přepínače se uloží zpět', async ({ page }) => {
await openSettings(page);
await page.locator('#boltDeliveredCheckbox').check();
await page.locator('.modal-footer button', { hasText: 'Uložit' }).click();
await expect(page.locator('.modal')).not.toBeVisible({ timeout: 5_000 });
await openSettings(page);
await page.locator('#boltDeliveredCheckbox').uncheck();
await page.locator('.modal-footer button', { hasText: 'Uložit' }).click();
await expect(page.locator('.modal')).not.toBeVisible({ timeout: 5_000 });
const token = await page.evaluate(() => localStorage.getItem('token'));
const resp = await page.request.get('/api/notifications/settings', {
headers: { Authorization: `Bearer ${token}` },
});
expect(await resp.json()).toMatchObject({ boltDeliveredPush: false });
});
+2 -2
View File
@@ -12,10 +12,10 @@ test('zobrazí mock datum 10.01.2025', async ({ page }) => {
await expect(page.locator('text=10.01')).toBeVisible({ timeout: 10_000 }); await expect(page.locator('text=10.01')).toBeVisible({ timeout: 10_000 });
}); });
test('zobrazí čtyři restaurační karty z mock dat', async ({ page }) => { test('zobrazí tři restaurační karty z mock dat', async ({ page }) => {
// Každá restaurace je obalena v .restaurant-card // Každá restaurace je obalena v .restaurant-card
const cards = page.locator('.restaurant-card'); const cards = page.locator('.restaurant-card');
await expect(cards).toHaveCount(4, { timeout: 10_000 }); await expect(cards).toHaveCount(3, { timeout: 10_000 });
}); });
test('zobrazí alespoň jedno jídlo v menu každé restaurace', async ({ page }) => { test('zobrazí alespoň jedno jídlo v menu každé restaurace', async ({ page }) => {
+1
View File
@@ -10,3 +10,4 @@ data:
REDIS_PORT: "6379" REDIS_PORT: "6379"
PORT: "3001" PORT: "3001"
HOST: "0.0.0.0" HOST: "0.0.0.0"
LOBBY_URL: "http://lobby.localhost"
+33 -2
View File
@@ -33,7 +33,10 @@
# V případě zapnutí je nutno vyplnit také HTTP_REMOTE_TRUSTED_IPS. # V případě zapnutí je nutno vyplnit také HTTP_REMOTE_TRUSTED_IPS.
# HTTP_REMOTE_USER_ENABLED=true # 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. # Seznam IP adres nebo rozsahů (CIDR, IPv4 i IPv6) oddělených čárkou, ze kterých budou akceptovány důvěryhodné hlavičky.
# Rozhoduje skutečná adresa spojení (tj. proxy před aplikací, ne klient), hlavička od jiné adresy se ignoruje.
# Uveďte co nejužší rozsah — jen adresu/síť proxy (v Kubernetes síť podů, např. 10.42.0.0/16 u RKE2), ne celé privátní rozsahy.
# Neplatná položka = server nenaběhne.
# HTTP_REMOTE_TRUSTED_IPS=127.0.0.1,192.168.1.0/24 # 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'. # Název důvěryhodné hlavičky obsahující login uživatele. Výchozí hodnota je 'remote-user'.
@@ -49,10 +52,38 @@
# Bez hesla může refresh volat každý přihlášený uživatel (podléhá rate limitu). # Bez hesla může refresh volat každý přihlášený uživatel (podléhá rate limitu).
# REFRESH_BYPASS_PASSWORD= # REFRESH_BYPASS_PASSWORD=
# Adresa aplikace Lobby s minihrami. Skrytý odkaz (3× kliknutí na logo) ji otevře v nové záložce.
# Bez vyplnění 3× kliknutí jen čeká na kód hry, která zůstala v Luncheru (kreslení).
# LOBBY_URL='https://lobby.example.com'
# Admin heslo pro správu seznamu obchodů na stránce /objednani. # Admin heslo pro správu seznamu obchodů na stránce /objednani.
# Bez hesla nelze přidávat ani odebírat obchody ze seznamu (POST/DELETE na /api/stores vrátí 403). # Bez hesla nelze přidávat ani odebírat obchody ze seznamu (POST/DELETE na /api/stores vrátí 403).
# ADMIN_PASSWORD= # ADMIN_PASSWORD=
# Interval (ms) scheduleru sledování objednávek Bolt Food. Výchozí 60000 (60 s). # Interval (ms) scheduleru sledování objednávek (Bolt Food, Wolt). Výchozí 60000 (60 s).
# Pro vývoj se simulací lze zkrátit (min. 1000), aby se změny stavu projevily rychleji. # Pro vývoj se simulací lze zkrátit (min. 1000), aby se změny stavu projevily rychleji.
# BOLT_POLL_INTERVAL_MS=3000 # BOLT_POLL_INTERVAL_MS=3000
# Sentry (volitelné) — když není vyplněno, Sentry se vůbec neaktivuje.
# Server hlásí chyby (5xx, pády, console.error/warn); console.log se k chybám přikládá jako breadcrumbs.
# SENTRY_DSN=https://...@sentry.example.com/1
# DSN pro prohlížeč (klient si ho vyzvedne z GET /api/config). Když není vyplněno, použije se SENTRY_DSN.
# SENTRY_CLIENT_DSN=https://...@sentry.example.com/2
# Interní logy Sentry SDK do konzole — pro ladění, když eventy nedorazí do Sentry.
# SENTRY_DEBUG=true
# Limity skupinových objednávek (pojistky proti překlepům, ne ochrana proti zlému úmyslu).
# Nevyplněné nebo neplatné hodnoty (ne celé kladné číslo) = výchozí hodnota. Klient je dostává přes GET /api/config.
# Max. délka poznámky člena (co si objednává), výchozí 300 znaků.
# GROUP_MAX_NOTE_LENGTH=300
# Max. délka popisu příplatku, výchozí 100 znaků.
# GROUP_MAX_SURCHARGE_TEXT_LENGTH=100
# Max. délka názvu skupiny, výchozí 100 znaků.
# GROUP_MAX_NAME_LENGTH=100
# Max. délka URL nabídky / odkazu na sledování, výchozí 500 znaků.
# GROUP_MAX_URL_LENGTH=500
# Max. výše jedné částky (útrata člena, příplatek, poplatky, doprava, spropitné, pevná sleva) v Kč, výchozí 10000.
# GROUP_MAX_AMOUNT_KC=10000
# Výběr z nabídky: max. kusů jedné položky (výchozí 10) a max. položek celkem (výchozí 20).
# GROUP_MAX_ITEM_QUANTITY=10
# GROUP_MAX_ITEMS=20
+6
View File
@@ -0,0 +1,6 @@
[
"Oprava: uložení preferovaného času odchodu i u podniku bez načteného menu",
"Oprava: načítání denního menu podniku TechTower po změně jejich webu",
"Oprava: rychlé klikání na šipky přepínání dní už nepřeskočí mimo pracovní týden",
"Letní vzhled: místo padajícího sněhu teď po stránce poletují motýli"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Oprava zobrazení stavu objednávky Bolt Food — objednávka čekající na přijetí podnikem se už nezobrazuje jako v přípravě"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Chytání motýlků: uchop myší síťku a lov poletující motýly – počet ulovených se ti počítá a ukládá"
]
+17
View File
@@ -0,0 +1,17 @@
[
"Chytání motýlků má teď mince, úrovně a ukládá se na server, takže o úlovky nepřijdeš ani po přihlášení na jiném zařízení",
"Za nachytané motýly dostáváš mince a po dosažení milníku se ti na chvíli aktivuje zlatá síťka – větší, magnetická a odolná proti ptákům",
"Vzácný zlatý motýl teď opravdu září a má vyšší hodnotu",
"Vosy postupně přibývají a otravují síťku – zaklikej je (plácačka), jinak chvíli nepůjde chytat",
"Ptáci přibývají a trhají síťku (protrhnou ji vždy); protržená síťka se sama zašije za minutu, nebo si připlatíš za okamžitou opravu",
"Můžeš si koupit plašič, který ptáky na chvíli vyžene",
"Vosy i ptáci se počítají na serveru, takže je nevynuluješ obnovením stránky",
"Denní bonus, kombo za rychlé chytání, týmový žebříček a osobní statistiky (zlatí motýli, zabité vosy, vyplašení ptáci) po kliknutí na počítadlo",
"Hra je celkově těžší: mnohem víc úrovní se strmější křivkou, dražší vylepšení a vzácnější zlatý motýl",
"Ptáci teď aktivně nalétávají na síťku a škůdců je víc",
"Občas přijde zloděj, který se plíží k tvým mincím – zaklikej ho, než ti je ukradne (chce to hodně ran)",
"Pozor i na housenku, která se plíží k úlovkům a umí ti sežrat nachytané motýly (i snížit úroveň)",
"Přidána ochrana proti podvádění (zmenšování okna) – rychlost chytání je omezená",
"Nová sezóna: statistiky začínají férově od nuly pro všechny",
"Oprava: v nasazené verzi se v počítadle vlevo dole opět zobrazuje ikonka motýlka"
]
+8
View File
@@ -0,0 +1,8 @@
[
"Nový obchod (tlačítko 🛒): utrácej mince za pomůcky i trvalá vylepšení – větší síťka, strašák na ptáky, zpevněná síťka, prémiová síťka na přání, vosí sprej, plašič a pojistka proti zloději",
"Pozor na černé můry – míchají se mezi motýly a když nějakou omylem chytíš (i magnetem), přijdeš o hodně úlovků",
"Ptáci jsou znovu výzva: je jich víc, přibývají rychleji a plašič je dražší a kratší",
"Zloději chodí častěji a lákají je bohatí hráči – vyplatí se koupit pojistku",
"Denní úkol za odměnu mincí a odznaky (achievementy) za milníky – najdeš je po kliknutí na počítadlo",
"V noci občas přilétne netopýr, který loví motýly, a pavouk umí zamotat síťku pavučinou – odeženeš je klikáním"
]
+5
View File
@@ -0,0 +1,5 @@
[
"Přepínač Hraní / Objednávání: ve výchozím režimu motýli jen poletují a nic neruší objednávání; hru zapneš tlačítkem vpravo dole a herní vrstva pak odstíní kliknutí, aby ses při hraní neproklikl do objednávky",
"Vylepšení „větší síťka“ teď síťku i viditelně zvětší (a projeví se hned po koupi)",
"Občas proběhne rychlá motýlí inspekce – chyť označeného zářícího motýla; férových hráčů se to skoro nedotkne, na roboty ale platí (a recidivisty čeká i chvilka ve vězení)"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Upozornění na doručení skupinové objednávky sledované přes Bolt Food (zapíná se v Nastavení → Notifikace)"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Na stránce statistik lze stáhnout vlastní přehled stravování za vybraný měsíc ve formátu Excel, CSV nebo JSON (včetně vybraného jídla, poznámky a částky u objednávek po slevě)"
]
+4
View File
@@ -0,0 +1,4 @@
[
"Sledování objednávky nově i přes Wolt — stačí u objednané skupiny vložit odkaz z track.wolt.com",
"Upozornění na doručení objednávky nyní chodí i u objednávek sledovaných přes Wolt"
]
Loaded 100 of 198 files, more files were not shown because too many files have changed in this diff. Show more