Compare commits

Author SHA1 Message Date
Vít Kilián 2a5eb07778 feat: chat v lobby
CI / Build and push Docker image (push) Successful in 1m4s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 31s
CI / Playwright E2E tests (push) Successful in 2m35s
CI / Server unit tests (push) Successful in 27s
CI / Build client (push) Successful in 49s
CI / Notify (push) Successful in 2s
2026-10-21 11:52:08 +02:00
Ondřej Anděl 9a3987a2fa feat: zadávání cen
CI / Build and push Docker image (push) Successful in 45s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 37s
CI / Playwright E2E tests (push) Successful in 2m15s
CI / Server unit tests (push) Successful in 28s
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 / Build and push Docker image (push) Successful in 46s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 30s
CI / Playwright E2E tests (push) Successful in 1m57s
CI / Server unit tests (push) Successful in 26s
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 / Build and push Docker image (push) Successful in 46s
CI / Generate TypeScript types (push) Successful in 13s
CI / Build server (push) Successful in 33s
CI / Playwright E2E tests (push) Successful in 1m54s
CI / Server unit tests (push) Successful in 27s
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 / Build and push Docker image (push) Successful in 44s
CI / Generate TypeScript types (push) Successful in 14s
CI / Build server (push) Successful in 34s
CI / Playwright E2E tests (push) Successful in 1m54s
CI / Server unit tests (push) Successful in 28s
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 / Build and push Docker image (push) Successful in 52s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 29s
CI / Playwright E2E tests (push) Successful in 2m2s
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 / Build and push Docker image (push) Successful in 45s
CI / Generate TypeScript types (push) Successful in 11s
CI / Build server (push) Successful in 32s
CI / Playwright E2E tests (push) Successful in 2m38s
CI / Server unit tests (push) Successful in 27s
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 / Build and push Docker image (push) Successful in 1m11s
CI / Generate TypeScript types (push) Successful in 16s
CI / Build server (push) Successful in 31s
CI / Playwright E2E tests (push) Successful in 2m16s
CI / Server unit tests (push) Successful in 29s
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 / Build and push Docker image (push) Canceled after 0s
CI / Generate TypeScript types (push) Canceled after 0s
CI / Build server (push) Canceled after 0s
CI / Playwright E2E tests (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 / Build and push Docker image (push) Successful in 45s
CI / Generate TypeScript types (push) Successful in 11s
CI / Build server (push) Successful in 31s
CI / Playwright E2E tests (push) Successful in 1m58s
CI / Server unit tests (push) Successful in 27s
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 / Build and push Docker image (push) Successful in 47s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 29s
CI / Playwright E2E tests (push) Successful in 2m24s
CI / Server unit tests (push) Successful in 27s
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 / Build and push Docker image (push) Successful in 46s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 29s
CI / Playwright E2E tests (push) Successful in 2m4s
CI / Server unit tests (push) Successful in 27s
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 / Build and push Docker image (push) Successful in 46s
CI / Generate TypeScript types (push) Successful in 11s
CI / Build server (push) Successful in 29s
CI / Playwright E2E tests (push) Successful in 1m52s
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 / Build and push Docker image (push) Successful in 46s
CI / Generate TypeScript types (push) Successful in 11s
CI / Build server (push) Successful in 29s
CI / Playwright E2E tests (push) Successful in 2m22s
CI / Server unit tests (push) Successful in 27s
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 / Build and push Docker image (push) Successful in 49s
CI / Generate TypeScript types (push) Successful in 11s
CI / Build server (push) Successful in 31s
CI / Playwright E2E tests (push) Successful in 1m58s
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 / Build and push Docker image (push) Successful in 45s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 29s
CI / Playwright E2E tests (push) Successful in 2m2s
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 / Build and push Docker image (push) Successful in 47s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 31s
CI / Playwright E2E tests (push) Successful in 1m59s
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 / Build and push Docker image (push) Canceled after 0s
CI / Generate TypeScript types (push) Canceled after 7s
CI / Build server (push) Canceled after 0s
CI / Playwright E2E tests (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
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 / Build and push Docker image (push) Successful in 46s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 29s
CI / Playwright E2E tests (push) Successful in 1m53s
CI / Server unit tests (push) Successful in 26s
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 / Build and push Docker image (push) Successful in 46s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 30s
CI / Playwright E2E tests (push) Successful in 1m51s
CI / Server unit tests (push) Successful in 27s
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 / Build and push Docker image (push) Successful in 57s
CI / Generate TypeScript types (push) Successful in 11s
CI / Build server (push) Successful in 30s
CI / Playwright E2E tests (push) Successful in 1m53s
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 / Build and push Docker image (push) Successful in 1m1s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 31s
CI / Playwright E2E tests (push) Successful in 1m50s
CI / Server unit tests (push) Successful in 27s
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 / Build and push Docker image (push) Skipped
CI / Generate TypeScript types (push) Successful in 11s
CI / Build server (push) Successful in 29s
CI / Playwright E2E tests (push) Failing after 2m44s
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 / Build and push Docker image (push) Successful in 47s
CI / Generate TypeScript types (push) Successful in 11s
CI / Build server (push) Successful in 29s
CI / Playwright E2E tests (push) Successful in 2m0s
CI / Server unit tests (push) Successful in 30s
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 / Build and push Docker image (push) Successful in 45s
CI / Generate TypeScript types (push) Successful in 11s
CI / Build server (push) Successful in 27s
CI / Playwright E2E tests (push) Successful in 1m39s
CI / Server unit tests (push) Successful in 31s
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 / Build and push Docker image (push) Successful in 53s
CI / Generate TypeScript types (push) Successful in 11s
CI / Build server (push) Successful in 36s
CI / Playwright E2E tests (push) Successful in 1m48s
CI / Server unit tests (push) Successful in 26s
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 / Build and push Docker image (push) Successful in 3m39s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 1m48s
CI / Playwright E2E tests (push) Successful in 2m26s
CI / Server unit tests (push) Successful in 25s
CI / Build client (push) Successful in 42s
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 / Build and push Docker image (push) Successful in 49s
CI / Generate TypeScript types (push) Successful in 12s
CI / Build server (push) Successful in 32s
CI / Playwright E2E tests (push) Successful in 1m47s
CI / Server unit tests (push) Successful in 26s
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 / Build and push Docker image (push) Successful in 49s
CI / Generate TypeScript types (push) Successful in 12s
CI / Build server (push) Successful in 30s
CI / Playwright E2E tests (push) Successful in 3m55s
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 / Build and push Docker image (push) Successful in 44s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 27s
CI / Playwright E2E tests (push) Successful in 1m50s
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 / Build and push Docker image (push) Successful in 45s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 58s
CI / Playwright E2E tests (push) Successful in 1m47s
CI / Server unit tests (push) Successful in 26s
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 / Build and push Docker image (push) Successful in 46s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 28s
CI / Playwright E2E tests (push) Successful in 1m50s
CI / Server unit tests (push) Successful in 25s
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 / Build and push Docker image (push) Successful in 46s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 32s
CI / Playwright E2E tests (push) Successful in 1m53s
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 / Build and push Docker image (push) Successful in 48s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 28s
CI / Playwright E2E tests (push) Successful in 1m49s
CI / Server unit tests (push) Successful in 26s
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 / Build and push Docker image (push) Successful in 51s
CI / Generate TypeScript types (push) Successful in 11s
CI / Build server (push) Successful in 29s
CI / Playwright E2E tests (push) Successful in 1m52s
CI / Server unit tests (push) Successful in 25s
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 / Build and push Docker image (push) Successful in 1m15s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 29s
CI / Playwright E2E tests (push) Successful in 1m43s
CI / Server unit tests (push) Successful in 24s
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 / Build and push Docker image (push) Successful in 3m2s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 27s
CI / Playwright E2E tests (push) Successful in 1m44s
CI / Server unit tests (push) Successful in 24s
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 / Build and push Docker image (push) Skipped
CI / Generate TypeScript types (push) Successful in 1m1s
CI / Build server (push) Successful in 28s
CI / Playwright E2E tests (push) Successful in 1m43s
CI / Server unit tests (push) Failing after 36s
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 / Build and push Docker image (push) Successful in 45s
CI / Generate TypeScript types (push) Successful in 11s
CI / Build server (push) Successful in 27s
CI / Playwright E2E tests (push) Successful in 1m44s
CI / Server unit tests (push) Successful in 25s
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 / Build and push Docker image (push) Successful in 46s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 30s
CI / Playwright E2E tests (push) Successful in 1m53s
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 / Build and push Docker image (push) Successful in 48s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 28s
CI / Playwright E2E tests (push) Successful in 1m43s
CI / Server unit tests (push) Successful in 26s
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 / Build and push Docker image (push) Successful in 46s
CI / Generate TypeScript types (push) Successful in 11s
CI / Build server (push) Successful in 29s
CI / Playwright E2E tests (push) Successful in 1m43s
CI / Server unit tests (push) Successful in 27s
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 / Build and push Docker image (push) Successful in 1m9s
CI / Generate TypeScript types (push) Successful in 17s
CI / Build server (push) Successful in 32s
CI / Playwright E2E tests (push) Successful in 1m47s
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 / Build and push Docker image (push) Successful in 45s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 28s
CI / Playwright E2E tests (push) Successful in 1m47s
CI / Server unit tests (push) Successful in 26s
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 / Build and push Docker image (push) Successful in 46s
CI / Generate TypeScript types (push) Successful in 11s
CI / Build server (push) Successful in 28s
CI / Playwright E2E tests (push) Successful in 1m45s
CI / Server unit tests (push) Successful in 25s
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 / Build and push Docker image (push) Successful in 48s
CI / Generate TypeScript types (push) Successful in 16s
CI / Build server (push) Successful in 28s
CI / Playwright E2E tests (push) Successful in 1m46s
CI / Server unit tests (push) Successful in 25s
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 / Build and push Docker image (push) Successful in 48s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 29s
CI / Playwright E2E tests (push) Successful in 1m46s
CI / Server unit tests (push) Successful in 24s
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 / Build and push Docker image (push) Successful in 53s
CI / Generate TypeScript types (push) Successful in 13s
CI / Build server (push) Successful in 28s
CI / Playwright E2E tests (push) Successful in 1m47s
CI / Server unit tests (push) Successful in 25s
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 / Build and push Docker image (push) Successful in 1m1s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 28s
CI / Playwright E2E tests (push) Successful in 1m53s
CI / Server unit tests (push) Successful in 25s
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 / Build and push Docker image (push) Successful in 53s
CI / Generate TypeScript types (push) Successful in 12s
CI / Build server (push) Successful in 28s
CI / Playwright E2E tests (push) Successful in 1m48s
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 / Build and push Docker image (push) Successful in 1m6s
CI / Generate TypeScript types (push) Successful in 15s
CI / Build server (push) Successful in 31s
CI / Playwright E2E tests (push) Successful in 1m30s
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 / Build and push Docker image (push) Successful in 49s
CI / Generate TypeScript types (push) Successful in 15s
CI / Build server (push) Successful in 29s
CI / Playwright E2E tests (push) Successful in 1m33s
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 / Build and push Docker image (push) Successful in 45s
CI / Generate TypeScript types (push) Successful in 12s
CI / Build server (push) Successful in 29s
CI / Playwright E2E tests (push) Successful in 1m25s
CI / Server unit tests (push) Successful in 22s
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 / Build and push Docker image (push) Successful in 44s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 27s
CI / Playwright E2E tests (push) Successful in 1m25s
CI / Server unit tests (push) Successful in 23s
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 / Build and push Docker image (push) Successful in 42s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 35s
CI / Playwright E2E tests (push) Successful in 1m27s
CI / Server unit tests (push) Successful in 23s
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 / Build and push Docker image (push) Successful in 43s
CI / Generate TypeScript types (push) Successful in 13s
CI / Build server (push) Successful in 26s
CI / Playwright E2E tests (push) Successful in 1m29s
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 / Playwright E2E tests (push) Successful in 1m27s
CI / Notify (push) Successful in 2s
CI / Server unit tests (push) Successful in 22s
CI / Build client (push) Successful in 42s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 27s
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 / Playwright E2E tests (push) Has been cancelled
CI / Notify (push) Has been cancelled
CI / Server unit tests (push) Successful in 22s
CI / Build client (push) Has been cancelled
CI / Generate TypeScript types (push) Successful in 19s
CI / Build server (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 / Playwright E2E tests (push) Successful in 1m40s
CI / Notify (push) Successful in 1s
CI / Server unit tests (push) Successful in 26s
CI / Build client (push) Successful in 41s
CI / Generate TypeScript types (push) Successful in 14s
CI / Build server (push) Successful in 54s
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 / Playwright E2E tests (push) Successful in 1m34s
CI / Notify (push) Successful in 2s
CI / Server unit tests (push) Successful in 23s
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 / Playwright E2E tests (push) Successful in 1m28s
CI / Notify (push) Successful in 2s
CI / Server unit tests (push) Successful in 22s
CI / Build client (push) Successful in 39s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 27s
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 / Playwright E2E tests (push) Successful in 1m32s
CI / Notify (push) Has been cancelled
CI / Server unit tests (push) Successful in 23s
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 / Playwright E2E tests (push) Successful in 1m20s
CI / Notify (push) Successful in 2s
CI / Server unit tests (push) Successful in 21s
CI / Build client (push) Successful in 37s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 25s
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 / Playwright E2E tests (push) Successful in 1m18s
CI / Notify (push) Successful in 1s
CI / Server unit tests (push) Successful in 20s
CI / Build client (push) Successful in 36s
CI / Generate TypeScript types (push) Successful in 18s
CI / Build server (push) Successful in 24s
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 / Playwright E2E tests (push) Has been skipped
CI / Notify (push) Successful in 2s
CI / Server unit tests (push) Has been skipped
CI / Build client (push) Has been skipped
CI / Generate TypeScript types (push) Failing after 13m15s
CI / Build server (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
batmanisko 791ccc1af6 docs: pokyn pro zápis nových funkcí do changelogu
CI / Build and push Docker image (push) Successful in 40s
CI / Playwright E2E tests (push) Successful in 1m18s
CI / Notify (push) Successful in 2s
CI / Server unit tests (push) Successful in 20s
CI / Build client (push) Successful in 35s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 23s
Přidána sekce do CLAUDE.md, která vyžaduje zaznamenání uživatelsky viditelných změn do server/changelogs/YYYY-MM-DD.json.
2026-06-17 12:44:30 +02:00
batmanisko 6a2bffa31f feat: nový krok „Vyzvedávání" ve stepperu Bolt Food
CI / Build and push Docker image (push) Successful in 44s
CI / Playwright E2E tests (push) Successful in 1m18s
CI / Notify (push) Successful in 2s
CI / Server unit tests (push) Successful in 21s
CI / Build client (push) Successful in 35s
CI / Generate TypeScript types (push) Successful in 14s
CI / Build server (push) Successful in 25s
Krok se aktivuje, když je kurýr u podniku a jídlo je zároveň označené
jako hotové k vyzvednutí — samotný arrived_to_provider stupínek
nezvedá, aby se neopakovala chyba Bolt UI, které „vyzvedávání" hlásí
i během přípravy.
2026-06-16 14:10:26 +02:00
mates 364d46bc18 fix: oprava zobrazení ikonky o zaplacení u historických objednávek
CI / Build and push Docker image (push) Successful in 40s
CI / Playwright E2E tests (push) Successful in 1m18s
CI / Notify (push) Successful in 2s
CI / Server unit tests (push) Successful in 22s
CI / Build client (push) Successful in 36s
CI / Generate TypeScript types (push) Successful in 21s
CI / Build server (push) Successful in 35s
2026-06-12 21:55:25 +02:00
mates 8c3c47dba1 Opravy UI na stránkách objednání 2026-06-12 21:07:19 +02:00
mates 34f6ba520e feat: podpora simulace objednávek z Bolt Food ve vývoji 2026-06-12 19:44:20 +02:00
mates bcec015c37 feat: skript pro import dat z Redis databáze 2026-06-12 19:19:36 +02:00
batmaniskoandClaude Fable 5 c404a3a03b fix: správné mapování stavu waiting_delivery ve stepperu Bolt
CI / Build and push Docker image (push) Successful in 42s
CI / Playwright E2E tests (push) Successful in 1m22s
CI / Notify (push) Successful in 1s
CI / Server unit tests (push) Successful in 20s
CI / Build client (push) Successful in 1m10s
CI / Generate TypeScript types (push) Successful in 12s
CI / Build server (push) Successful in 28s
Stav waiting_delivery znamená "jídlo čeká v podniku na vyzvednutí",
heuristika ho ale kvůli slovu "delivery" mapovala na krok "Na cestě".

- waiting_delivery (a obecně waiting_*) se nyní mapuje na "Příprava"
- server nově ukládá i stav kurýra (boltCourierState z courier.state);
  krok "Na cestě" se aktivuje až když kurýr objednávku skutečně veze
  (picked_up, heading_to_client, ...), kurýr u podniku zůstává v "Příprava"
- tooltip stepperu zobrazuje oba raw stavy pro snadnější diagnostiku
- regresní test s reálnou odpovědí Bolt API

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:55:21 +02:00
batmaniskoandClaude Fable 5 84b95c6c70 feat: zobrazení stavu objednávky Bolt Food jako progress stepper
CI / Build and push Docker image (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 1m22s
CI / Notify (push) Successful in 2s
CI / Server unit tests (push) Successful in 21s
CI / Build client (push) Successful in 35s
CI / Generate TypeScript types (push) Successful in 9s
CI / Build server (push) Successful in 24s
Jak to funguje:
- OrderGroup má nové pole boltOrderState (raw order_state z Bolt API).
  Polling scheduler ho ukládá při každé změně a rozesílá přes Socket.io,
  takže stepper se posouvá živě všem uživatelům.
- Komponenta BoltOrderProgress vykresluje čtyři kroky
  (Přijato → Příprava → Na cestě → Doručeno) pod časy skupiny.
  Známé stavy se mapují explicitně, neznámé heuristikou podle
  klíčových slov, zrušená objednávka se zobrazí červeně.
  Tooltip ukazuje raw stav, aktivní krok pulzuje, dokud sledování běží.
- Po doručení (nebo zmizení objednávky z API) se token smaže,
  ale boltOrderState zůstává "delivered" — dokončený stepper je vidět
  po zbytek dne. Vynuluje se při změně/zrušení odkazu nebo návratu
  skupiny do stavu uzamčeno.
- Nastavení odkazu nově spustí okamžitý poll, stepper se tak objeví
  do vteřiny místo čekání na další tik scheduleru.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:28:26 +02:00
mates 9152425d2b feat: přehlednější zobrazení QR kódů, oprava zobrazení na stránce objednání
CI / Build and push Docker image (push) Successful in 46s
CI / Playwright E2E tests (push) Successful in 1m20s
CI / Notify (push) Successful in 3s
CI / Server unit tests (push) Successful in 21s
CI / Build client (push) Successful in 37s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 30s
2026-06-11 11:45:24 +02:00
mates b42b051e6f feat: uživatelsky přívětivější možnost zadání sledovací URL pro Bolt Food
CI / Build and push Docker image (push) Successful in 40s
CI / Playwright E2E tests (push) Successful in 1m24s
CI / Notify (push) Successful in 2s
CI / Server unit tests (push) Successful in 22s
CI / Build client (push) Successful in 37s
CI / Generate TypeScript types (push) Successful in 13s
CI / Build server (push) Successful in 29s
2026-06-10 19:28:46 +02:00
mates 88b9e20e2d feat: proklik na stránky podniku ze stránky objednávek
CI / Build and push Docker image (push) Successful in 39s
CI / Playwright E2E tests (push) Successful in 1m18s
CI / Notify (push) Successful in 2s
CI / Server unit tests (push) Successful in 21s
CI / Build client (push) Successful in 37s
CI / Generate TypeScript types (push) Successful in 9s
CI / Build server (push) Successful in 25s
2026-06-10 19:09:20 +02:00
mates cc09ddbd2c feat: možnost označení návrhu jako vyřešeného (resolved)
CI / Build and push Docker image (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 1m18s
CI / Notify (push) Successful in 2s
CI / Server unit tests (push) Successful in 21s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 24s
CI / Build client (push) Successful in 36s
2026-06-10 18:38:55 +02:00
batmaniskoandClaude Fable 5 491ec25b52 feat: automatické sledování doručení objednávky přes Bolt Food
CI / Build and push Docker image (push) Successful in 43s
CI / Playwright E2E tests (push) Successful in 1m25s
CI / Notify (push) Successful in 2s
CI / Server unit tests (push) Successful in 21s
CI / Build client (push) Successful in 36s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 24s
Zakladatel skupiny může na stránce objednání vložit sdílecí odkaz
Bolt Food. Server pak každou minutu dotazuje veřejné Bolt API
a automaticky aktualizuje čas doručení skupiny (deliveryAt).
Sledování se samo ukončí po doručení, zrušení objednávky nebo
opakovaných chybách. Leader lease vytažena do znovupoužitelného
modulu leaderLease.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:22:07 +02:00
batmanisko 1df21edc1a fix: oprava updateData v Redis storage (node-redis v5 nemá executeIsolated)
CI / Build and push Docker image (push) Successful in 36s
CI / Playwright E2E tests (push) Successful in 1m18s
CI / Notify (push) Successful in 1s
CI / Server unit tests (push) Successful in 19s
CI / Build client (push) Successful in 35s
CI / Generate TypeScript types (push) Successful in 9s
CI / Build server (push) Successful in 23s
2026-06-10 11:23:20 +02:00
batmanisko df5423511f feat: podpora high-availability a multi-replica nasazení
- Socket.io Redis adapter pro sdílený stav přes repliky
- graceful shutdown serveru
- WATCH/MULTI v updateData pro race-condition-safe aktualizace
- lease mechanismus pro push reminder (zabrání duplicitnímu odesílání)
- k8s/ manifesty pro testovací kind cluster
- Dockerfile: opraven EXPOSE port na 3001
- .gitignore: ignorovány Claude pracovní soubory
2026-06-10 11:23:20 +02:00
mates 17132d4124 feat: nová stránka pro návrhy na vylepšení
CI / Build and push Docker image (push) Successful in 40s
CI / Playwright E2E tests (push) Successful in 1m18s
CI / Notify (push) Successful in 2s
CI / Server unit tests (push) Successful in 21s
CI / Build client (push) Successful in 38s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 24s
2026-06-05 19:15:46 +02:00
mates f28f127a92 feat: zvýraznění dnů v historii obsahujících objednávky
CI / Build and push Docker image (push) Successful in 40s
CI / Playwright E2E tests (push) Successful in 1m17s
CI / Notify (push) Successful in 2s
CI / Server unit tests (push) Successful in 20s
CI / Generate TypeScript types (push) Successful in 12s
CI / Build server (push) Successful in 24s
CI / Build client (push) Successful in 36s
2026-06-05 14:50:33 +02:00
mates fb84bff687 feat: možnost zobrazení objednávek z historie
CI / Build and push Docker image (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 1m17s
CI / Notify (push) Successful in 2s
CI / Server unit tests (push) Successful in 20s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 23s
CI / Build client (push) Successful in 33s
2026-06-05 14:11:39 +02:00
mates c85842267a feat: podpora neplatících osob u objednávek
CI / Build and push Docker image (push) Successful in 39s
CI / Playwright E2E tests (push) Successful in 1m17s
CI / Notify (push) Successful in 2s
CI / Server unit tests (push) Successful in 20s
CI / Build client (push) Successful in 33s
CI / Generate TypeScript types (push) Successful in 14s
CI / Build server (push) Successful in 24s
2026-06-05 11:31:55 +02:00
mates c2bbf7ea60 fix: opravy generování QR kódů, zobrazení také na stránce objednání
CI / Build and push Docker image (push) Successful in 49s
CI / Playwright E2E tests (push) Successful in 1m18s
CI / Notify (push) Successful in 3s
CI / Server unit tests (push) Successful in 21s
CI / Build client (push) Successful in 34s
CI / Generate TypeScript types (push) Successful in 14s
CI / Build server (push) Successful in 25s
2026-06-05 10:37:58 +02:00
mates 318d188495 fix: oprava pro aktuální podobu TechTower
CI / Build and push Docker image (push) Successful in 49s
CI / Playwright E2E tests (push) Successful in 1m21s
CI / Notify (push) Successful in 2s
CI / Server unit tests (push) Successful in 21s
CI / Build client (push) Successful in 33s
CI / Generate TypeScript types (push) Successful in 11s
CI / Build server (push) Successful in 23s
2026-06-03 10:04:36 +02:00
mates a26d6cf85c feat: vylepšená podpora themes
CI / Build and push Docker image (push) Successful in 2m46s
CI / Playwright E2E tests (push) Successful in 1m20s
CI / Notify (push) Successful in 1s
CI / Server unit tests (push) Successful in 30s
CI / Build client (push) Successful in 36s
CI / Generate TypeScript types (push) Successful in 1m3s
CI / Build server (push) Successful in 25s
2026-05-20 12:51:46 +02:00
mates 640c7ed41d feat: podpora themes
CI / Build and push Docker image (push) Successful in 42s
CI / Playwright E2E tests (push) Successful in 1m16s
CI / Notify (push) Successful in 1s
CI / Server unit tests (push) Successful in 21s
CI / Build client (push) Successful in 33s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 24s
2026-05-14 21:36:56 +02:00
mates a166634db8 fix: oprava zobrazení cen v našeptávači Pizza Chefie
CI / Build and push Docker image (push) Successful in 1m4s
CI / Playwright E2E tests (push) Successful in 1m16s
CI / Notify (push) Successful in 2s
CI / Server unit tests (push) Successful in 20s
CI / Build client (push) Successful in 36s
CI / Generate TypeScript types (push) Successful in 13s
CI / Build server (push) Successful in 23s
2026-05-14 10:12:14 +02:00
mates 916766450a docs: zrušení neaktuálního TODO.md
CI / Build and push Docker image (push) Successful in 44s
CI / Playwright E2E tests (push) Successful in 1m18s
CI / Notify (push) Successful in 1s
CI / Server unit tests (push) Successful in 20s
CI / Build client (push) Successful in 38s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 25s
2026-05-10 08:47:06 +02:00
mates 5e596c3b99 refactor: opravy dle SonarQube
CI / Build and push Docker image (push) Has been cancelled
CI / Playwright E2E tests (push) Has been cancelled
CI / Notify (push) Has been cancelled
CI / Server unit tests (push) Successful in 20s
CI / Build client (push) Successful in 33s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 29s
2026-05-10 08:44:39 +02:00
mates 3ba5fdd086 feat: vylepšení funkce objednávání
CI / Build and push Docker image (push) Has been cancelled
CI / Playwright E2E tests (push) Has been cancelled
CI / Notify (push) Has been cancelled
CI / Server unit tests (push) Successful in 3m25s
CI / Build client (push) Successful in 41s
CI / Generate TypeScript types (push) Successful in 1m24s
CI / Build server (push) Successful in 26s
2026-05-10 08:24:01 +02:00
mates 03f4e438a3 test: oprava Playwright testů
CI / Build and push Docker image (push) Successful in 43s
CI / Playwright E2E tests (push) Successful in 1m17s
CI / Notify (push) Successful in 2s
CI / Server unit tests (push) Successful in 21s
CI / Build client (push) Successful in 33s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 24s
2026-05-07 17:17:21 +02:00
mates b591411d10 test: oprava Playwright testů
CI / Build and push Docker image (push) Has been skipped
CI / Playwright E2E tests (push) Failing after 17m56s
CI / Notify (push) Successful in 2s
CI / Server unit tests (push) Successful in 19s
CI / Build client (push) Successful in 33s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 27s
2026-05-07 14:52:42 +02:00
mates 8a588cf486 fix: oprava přesměrování
CI / Build and push Docker image (push) Has been skipped
CI / Playwright E2E tests (push) Failing after 4m25s
CI / Notify (push) Successful in 3s
CI / Server unit tests (push) Successful in 20s
CI / Build client (push) Successful in 4m37s
CI / Generate TypeScript types (push) Successful in 9s
CI / Build server (push) Successful in 24s
2026-05-07 14:36:23 +02:00
mates 0e4dc061b8 fix: oprava refresh na stránce /objednani 2026-05-07 14:13:30 +02:00
mates 7fd3ba0fc4 fix: padding kontrolní číslice IBAN
CI / Build and push Docker image (push) Successful in 5m16s
CI / Playwright E2E tests (push) Successful in 1m18s
CI / Notify (push) Successful in 45s
CI / Build client (push) Successful in 51s
CI / Server unit tests (push) Successful in 4m0s
CI / Generate TypeScript types (push) Successful in 35s
CI / Build server (push) Successful in 47s
2026-05-07 14:07:36 +02:00
mates 94b8f0a452 fix: oprava zaokrouhlování
CI / Build and push Docker image (push) Successful in 9m29s
CI / Playwright E2E tests (push) Successful in 1m18s
CI / Notify (push) Successful in 10s
CI / Build client (push) Successful in 45s
CI / Server unit tests (push) Successful in 3m20s
CI / Generate TypeScript types (push) Successful in 38s
CI / Build server (push) Successful in 25s
2026-05-07 13:35:44 +02:00
mates 3e6ecd4e6a fix: oprava UI
CI / Build and push Docker image (push) Has been cancelled
CI / Playwright E2E tests (push) Successful in 1m18s
CI / Notify (push) Has been cancelled
CI / Server unit tests (push) Successful in 2m54s
CI / Build client (push) Successful in 34s
CI / Generate TypeScript types (push) Successful in 1m21s
CI / Build server (push) Successful in 47s
2026-05-07 13:29:30 +02:00
mates f12dc7b562 feat: celková částka objednávky, proklik na stránku s objednávkami
CI / Build and push Docker image (push) Has been cancelled
CI / Playwright E2E tests (push) Has been cancelled
CI / Notify (push) Has been cancelled
CI / Server unit tests (push) Successful in 20s
CI / Generate TypeScript types (push) Successful in 11s
CI / Build server (push) Successful in 33s
CI / Build client (push) Successful in 6m33s
2026-05-07 13:17:45 +02:00
mates 8aef00ab05 fix: počítání částek v haléřích z důvodu přesnosti
CI / Build and push Docker image (push) Successful in 35s
CI / Playwright E2E tests (push) Successful in 1m20s
CI / Notify (push) Successful in 2s
CI / Server unit tests (push) Successful in 55s
CI / Build client (push) Successful in 33s
CI / Generate TypeScript types (push) Successful in 21s
CI / Build server (push) Successful in 25s
2026-05-07 13:09:15 +02:00
mates d91c8db49c fix: revert yarn
CI / Generate TypeScript types (pull_request) Successful in 18s
CI / Playwright E2E tests (pull_request) Successful in 7m24s
CI / Build and push Docker image (push) Successful in 1m52s
CI / Build and push Docker image (pull_request) Has been skipped
CI / Playwright E2E tests (push) Successful in 1m19s
CI / Notify (push) Successful in 2s
CI / Build client (pull_request) Successful in 5m10s
CI / Notify (pull_request) Has been skipped
CI / Build server (pull_request) Successful in 26s
CI / Server unit tests (push) Successful in 20s
CI / Server unit tests (pull_request) Successful in 23s
CI / Generate TypeScript types (push) Successful in 11s
CI / Build server (push) Successful in 59s
CI / Build client (push) Successful in 38s
2026-05-07 11:04:20 +02:00
mates d8714b2086 fix: oprava buildu
CI / Generate TypeScript types (pull_request) Successful in 23s
CI / Build and push Docker image (push) Has been skipped
CI / Playwright E2E tests (pull_request) Successful in 1m18s
CI / Build and push Docker image (pull_request) Has been skipped
CI / Playwright E2E tests (push) Successful in 1m19s
CI / Notify (push) Successful in 10s
CI / Build client (pull_request) Successful in 1m40s
CI / Notify (pull_request) Has been skipped
CI / Server unit tests (push) Successful in 21s
CI / Build server (pull_request) Successful in 25s
CI / Generate TypeScript types (push) Successful in 11s
CI / Build client (push) Successful in 34s
CI / Server unit tests (pull_request) Successful in 21s
CI / Build server (push) Successful in 1m4s
2026-05-07 10:50:40 +02:00
mates c7f78cf2c9 feat: vylepšení objednávek
CI / Generate TypeScript types (pull_request) Successful in 20s
CI / Playwright E2E tests (pull_request) Has been skipped
CI / Build and push Docker image (push) Has been skipped
CI / Build and push Docker image (pull_request) Has been skipped
CI / Playwright E2E tests (push) Has been skipped
CI / Notify (push) Successful in 4s
CI / Build client (pull_request) Failing after 30s
CI / Notify (pull_request) Has been skipped
CI / Build server (pull_request) Successful in 3m13s
CI / Build client (push) Failing after 10m5s
CI / Server unit tests (push) Failing after 22s
CI / Server unit tests (pull_request) Failing after 20s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 41s
2026-05-07 09:50:51 +02:00
mates 1efe2b8f7d feat: potvrzení o úhradě objednávky
CI / Generate TypeScript types (pull_request) Successful in 9s
CI / Playwright E2E tests (pull_request) Successful in 1m18s
CI / Build and push Docker image (pull_request) Has been skipped
CI / Build and push Docker image (push) Has been skipped
CI / Playwright E2E tests (push) Successful in 6m51s
CI / Notify (push) Successful in 16s
CI / Server unit tests (push) Successful in 21s
CI / Build client (pull_request) Successful in 47s
CI / Notify (pull_request) Has been skipped
CI / Build client (push) Successful in 33s
CI / Build server (pull_request) Successful in 35s
CI / Generate TypeScript types (push) Successful in 9s
CI / Server unit tests (pull_request) Successful in 20s
CI / Build server (push) Successful in 3m9s
2026-05-07 09:09:47 +02:00
mates 5f03471541 fix: opravy po review
CI / Generate TypeScript types (pull_request) Successful in 47s
CI / Playwright E2E tests (pull_request) Successful in 1m20s
CI / Build and push Docker image (pull_request) Has been skipped
CI / Build and push Docker image (push) Has been skipped
CI / Playwright E2E tests (push) Successful in 6m7s
CI / Notify (push) Successful in 6s
CI / Server unit tests (push) Successful in 21s
CI / Build client (pull_request) Successful in 40s
CI / Notify (pull_request) Has been skipped
CI / Build server (pull_request) Successful in 27s
CI / Build client (push) Successful in 4m13s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 27s
CI / Server unit tests (pull_request) Successful in 20s
2026-05-07 08:56:49 +02:00
batmanisko 21d7224fb4 Zbytečný changelog
CI / Generate TypeScript types (pull_request) Successful in 14s
CI / Playwright E2E tests (pull_request) Has been cancelled
CI / Build and push Docker image (pull_request) Has been cancelled
CI / Build and push Docker image (push) Has been skipped
CI / Playwright E2E tests (push) Successful in 1m19s
CI / Notify (push) Successful in 12s
CI / Build client (pull_request) Has been cancelled
CI / Notify (pull_request) Has been cancelled
CI / Server unit tests (push) Successful in 3m53s
CI / Build server (pull_request) Has been cancelled
CI / Build client (push) Successful in 54s
CI / Generate TypeScript types (push) Successful in 19s
CI / Build server (push) Successful in 53s
CI / Server unit tests (pull_request) Has been cancelled
2026-05-07 07:26:37 +02:00
batmanisko abc3d070cc feat: novinka /objednani, odebrání z hlasování (CUSTOM_QR implementováno)
CI / Generate TypeScript types (pull_request) Successful in 35s
CI / Build and push Docker image (push) Has been cancelled
CI / Playwright E2E tests (pull_request) Has been cancelled
CI / Build and push Docker image (pull_request) Has been cancelled
CI / Playwright E2E tests (push) Has been cancelled
CI / Notify (push) Has been cancelled
CI / Build client (pull_request) Has been cancelled
CI / Notify (pull_request) Has been cancelled
CI / Server unit tests (push) Successful in 20s
CI / Build client (push) Has been cancelled
CI / Build server (pull_request) Has been cancelled
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 28s
CI / Server unit tests (pull_request) Has been cancelled
2026-05-07 07:24:52 +02:00
batmanisko cca751752d fix: poplatky děleny všemi (včetně plátce), přejmenování Dýško → Poplatek
CI / Generate TypeScript types (pull_request) Successful in 10s
CI / Build and push Docker image (push) Has been cancelled
CI / Playwright E2E tests (pull_request) Has been cancelled
CI / Build and push Docker image (pull_request) Has been cancelled
CI / Playwright E2E tests (push) Successful in 1m17s
CI / Notify (push) Has been cancelled
CI / Build client (pull_request) Has been cancelled
CI / Notify (pull_request) Has been cancelled
CI / Server unit tests (push) Has been cancelled
CI / Build server (pull_request) Has been cancelled
CI / Generate TypeScript types (push) Successful in 11s
CI / Build server (push) Successful in 46s
CI / Build client (push) Successful in 39s
CI / Server unit tests (pull_request) Successful in 21s
2026-05-07 07:19:50 +02:00
batmanisko d2f45be2d3 chore: run_dev.ps1 + VS Code tasks
CI / Generate TypeScript types (pull_request) Successful in 12s
CI / Build and push Docker image (push) Has been skipped
CI / Playwright E2E tests (pull_request) Has been cancelled
CI / Build and push Docker image (pull_request) Has been cancelled
CI / Playwright E2E tests (push) Successful in 1m17s
CI / Notify (push) Successful in 2s
CI / Build client (pull_request) Successful in 5m36s
CI / Notify (pull_request) Has been cancelled
CI / Server unit tests (push) Successful in 1m45s
CI / Build server (pull_request) Successful in 28s
CI / Generate TypeScript types (push) Successful in 16s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 35s
CI / Server unit tests (pull_request) Successful in 20s
Windows Terminal dev runner a VS Code tasks pro spuštění
server+client z editoru.
2026-05-07 07:08:16 +02:00
batmanisko 936b33cc80 feat: /objednani – skupinové objednávky s QR platbou
CI / Generate TypeScript types (pull_request) Successful in 9s
CI / Build and push Docker image (push) Has been cancelled
CI / Playwright E2E tests (pull_request) Has been cancelled
CI / Build and push Docker image (pull_request) Has been cancelled
CI / Playwright E2E tests (push) Has been cancelled
CI / Notify (push) Has been cancelled
CI / Build client (pull_request) Has been cancelled
CI / Notify (pull_request) Has been cancelled
CI / Server unit tests (push) Successful in 21s
CI / Build client (push) Successful in 40s
CI / Build server (pull_request) Successful in 24s
CI / Generate TypeScript types (push) Successful in 18s
CI / Server unit tests (pull_request) Successful in 21s
CI / Build server (push) Has been cancelled
Nahrazuje /vecere novou stránkou /objednani. Místo jednoho
OBJEDNAVAM bucketu umožňuje vytvářet více skupin, kde každá
objednává z jiného obchodu.

- Skupiny mají stavový automat: open → locked → ordered
- Obchody spravuje admin heslem (ADMIN_PASSWORD env var)
  přes modal „Správa obchodů"
- Při stavu ordered zakladatel generuje QR kódy platby
  (nový PayForGroupModal – volné částky bez menu)
- PayForAllModal (oběd) upraven: plátce nyní vidí svůj
  vlastní díl jako informační řádek
- Nové testy: stores.test.ts + groups.test.ts (36 testů)
2026-05-07 07:05:01 +02:00
batmanisko 774be3df6d feat: večeře (extra meal slot)
CI / Generate TypeScript types (pull_request) Successful in 11s
CI / Build and push Docker image (push) Has been skipped
CI / Playwright E2E tests (pull_request) Successful in 10m34s
CI / Build and push Docker image (pull_request) Has been skipped
CI / Playwright E2E tests (push) Successful in 1m18s
CI / Notify (push) Successful in 2s
CI / Build client (pull_request) Successful in 37s
CI / Notify (pull_request) Has been skipped
CI / Server unit tests (push) Successful in 22s
CI / Build client (push) Successful in 37s
CI / Build server (pull_request) Successful in 3m14s
CI / Generate TypeScript types (push) Successful in 36s
CI / Server unit tests (pull_request) Successful in 25s
CI / Build server (push) Successful in 1m0s
- Nová stránka /vecere pro evidenci extra jídla (večeře/pozdní oběd)
- MealSlot enum (obed/extra), oddělený storage namespace YYYY-MM-DD_extra
- slot parametr na všech food endpointech a GET /api/data
- Push reminder: přechod na 60min cooldown, login v payloadu místo endpointu
- server: slot?: string → slot?: MealSlot, enum konstanty místo literálů
- Jest testy izolace extra/obed storage namespace
- Aktualizace changelogů (saláty, SINGLE_PAYMENT, večeře)
2026-05-06 21:06:25 +02:00
mates 5f903797f1 build: sjednocení Dockerfile
CI / Build and push Docker image (push) Successful in 12m57s
CI / Playwright E2E tests (push) Successful in 15m25s
CI / Notify (push) Successful in 11s
CI / Server unit tests (push) Successful in 34s
CI / Build client (push) Successful in 17m30s
CI / Generate TypeScript types (push) Successful in 1m8s
CI / Build server (push) Successful in 1m39s
2026-05-05 21:57:41 +02:00
batmanisko a2d45ad7e7 docs: sync CLAUDE.md with current repo state
CI / Build and push Docker image (push) Successful in 34s
CI / Playwright E2E tests (push) Successful in 1m16s
CI / Notify (push) Successful in 2s
CI / Server unit tests (push) Successful in 21s
CI / Build client (push) Successful in 35s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 25s
Add e2e/ package, Playwright commands, Gitea CI pipeline, changelog
route, memory storage backend, client hooks/utils folders, and correct
context filenames.
2026-04-30 01:24:00 +02:00
batmanisko 4da3ce3b10 fix: Dockerfile cp recursion – changelogs already COPYd in image
CI / Build and push Docker image (push) Successful in 40s
CI / Playwright E2E tests (push) Successful in 1m10s
CI / Notify (push) Successful in 3s
CI / Server unit tests (push) Successful in 25s
CI / Build client (push) Successful in 32s
CI / Generate TypeScript types (push) Successful in 11s
CI / Build server (push) Successful in 37s
2026-04-30 00:59:17 +02:00
batmanisko e2615edc0f Merge pull request 'feat: přidání testů – Jest unit testy + Playwright E2E + CI pipeline' (#54) from feat/tests into master
CI / Build and push Docker image (push) Failing after 2m52s
CI / Playwright E2E tests (push) Successful in 1m18s
CI / Notify (push) Successful in 2s
CI / Server unit tests (push) Successful in 24s
CI / Generate TypeScript types (push) Successful in 11s
CI / Build server (push) Successful in 25s
CI / Build client (push) Successful in 33s
feat: přidání testů – Jest unit testy + Playwright E2E + CI pipeline
2026-04-30 00:49:39 +02:00
batmanisko a0d4921d87 fix: unit testy selhávaly v CI kvůli MOCK_DATA=true z workflow env
CI / Generate TypeScript types (pull_request) Successful in 10s
CI / Build and push Docker image (push) Has been skipped
CI / Playwright E2E tests (pull_request) Successful in 1m10s
CI / Build and push Docker image (pull_request) Has been skipped
CI / Playwright E2E tests (push) Successful in 1m17s
CI / Notify (push) Successful in 2s
CI / Build client (pull_request) Successful in 31s
CI / Notify (pull_request) Has been skipped
CI / Server unit tests (push) Successful in 27s
CI / Build client (push) Successful in 32s
CI / Build server (pull_request) Successful in 26s
CI / Generate TypeScript types (push) Successful in 11s
CI / Build server (push) Successful in 24s
CI / Server unit tests (pull_request) Successful in 19s
setupEnv.ts nyní explicitně ruší MOCK_DATA, aby getToday() vracelo
skutečné datum i když CI job nastavuje MOCK_DATA=true. seedPizzaDay
používá getToday() místo new Date() pro konzistenci s pizza funkcemi.
2026-04-30 00:45:15 +02:00
batmanisko 8b1703dce9 merge: master → feat/tests, resolve conflicts + fix all tests
CI / Generate TypeScript types (pull_request) Successful in 11s
CI / Build and push Docker image (push) Has been skipped
CI / Playwright E2E tests (pull_request) Successful in 1m9s
CI / Build and push Docker image (pull_request) Has been skipped
CI / Playwright E2E tests (push) Successful in 1m17s
CI / Notify (push) Successful in 2s
CI / Build client (pull_request) Successful in 32s
CI / Notify (pull_request) Has been skipped
CI / Server unit tests (push) Failing after 24s
CI / Build client (push) Successful in 31s
CI / Build server (pull_request) Successful in 25s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 24s
CI / Server unit tests (pull_request) Failing after 18s
- odstraněn .woodpecker/workflow.yaml (CI přesunuto na Gitea Actions)
- tsconfig.json: exclude src/tests/**/* (feat/tests verze)
- jest.config.js: testEnvironment node + master cesty
- auth/pizza/voting tests: union obou větví, použit resetMemoryStorage()
- service.test.ts: jest.useFakeTimers místo MOCK_DATA=true
- všechny testy: 167/167 PASS
2026-04-30 00:32:43 +02:00
mates 3ed781d0cf test: opravy Playwright testů
CI / Build and push Docker image (push) Has been skipped
CI / Generate TypeScript types (pull_request) Successful in 13s
CI / Playwright E2E tests (pull_request) Successful in 1m19s
CI / Build and push Docker image (pull_request) Has been skipped
CI / Playwright E2E tests (push) Successful in 1m15s
CI / Notify (push) Successful in 2s
CI / Server unit tests (push) Successful in 22s
CI / Build client (pull_request) Successful in 37s
CI / Notify (pull_request) Has been skipped
CI / Build client (push) Successful in 31s
CI / Build server (pull_request) Successful in 30s
CI / Generate TypeScript types (push) Successful in 11s
CI / Build server (push) Successful in 23s
CI / Server unit tests (pull_request) Successful in 25s
2026-04-29 22:55:23 +02:00
mates 70ed59ab9d test: opravy Playwright testů
CI / Generate TypeScript types (pull_request) Successful in 11s
CI / Playwright E2E tests (pull_request) Failing after 2m14s
CI / Build and push Docker image (push) Has been skipped
CI / Build and push Docker image (pull_request) Has been skipped
CI / Playwright E2E tests (push) Failing after 2m24s
CI / Notify (push) Successful in 4s
CI / Build client (pull_request) Successful in 33s
CI / Notify (pull_request) Has been skipped
CI / Server unit tests (push) Successful in 23s
CI / Build client (push) Successful in 34s
CI / Build server (pull_request) Successful in 23s
CI / Generate TypeScript types (push) Successful in 12s
CI / Build server (push) Successful in 23s
CI / Server unit tests (pull_request) Successful in 19s
2026-04-29 22:06:46 +02:00
mates 6b2deff215 test: opravy Playwright testů
CI / Build and push Docker image (push) Has been cancelled
CI / Generate TypeScript types (pull_request) Successful in 9s
CI / Playwright E2E tests (pull_request) Failing after 2m23s
CI / Build and push Docker image (pull_request) Has been skipped
CI / Playwright E2E tests (push) Has been cancelled
CI / Notify (push) Has been cancelled
CI / Build client (pull_request) Successful in 32s
CI / Notify (pull_request) Has been skipped
CI / Server unit tests (push) Has been cancelled
CI / Build client (push) Has been cancelled
CI / Build server (pull_request) Successful in 23s
CI / Build server (push) Has been cancelled
CI / Generate TypeScript types (push) Has been cancelled
CI / Server unit tests (pull_request) Successful in 19s
2026-04-29 21:40:32 +02:00
mates ace4130171 test: opravy Playwright testů
CI / Build and push Docker image (push) Has been cancelled
CI / Generate TypeScript types (pull_request) Successful in 14s
CI / Playwright E2E tests (pull_request) Failing after 2m40s
CI / Build and push Docker image (pull_request) Has been skipped
CI / Playwright E2E tests (push) Has been cancelled
CI / Notify (push) Has been cancelled
CI / Build client (pull_request) Successful in 35s
CI / Notify (pull_request) Has been skipped
CI / Server unit tests (push) Has been cancelled
CI / Build client (push) Has been cancelled
CI / Build server (pull_request) Successful in 36s
CI / Generate TypeScript types (push) Has been cancelled
CI / Build server (push) Has been cancelled
CI / Server unit tests (pull_request) Successful in 37s
2026-04-29 21:27:16 +02:00
mates 9383cd7d4c fix: oprava použití yarn v Gitea Actions
CI / Generate TypeScript types (pull_request) Successful in 10s
CI / Playwright E2E tests (pull_request) Has been cancelled
CI / Build and push Docker image (pull_request) Has been cancelled
CI / Build and push Docker image (push) Has been skipped
CI / Playwright E2E tests (push) Failing after 3m24s
CI / Notify (push) Successful in 2s
CI / Build client (pull_request) Has been cancelled
CI / Notify (pull_request) Has been cancelled
CI / Server unit tests (push) Successful in 19s
CI / Build server (pull_request) Has been cancelled
CI / Build client (push) Successful in 40s
CI / Generate TypeScript types (push) Successful in 12s
CI / Server unit tests (pull_request) Has been cancelled
CI / Build server (push) Successful in 32s
2026-04-29 21:12:50 +02:00
mates db1fe473cd test: opravy Playwright testů
CI / Generate TypeScript types (pull_request) Successful in 9s
CI / Playwright E2E tests (pull_request) Has been skipped
CI / Build and push Docker image (push) Has been skipped
CI / Build and push Docker image (pull_request) Has been skipped
CI / Playwright E2E tests (push) Failing after 4m13s
CI / Notify (push) Successful in 3s
CI / Build client (pull_request) Failing after 8s
CI / Notify (pull_request) Has been skipped
CI / Server unit tests (push) Successful in 19s
CI / Build server (pull_request) Failing after 8s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 23s
CI / Server unit tests (pull_request) Failing after 7s
CI / Build client (push) Successful in 33s
2026-04-29 21:03:53 +02:00
mates d7c8a4663d test: opravy Playwright testů
CI / Build and push Docker image (push) Has been cancelled
CI / Generate TypeScript types (pull_request) Successful in 9s
CI / Playwright E2E tests (pull_request) Has been cancelled
CI / Build and push Docker image (pull_request) Has been cancelled
CI / Playwright E2E tests (push) Has been cancelled
CI / Notify (push) Has been cancelled
CI / Build client (pull_request) Successful in 33s
CI / Notify (pull_request) Has been cancelled
CI / Server unit tests (push) Has been cancelled
CI / Build server (pull_request) Successful in 23s
CI / Generate TypeScript types (push) Has been cancelled
CI / Build server (push) Has been cancelled
CI / Build client (push) Has been cancelled
CI / Server unit tests (pull_request) Successful in 19s
2026-04-29 20:48:05 +02:00
mates ecbbeb2cec test: opravy Playwright testů
CI / Generate TypeScript types (pull_request) Successful in 11s
CI / Build and push Docker image (push) Has been skipped
CI / Playwright E2E tests (pull_request) Failing after 1m48s
CI / Build and push Docker image (pull_request) Has been skipped
CI / Playwright E2E tests (push) Failing after 1m48s
CI / Notify (push) Successful in 4s
CI / Build client (pull_request) Successful in 32s
CI / Notify (pull_request) Has been skipped
CI / Server unit tests (push) Successful in 22s
CI / Build server (pull_request) Successful in 30s
CI / Generate TypeScript types (push) Successful in 9s
CI / Build server (push) Successful in 29s
CI / Server unit tests (pull_request) Successful in 25s
CI / Build client (push) Successful in 34s
2026-04-29 20:41:57 +02:00
mates e9c570b3d5 test: opravy Playwright testů
CI / Generate TypeScript types (pull_request) Successful in 10s
CI / Build and push Docker image (push) Has been skipped
CI / Playwright E2E tests (pull_request) Failing after 2m19s
CI / Build and push Docker image (pull_request) Has been skipped
CI / Playwright E2E tests (push) Failing after 2m3s
CI / Notify (push) Successful in 3s
CI / Build client (pull_request) Successful in 33s
CI / Notify (pull_request) Has been skipped
CI / Server unit tests (push) Successful in 19s
CI / Build server (pull_request) Successful in 25s
CI / Generate TypeScript types (push) Successful in 9s
CI / Build server (push) Successful in 24s
CI / Server unit tests (pull_request) Successful in 22s
CI / Build client (push) Successful in 32s
2026-04-29 20:29:25 +02:00
mates f400d1c5f2 fix: notifikace přes ntfy
CI / Generate TypeScript types (pull_request) Successful in 10s
CI / Build and push Docker image (push) Has been skipped
CI / Playwright E2E tests (pull_request) Failing after 2m14s
CI / Build and push Docker image (pull_request) Has been skipped
CI / Playwright E2E tests (push) Failing after 1m57s
CI / Notify (push) Successful in 4s
CI / Build client (pull_request) Successful in 40s
CI / Notify (pull_request) Has been skipped
CI / Server unit tests (push) Successful in 25s
CI / Build server (pull_request) Successful in 44s
CI / Generate TypeScript types (push) Successful in 11s
CI / Build server (push) Successful in 30s
CI / Build client (push) Successful in 45s
CI / Server unit tests (pull_request) Successful in 19s
2026-04-29 20:16:10 +02:00
mates ec6df8700b fix: Discord notifikace i při selhání workflow
CI / Generate TypeScript types (pull_request) Successful in 10s
CI / Build and push Docker image (push) Has been skipped
CI / Playwright E2E tests (pull_request) Failing after 2m23s
CI / Build and push Docker image (pull_request) Has been skipped
CI / Playwright E2E tests (push) Failing after 2m3s
CI / Build client (pull_request) Successful in 32s
CI / Discord notification (push) Has been skipped
CI / Server unit tests (push) Successful in 21s
CI / Build server (pull_request) Successful in 34s
CI / Discord notification (pull_request) Has been skipped
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 27s
CI / Server unit tests (pull_request) Successful in 21s
CI / Build client (push) Successful in 34s
2026-04-29 20:06:17 +02:00
mates 85cda34881 fix: instalace types před buildem klienta
CI / Generate TypeScript types (pull_request) Successful in 10s
CI / Build and push Docker image (push) Has been skipped
CI / Playwright E2E tests (pull_request) Failing after 4m16s
CI / Build and push Docker image (pull_request) Has been skipped
CI / Playwright E2E tests (push) Failing after 3m38s
CI / Build client (pull_request) Successful in 32s
CI / Discord notification (push) Has been skipped
CI / Server unit tests (push) Successful in 20s
CI / Build client (push) Successful in 33s
CI / Build server (pull_request) Successful in 25s
CI / Discord notification (pull_request) Has been skipped
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 26s
CI / Server unit tests (pull_request) Successful in 21s
2026-04-29 19:56:17 +02:00
batmaniskoandClaude Opus 4.7 d91c48c599 fix: instalace types/node_modules před buildem serveru (tsc kompiluje ../types/**)
CI / Generate TypeScript types (pull_request) Successful in 9s
CI / Build and push Docker image (push) Has been skipped
CI / Playwright E2E tests (pull_request) Has been skipped
CI / Build and push Docker image (pull_request) Has been skipped
CI / Playwright E2E tests (push) Has been skipped
CI / Build client (pull_request) Failing after 25s
CI / Discord notification (push) Has been skipped
CI / Server unit tests (push) Successful in 24s
CI / Build server (pull_request) Successful in 28s
CI / Discord notification (pull_request) Has been skipped
CI / Generate TypeScript types (push) Successful in 12s
CI / Build server (push) Successful in 24s
CI / Build client (push) Failing after 26s
CI / Server unit tests (pull_request) Successful in 18s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 19:46:24 +02:00
batmaniskoandClaude Opus 4.7 e83cf14594 fix: downgrade artifact actions na v3 (v4 nepodporováno na Gitea/GHES)
CI / Generate TypeScript types (pull_request) Successful in 17s
CI / Build and push Docker image (push) Has been skipped
CI / Playwright E2E tests (pull_request) Has been skipped
CI / Build and push Docker image (pull_request) Has been skipped
CI / Playwright E2E tests (push) Has been skipped
CI / Discord notification (push) Has been skipped
CI / Build client (pull_request) Failing after 24s
CI / Server unit tests (push) Successful in 34s
CI / Build server (pull_request) Failing after 21s
CI / Discord notification (pull_request) Has been skipped
CI / Generate TypeScript types (push) Successful in 19s
CI / Build server (push) Failing after 32s
CI / Server unit tests (pull_request) Successful in 19s
CI / Build client (push) Failing after 28s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 19:43:14 +02:00
batmaniskoandClaude Opus 4.7 2067c21a29 fix: instalace yarn přes npm před setup-node (yarn nebyl v PATH)
CI / Build and push Docker image (push) Has been skipped
CI / Generate TypeScript types (pull_request) Failing after 10s
CI / Playwright E2E tests (pull_request) Has been skipped
CI / Build and push Docker image (pull_request) Has been skipped
CI / Playwright E2E tests (push) Has been skipped
CI / Server unit tests (push) Has been skipped
CI / Discord notification (push) Has been skipped
CI / Build client (pull_request) Has been skipped
CI / Build client (push) Has been skipped
CI / Build server (pull_request) Has been skipped
CI / Discord notification (pull_request) Has been skipped
CI / Generate TypeScript types (push) Failing after 10s
CI / Build server (push) Has been skipped
CI / Server unit tests (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 19:39:51 +02:00
batmaniskoandClaude Opus 4.7 99260a3250 fix: oprava YAML chyby v discord-notify kroku (víceřádkový string)
CI / Build and push Docker image (push) Has been skipped
CI / Generate TypeScript types (pull_request) Failing after 1m42s
CI / Playwright E2E tests (pull_request) Has been skipped
CI / Build and push Docker image (pull_request) Has been skipped
CI / Playwright E2E tests (push) Has been skipped
CI / Discord notification (push) Has been skipped
CI / Build client (pull_request) Has been skipped
CI / Server unit tests (push) Has been skipped
CI / Build client (push) Has been skipped
CI / Build server (pull_request) Has been skipped
CI / Discord notification (pull_request) Has been skipped
CI / Generate TypeScript types (push) Failing after 1m26s
CI / Build server (push) Has been skipped
CI / Server unit tests (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 19:34:25 +02:00
batmaniskoandClaude Opus 4.7 091294f7f3 feat: migrace CI z Woodpecker na Gitea Actions
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 19:31:20 +02:00
batmaniskoandClaude Sonnet 4.6 bfe819020d fix: redis-stack-server RC3 → 7.4.0-v1, obnova Redis pro E2E
ci/woodpecker/pr/workflow Pipeline was canceled
ci/woodpecker/push/workflow Pipeline failed
7.2.0-RC3 havaroval kvůli RedisAI modulu (odstraněn ve verzi 7.4).
Stable 7.4.0-v1 RedisAI neobsahuje, RedisJSON zůstává.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 19:07:52 +02:00
batmaniskoandClaude Sonnet 4.6 467e3c155a fix: E2E testy přepnuty na json storage, odstraněna Redis služba
redis/redis-stack-server:7.2.0-RC3 havaroval v CI kvůli chybě
inicializace RedisAI modulu, takže se server nikdy nepřipojil
a webServer timeout vyprchával. E2E testy testují chování aplikace,
ne storage backend – json storage stačí.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 18:45:26 +02:00
batmaniskoandClaude Sonnet 4.6 d3224a36d5 fix: oprava HTTP_REMOTE_TRUSTED_IPS pro CI Playwright
ci/woodpecker/pr/workflow Pipeline was canceled
ci/woodpecker/push/workflow Pipeline failed
proxy-addr nepodporuje CIDR notaci (0.0.0.0/0), takže server
havaroval při startu. V CI kontejneru se browser připojuje ze
smyčkového rozhraní, takže 127.0.0.1,::1,::ffff:127.0.0.1 stačí.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 18:33:28 +02:00
mates 64d85036fd test: rozšíření serverových testů
ci/woodpecker/push/workflow Pipeline failed
2026-04-29 15:42:08 +02:00
batmaniskoandClaude Opus 4.7 fe6bb3290e feat: přidání testů – Jest unit testy + Playwright E2E + CI pipeline
ci/woodpecker/pr/workflow Pipeline failed
ci/woodpecker/push/workflow Pipeline was canceled
Server:
- Jest unit testy (88 testů): auth, utils, restaurants, service, voting, pizza
- in-memory storage mock pro izolaci testů
- oprava race condition při inicializaci Redis (storageReady promise)
- dev route dostupná i pro NODE_ENV=test
- getStatsMock deterministický (nahrazení Math.random)
- exporty interních helperů pro testovatelnost
- /api/health endpoint pro Playwright readiness check
- tsconfig vylučuje test soubory z produkčního buildu

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

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

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

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

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

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

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

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

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

fix: oprava
2026-03-08 10:55:50 +01:00
batmanisko a1b1eed86d docs: přidána strategie vyhledávání kódu do CLAUDE.md
ci/woodpecker/push/workflow Pipeline was successful
2026-03-05 22:13:19 +01:00
batmanisko f8a65d7177 feat: detekce starého menu TechTower, příznak isStale
Pokud TechTower vrátí menu z jiného týdne, uloží data s příznakem
isStale a zobrazí varování "Data jsou z minulého týdne" místo chybové
hlášky. Odstraněno staré varování o datech starších 24 hodin.
2026-03-05 22:11:45 +01:00
batmanisko 607bcd9bf5 feat: uprava refresh menu hesel
každý může udělat refresh, jen ne tak často, bypass mimo zdrojak
2026-03-05 21:50:17 +01:00
batmanisko b6fdf1de98 feat: akce "Neobědvám" přímo z push notifikace
ci/woodpecker/push/workflow Pipeline was successful
2026-03-04 14:37:05 +01:00
batmanisko 27e56954bd fix: nahrazení selectu časovým inputem pro výběr času připomínky
ci/woodpecker/push/workflow Pipeline was successful
2026-03-04 14:14:29 +01:00
batmanisko 20cc7259a3 chore: test endpoint na push 2026-03-04 14:11:22 +01:00
batmanisko d62f6c1f5a feat: push notifikace pro připomínku výběru oběda
ci/woodpecker/push/workflow Pipeline was successful
2026-03-04 13:33:58 +01:00
batmanisko b77914404b retarded dsstore 2026-03-04 10:36:14 +01:00
batmanisko 8506b4e79f claudemd init
ci/woodpecker/push/workflow Pipeline failed
2026-03-04 10:35:13 +01:00
mates 5f79a9431c fix: oprava závislostí
ci/woodpecker/push/workflow Pipeline was successful
2026-02-20 14:32:02 +01:00
mates cc98c2be0d feat: podpora ručního generování QR kódů pro platby
ci/woodpecker/push/workflow Pipeline was successful
2026-02-20 14:17:39 +01:00
stanekpa a849f4e922 feat: zarovnani ikony varovani doprava
ci/woodpecker/push/workflow Pipeline was successful
2026-02-11 13:55:40 +01:00
mates ac6727efa5 feat: vylepšení Pizza day
ci/woodpecker/push/workflow Pipeline was successful
2026-02-10 23:59:58 +01:00
Stánek Pavel f13cd4ffa9 fix: opravy zobrazeni sekce vybranych jidel
ci/woodpecker/push/workflow Pipeline was successful
2026-02-05 10:18:58 +01:00
batmanisko 086646fd1c fix: přidání nových typů do OpenAPI spec pro přežití regenerace
ci/woodpecker/push/workflow Pipeline was successful
Typy PendingQr, NotificationSettings a nové endpointy
(dismissQr, notifications/settings) byly přidány přímo
do YAML specifikace místo ručních úprav generovaných souborů.
2026-02-04 17:34:05 +01:00
batmanisko b8629afef2 feat: trvalé zobrazení QR kódu do ručního zavření (#31)
QR kódy pro platbu za pizza day jsou nyní zobrazeny persistentně
i po následující dny, dokud uživatel nepotvrdí platbu tlačítkem
"Zaplatil jsem". Nevyřízené QR kódy jsou uloženy per-user v storage
a zobrazeny v sekci "Nevyřízené platby".
2026-02-04 17:34:05 +01:00
batmanisko d366ac39d4 feat: podpora per-user notifikací s Discord, ntfy a Teams (#39)
Uživatelé mohou v nastavení konfigurovat vlastní webhook URL/topic
pro Discord, MS Teams a ntfy, a zvolit události k odběru.
Notifikace jsou odesílány pouze uživatelům se stejnou zvolenou lokalitou.
2026-02-04 17:33:53 +01:00
batmanisko fdd42dc46a feat: zobrazení minulého týdne o víkendu místo "Užívejte víkend" (#30)
Na víkendu se nyní zobrazuje páteční menu s možností procházet celý týden.
Editační ovládací prvky jsou automaticky skryté díky existující logice canChangeChoice.
2026-02-04 14:56:24 +01:00
batmanisko 2b7197eff6 fix: zobrazení popisů funkcí místo varnames ve statistikách (#26)
ci/woodpecker/push/workflow Pipeline failed
2026-02-04 13:40:20 +01:00
batmanisko 6f43c74769 fix: resolve 6 Gitea issues (#9, #10, #12, #14, #15, #21)
- #21: Add missing await in removeChoiceIfPresent() to prevent user appearing in two restaurants
- #15: Add 1-hour TTL for menu refetching to avoid scraping on every page load
- #9: Block stats API and UI navigation for future dates
- #14: Add restaurant warnings (missing soup/prices, stale data) with warning icon
- #12: Pre-fill restaurant/departure dropdowns from existing choices on page refresh
- #10: Add voting statistics endpoint and table on stats page
2026-02-04 13:18:27 +01:00
Stánek Pavel d85c764c88 fix: opravy a ladeni vzhledu a UX 2026-02-04 12:06:17 +01:00
Stánek Pavel 37cacd895a feat: redesign aplikace pomocí claude code 2026-02-03 10:37:27 +01:00
batmanisko 6a1da97ef1 feat: podpora dark mode 2026-01-30 07:47:03 +01:00
mates f91973f1a4 Oprava parsování názvů TechTower 2026-01-19 10:18:24 +01:00
mates 7cf9179a87 Neumožnění výběru jídla kliknutím do minulosti 2026-01-13 16:03:31 +01:00
mates 54e5be6b6a Povýšení závislostí 2026-01-13 15:43:19 +01:00
mates c264f9921e Opravy dle SonarLint - klient 2026-01-13 15:35:00 +01:00
mates e03ba45415 Možnost označení objednávajícího 2026-01-13 14:06:16 +01:00
mates 20f4ee0427 Zimní atmosféra 2026-01-09 08:46:14 +01:00
mates be4cee4cdb Oprava parsování Sladovnická a TechTower 2026-01-09 08:35:35 +01:00
mates 8285dd2780 Povýšení závislostí
ci/woodpecker/push/workflow Pipeline was successful
2025-11-10 22:03:48 +01:00
mates 039d8457f3 Povýšení závislostí
ci/woodpecker/push/workflow Pipeline was successful
2025-11-06 21:22:39 +01:00
mates 091e25f446 Kopírování poznámky jen pokud je vyplněna
ci/woodpecker/push/workflow Pipeline was successful
2025-11-03 14:17:22 +01:00
mates 00b4a0cce2 Oprava/narovnání ikony kopírování poznámky
ci/woodpecker/push/workflow Pipeline was successful
2025-11-03 14:05:01 +01:00
batmanisko 979c79e090 poznamka, pak icon
ci/woodpecker/push/workflow Pipeline was successful
2025-11-03 13:43:20 +01:00
batmanisko d29c7863dd chat ikona misto barvicek
ci/woodpecker/push/workflow Pipeline was successful
2025-11-03 13:36:54 +01:00
batmanisko 0781b84f11 Kopírování komentaře na kliknutí.
ci/woodpecker/push/workflow Pipeline was successful
2025-11-03 12:57:22 +01:00
mates e3d217822a Oprava výběru jídla při přepínání dnů šipkami
ci/woodpecker/push/workflow Pipeline was successful
2025-11-03 12:48:56 +01:00
mates ccfe9a9ae1 Oprava parsování Sladovnické v případě chybějících dnů
ci/woodpecker/push/workflow Pipeline was successful
2025-10-31 08:59:30 +01:00
mates 7407a3d881 Oprava hover popisků u Font Awesome ikon
ci/woodpecker/push/workflow Pipeline was successful
2025-10-16 08:06:22 +02:00
mates 8b139eba4e Oprava zobrazení tooltipu při hoveru
ci/woodpecker/push/workflow Pipeline was successful
2025-10-16 07:39:39 +02:00
mates 74eeef1de9 Aktualizace posledních změn
ci/woodpecker/push/workflow Pipeline was successful
2025-10-11 13:24:11 +02:00
mates fd67c0e646 Oprava buildu
ci/woodpecker/push/workflow Pipeline was successful
2025-10-11 13:15:47 +02:00
mates 60150889b0 Povýšení závislostí
ci/woodpecker/push/workflow Pipeline failed
2025-10-11 13:02:56 +02:00
mates 9e0e842c2d Podzimní atmosféra
ci/woodpecker/push/workflow Pipeline was successful
2025-10-11 11:03:20 +02:00
mates 86c50b315a Přidání alergenů do mock dat
ci/woodpecker/push/workflow Pipeline failed
2025-10-11 11:01:42 +02:00
mates fe7d609b5f Oprava zobrazení patičky
ci/woodpecker/push/workflow Pipeline was successful
2025-10-11 10:36:21 +02:00
mates 331a890cc5 Oddělení přenačtení menu do vlastní komponenty
ci/woodpecker/push/workflow Pipeline was successful
2025-10-10 14:00:01 +02:00
mates 523bbbfb0f Proklik na seznam alergenů
ci/woodpecker/push/workflow Pipeline was successful
2025-10-10 13:15:18 +02:00
mates 1acf9bf092 Úprava cesty pro čtení easter eggs
ci/woodpecker/push/workflow Pipeline was successful
2025-10-10 08:49:17 +02:00
mates 81f67c8424 Podpora parsování a zobrazení alergenů
ci/woodpecker/push/workflow Pipeline was successful
2025-10-06 16:28:38 +02:00
mates c2a001b7e5 Oprava parsování TechTower dle aktuální podoby HTML
ci/woodpecker/push/workflow Pipeline was successful
2025-10-06 13:12:58 +02:00
mates 670e45b805 Nevyvolávat přenačtení u zavřených podniků
ci/woodpecker/push/workflow Pipeline was successful
2025-08-11 10:30:42 +02:00
mates 52769fc981 Opravy dle SonarQube
ci/woodpecker/push/workflow Pipeline was successful
2025-08-07 13:12:55 +02:00
mates 0d90453c38 Oprava chybného čtení .env souborů 2025-08-07 13:02:41 +02:00
mates a9709a944f Úprava pro novou podobu stránek Sladovnická
ci/woodpecker/push/workflow Pipeline was successful
2025-08-04 17:30:04 +02:00
mates 593ffcf02b Vylepšení run_dev.sh pro vývoj 2025-08-04 17:27:03 +02:00
mates b4b62870e3 Úprava .gitignore 2025-08-04 17:26:38 +02:00
mates 480fe725f1 Oprava načítání jídel pro Šenk Šeříková na přelomu měsíce
ci/woodpecker/push/workflow Pipeline was successful
2025-08-01 14:00:27 +02:00
batmanisko d2845f7d0f Merge pull request 'feat/odflaknutyRefreshDat' (#17) from feat/odflaknutyRefreshDat into master
ci/woodpecker/push/workflow Pipeline was successful
Reviewed-on: #17
2025-08-01 09:05:50 +02:00
batmanisko 269f1994bc Update novinky 2025-08-01 09:04:28 +02:00
batmanisko 3dcda2028e Error pri fetch do klienta 2025-07-31 23:47:43 +02:00
batmanisko cfffd2b31d pro refresh endpoint nevyzadovat authtoken 2025-07-31 23:45:47 +02:00
batmanisko 58bb5f4e7d pro refresh endpoint nevyzadovat authtoken 2025-07-31 23:41:51 +02:00
batmanisko a3dfdb17e8 fix async.... 2025-07-31 23:37:21 +02:00
batmanisko 124fdce69d tak jsem to mozna robil, ale mozna taky ne lol 2025-07-31 23:35:38 +02:00
batmanisko ff20394b97 feat: Přidání funkce pro manuální refresh jidel. 2025-07-31 23:29:19 +02:00
batmanisko a77a04bcdf umichal patek fix
ci/woodpecker/push/workflow Pipeline was successful
2025-07-30 12:00:03 +02:00
mates 42852805e0 Oprava plnění data a času poslední aktualizace menu
ci/woodpecker/push/workflow Pipeline was successful
2025-07-29 15:41:05 +02:00
batmanisko d767730b19 sonar
ci/woodpecker/push/workflow Pipeline was successful
2025-07-29 15:15:16 +02:00
batmanisko fa4f9903cb parametr forceupdate jidla
ci/woodpecker/push/workflow Pipeline was successful
2025-07-29 11:33:31 +02:00
batmanisko cf8be8c64f fix: feat jsem to dal na spatnej radaek
ci/woodpecker/push/workflow Pipeline was successful
2025-07-29 10:55:04 +02:00
batmanisko 4c2b08adf8 feat: refresh jidla endpoint
ci/woodpecker/push/workflow Pipeline failed
2025-07-29 10:43:49 +02:00
mates 62cc82da9a Úprava parsování TechTower pro aktuální týden
ci/woodpecker/push/workflow Pipeline was successful
2025-07-22 17:10:39 +02:00
mates 40c113a4c8 Oprava pádů při načítání z menicka.cz
ci/woodpecker/push/workflow Pipeline was successful
2025-07-07 08:47:39 +02:00
mates 7681584d11 Oprava parsování TechTower pro aktuální stav
ci/woodpecker/push/workflow Pipeline was successful
2025-07-07 08:24:26 +02:00
mates c670b4212a Revert "TechTower hack pro tento specifický týden"
ci/woodpecker/push/workflow Pipeline was successful
This reverts commit 5fd90de3f8.
2025-05-26 10:13:35 +02:00
mates 5fd90de3f8 TechTower hack pro tento specifický týden
ci/woodpecker/push/workflow Pipeline was successful
2025-05-20 08:02:34 +02:00
batmanisko 49b8ab5c13 Update server/src/index.ts
ci/woodpecker/push/workflow Pipeline was successful
delete req.headers["cookie"]
2025-04-11 12:06:52 +02:00
batmanisko 9a05ef1fe6 Update server/src/index.ts
ci/woodpecker/push/workflow Pipeline was successful
vic logov
2025-04-11 12:01:58 +02:00
batmanisko 0bfea3765f properta pro logovani headeru
ci/woodpecker/push/workflow Pipeline was successful
2025-04-11 10:42:27 +02:00
batmanisko 962fbe2947 fix hardcoded header name xd
ci/woodpecker/push/workflow Pipeline was successful
2025-04-11 10:06:35 +02:00
mates d6d6ebb682 Aktualizace posledních změn
ci/woodpecker/push/workflow Pipeline was successful
2025-03-21 00:24:26 +01:00
mates 5bb7de58e7 Odebrání zimní atmosféry 2025-03-21 00:24:17 +01:00
mates 739c7707e1 Migrace serveru na OpenAPI
ci/woodpecker/push/workflow Pipeline was successful
2025-03-20 23:50:47 +01:00
mates d366882f6b Migrace klienta na OpenAPI
ci/woodpecker/push/workflow Pipeline was successful
2025-03-19 23:08:46 +01:00
mates f09bc44d63 Oprava nefunkčního odebrání prvního vybraného jídla
ci/woodpecker/push/workflow Pipeline was successful
2025-03-11 20:57:37 +01:00
mates f0d56f11aa Oprava popisu varianty "neobědvám"
ci/woodpecker/push/workflow Pipeline was successful
2025-03-11 19:41:37 +01:00
mates f74ec379c8 Oprava výběru možnosti stravování
ci/woodpecker/push/workflow Pipeline was successful
2025-03-06 08:03:49 +01:00
mates c9fa710070 Oprava buildu
ci/woodpecker/push/workflow Pipeline failed
2025-03-06 07:59:33 +01:00
mates e55ee7c11e Refaktor: Nálezy SonarQube
ci/woodpecker/push/workflow Pipeline is running
2025-03-05 21:48:02 +01:00
mates 55fd368663 Oprava Woodpecker pipeline
ci/woodpecker/push/workflow Pipeline was successful
2025-03-05 21:19:33 +01:00
mates 61f13d2132 Validace TypeScript typů při sestavení klienta
ci/woodpecker/push/workflow Pipeline failed
2025-03-05 21:05:40 +01:00
mates d69e09afee Migrace na OpenAPI - TypeScript typy 2025-03-05 21:05:21 +01:00
batmanisko d144c55bf7 feat: #11 je tohle feat?, pridani poctu lidi k restauraci
ci/woodpecker/push/workflow Pipeline was successful
2025-03-05 18:56:21 +01:00
mates 999a517404 Oprava lokalizace datumu
ci/woodpecker/push/workflow Pipeline was successful
2025-03-03 10:20:41 +01:00
mates 68bafa808c Oprava #8
ci/woodpecker/push/workflow Pipeline was successful
2025-02-27 21:46:50 +01:00
mates a34614c8db Oprava #6
ci/woodpecker/push/workflow Pipeline failed
2025-02-27 21:36:21 +01:00
mates f4e31cea36 Oprava #4, #5
ci/woodpecker/push/workflow Pipeline was successful
2025-02-27 21:29:43 +01:00
mates 8dda6b1014 Oprava #7
ci/woodpecker/push/workflow Pipeline was successful
2025-02-27 21:19:38 +01:00
mates f9c7d647f7 Migrace Node v18 -> v22
ci/woodpecker/push/workflow Pipeline was successful
2025-02-27 00:28:14 +01:00
mates ca400638d1 Přidání základních statistik
ci/woodpecker/push/workflow Pipeline failed
2025-02-27 00:22:34 +01:00
mates 0af78e72d9 Nastavení časové zóny
ci/woodpecker/push/workflow Pipeline was successful
2025-02-24 12:06:29 +01:00
412 changed files with 51059 additions and 5108 deletions

No files matched your search

+261
View File
@@ -0,0 +1,261 @@
name: CI
on:
push:
branches:
- "**"
pull_request:
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
jobs:
# ─── 1. Generate OpenAPI types ────────────────────────────────────────────
generate-types:
name: Generate TypeScript types
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "26"
- run: corepack enable
- run: cd types && yarn install --frozen-lockfile && yarn openapi-ts
- uses: actions/upload-artifact@v3
with:
name: types-gen
path: types/gen
# ─── 2a. Server unit tests ────────────────────────────────────────────────
server-test:
name: Server unit tests
runs-on: ubuntu-latest
needs: generate-types
env:
NODE_ENV: test
JWT_SECRET: test-secret-min-32-chars-aaaaaaa!
MOCK_DATA: "true"
STORAGE: json
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "26"
- run: corepack enable
- uses: actions/download-artifact@v3
with:
name: types-gen
path: types/gen
- run: cd server && yarn install --frozen-lockfile && yarn test
# ─── 2b. Build server ─────────────────────────────────────────────────────
server-build:
name: Build server
runs-on: ubuntu-latest
needs: generate-types
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "26"
- run: corepack enable
- uses: actions/download-artifact@v3
with:
name: types-gen
path: types/gen
- run: cd types && yarn install --frozen-lockfile
- run: cd server && yarn install --frozen-lockfile && yarn build
- uses: actions/upload-artifact@v3
with:
name: server-dist
path: server/dist
# ─── 2c. Build client ─────────────────────────────────────────────────────
client-build:
name: Build client
runs-on: ubuntu-latest
needs: generate-types
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "26"
- run: corepack enable
- uses: actions/download-artifact@v3
with:
name: types-gen
path: types/gen
- run: cd types && yarn install --frozen-lockfile
- run: cd client && yarn install --frozen-lockfile && yarn build
- uses: actions/upload-artifact@v3
with:
name: client-dist
path: client/dist
# ─── 3. Playwright E2E tests ──────────────────────────────────────────────
e2e:
name: Playwright E2E tests
runs-on: ubuntu-latest
needs: [ server-build, client-build ]
container: mcr.microsoft.com/playwright:v1.59.1-jammy
services:
redis:
image: redis/redis-stack-server:7.4.0-v1
env:
REDIS_ARGS: "--save '' --loglevel warning"
env:
CI: "true"
NODE_ENV: test
JWT_SECRET: test-secret-min-32-chars-aaaaaaa!
MOCK_DATA: "true"
STORAGE: redis
REDIS_HOST: redis
REDIS_PORT: "6379"
HTTP_REMOTE_USER_ENABLED: "true"
HTTP_REMOTE_USER_HEADER_NAME: remote-user
HTTP_REMOTE_TRUSTED_IPS: "127.0.0.1,::1,::ffff:127.0.0.1"
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
with:
name: server-dist
path: server/dist
- uses: actions/download-artifact@v3
with:
name: client-dist
path: client/dist
- name: Install server dependencies
run: cd server && yarn install --frozen-lockfile
- name: Copy client build into server/public
run: cp -r client/dist server/public
- name: Install e2e dependencies and browsers
run: cd e2e && yarn install --frozen-lockfile && yarn playwright install firefox
--with-deps
- name: Run Playwright tests
run: cd e2e && yarn test
- uses: actions/upload-artifact@v3
if: failure()
with:
name: playwright-report
path: |
e2e/playwright-report
e2e/test-results
# ─── 4. Build and push Docker image (master only) ─────────────────────────
docker-build:
name: Build and push Docker image
runs-on: ubuntu-latest
needs: [ server-build, client-build, server-test, e2e ]
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "26"
- run: corepack enable
- uses: actions/download-artifact@v3
with:
name: server-dist
path: server/dist
- uses: actions/download-artifact@v3
with:
name: client-dist
path: client/dist
- name: Install server production dependencies
run: cd server && yarn install --frozen-lockfile --production
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ secrets.REPO_URL }}
username: ${{ secrets.REPO_USERNAME }}
password: ${{ secrets.REPO_PASSWORD }}
- uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile
target: runner-prebuilt
platforms: linux/amd64
push: true
tags: ${{ secrets.REPO_URL }}/${{ secrets.REPO_NAME }}:latest
# ─── 5. Notifications ────────────────────────
notify:
name: Notify
runs-on: ubuntu-latest
needs: [ server-build, client-build, server-test, e2e, docker-build ]
if: always() && github.event_name == 'push'
steps:
- name: Send webhook
env:
DISCORD_WEBHOOK_ID: ${{ secrets.DISCORD_WEBHOOK_ID }}
DISCORD_WEBHOOK_TOKEN: ${{ secrets.DISCORD_WEBHOOK_TOKEN }}
NTFY_URL: ${{ secrets.NTFY_URL }}
BUILD_RESULT: ${{ needs.docker-build.result }}
RUN_NUMBER: ${{ github.run_number }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{
github.run_id }}
COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
COMMIT_AUTHOR: ${{ github.event.head_commit.author.name }}
run: |
if [ "$BUILD_RESULT" = "success" ]; then
MSG="✅ Sestavení #${RUN_NUMBER} proběhlo úspěšně."
NTFY_TAGS="white_check_mark"
else
MSG="❌ Sestavení #${RUN_NUMBER} selhalo."
NTFY_TAGS="x"
fi
FULL_MSG="$(printf '%s\n\nPipeline: %s\nPoslední commit: %sAutor: %s' \
"$MSG" "$RUN_URL" "$COMMIT_MESSAGE" "$COMMIT_AUTHOR")"
curl -s -X POST \
"https://discord.com/api/webhooks/${DISCORD_WEBHOOK_ID}/${DISCORD_WEBHOOK_TOKEN}" \
-H "Content-Type: application/json" \
--data "$(jq -n --arg content "$FULL_MSG" '{content: $content}')"
curl -s -X POST "${NTFY_URL}" \
-H "Title: Luncher CI #${RUN_NUMBER}" \
-H "Tags: ${NTFY_TAGS}" \
-H "Click: ${RUN_URL}" \
-d "${FULL_MSG}"
+9
View File
@@ -1 +1,10 @@
node_modules node_modules
types/gen
**.DS_Store
.mcp.json
.claude/settings.local.json
server/public/
.claude/*.lock
.claude/worktrees
.playwright-mcp
.idea/
+32
View File
@@ -0,0 +1,32 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Server (ts-node, debug)",
"type": "node",
"request": "launch",
"cwd": "${workspaceFolder}/server",
"runtimeArgs": ["-r", "ts-node/register"],
"program": "${workspaceFolder}/server/src/index.ts",
"env": { "NODE_ENV": "development" },
"console": "integratedTerminal",
"skipFiles": ["<node_internals>/**"],
"preLaunchTask": "types: openapi-ts"
},
{
"name": "Client (vite + Edge)",
"type": "msedge",
"request": "launch",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}/client",
"preLaunchTask": "client: vite"
}
],
"compounds": [
{
"name": "Dev: server + client",
"configurations": ["Server (ts-node, debug)", "Client (vite + Edge)"],
"stopAll": true
}
]
}
+67
View File
@@ -0,0 +1,67 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "types: openapi-ts",
"type": "shell",
"command": "yarn openapi-ts",
"options": {
"cwd": "${workspaceFolder}/types"
},
"presentation": {
"reveal": "silent",
"panel": "dedicated"
},
"problemMatcher": []
},
{
"label": "server: startReload",
"type": "shell",
"command": "yarn startReload",
"options": {
"cwd": "${workspaceFolder}/server",
"env": {
"NODE_ENV": "development"
}
},
"isBackground": true,
"presentation": {
"reveal": "always",
"panel": "dedicated",
"group": "dev"
},
"problemMatcher": []
},
{
"label": "client: vite",
"type": "shell",
"command": "yarn start",
"options": {
"cwd": "${workspaceFolder}/client"
},
"isBackground": true,
"presentation": {
"reveal": "always",
"panel": "dedicated",
"group": "dev"
},
"problemMatcher": []
},
{
"label": "dev: server+client",
"dependsOn": [
"server: startReload",
"client: vite"
]
},
{
"label": "dev: all",
"dependsOrder": "sequence",
"dependsOn": [
"types: openapi-ts",
"dev: server+client"
],
"problemMatcher": []
}
]
}
-56
View File
@@ -1,56 +0,0 @@
variables:
- &node_image 'node:18-alpine3.18'
- &branch 'master'
when:
- event: push
branch: *branch
steps:
- name: Install server dependencies
image: *node_image
commands:
- cd server
- yarn install --frozen-lockfile
- name: Install client dependencies
image: *node_image
commands:
- cd client
- yarn install --frozen-lockfile
- name: Build server
depends_on: [Install server dependencies]
image: *node_image
commands:
- cd server
- yarn build
- name: Build client
depends_on: [Install client dependencies]
image: *node_image
commands:
- cd client
- yarn build
- name: Build Docker image
depends_on: [Build server, Build client]
image: woodpeckerci/plugin-docker-buildx
settings:
dockerfile: Dockerfile-Woodpecker
platforms: linux/amd64
registry:
from_secret: REPO_URL
username:
from_secret: REPO_USERNAME
password:
from_secret: REPO_PASSWORD
repo:
from_secret: REPO_NAME
- name: Discord notification - build
image: appleboy/drone-discord
depends_on: [Build Docker image]
when:
- status: [success, failure]
settings:
webhook_id:
from_secret: DISCORD_WEBHOOK_ID
webhook_token:
from_secret: DISCORD_WEBHOOK_TOKEN
message: "{{#success build.status}}✅ Sestavení {{build.number}} proběhlo úspěšně.{{else}}❌ Sestavení {{build.number}} selhalo.{{/success}}\n\nPipeline: {{build.link}}\nPoslední commit: {{commit.message}}Autor: {{commit.author}}"
+146
View File
@@ -0,0 +1,146 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Luncher is a lunch management app for teams — daily restaurant menus, food ordering, pizza day events, and payment QR codes. Czech-language UI. Full-stack TypeScript monorepo.
## Monorepo Structure
```
types/ → Shared OpenAPI-generated TypeScript types (source of truth: types/api.yml)
server/ → Express 5 backend (Node.js 22, ts-node)
client/ → React 19 frontend (Vite 7, React Bootstrap)
e2e/ → Playwright E2E tests (separate package)
```
Each of the four directories has its own `package.json`. Package manager: **Yarn Classic**.
Deployment files at repo root: `Dockerfile` (multi-stage, dva runner targety: výchozí `runner` pro lokální build, `runner-prebuilt` pro CI s předem sestavenými artefakty), `compose.yml`, `compose-traefik.yml`.
## Development Commands
### Initial setup
```bash
cd types && yarn install && yarn openapi-ts # Generate API types first
cd ../server && yarn install
cd ../client && yarn install
cd ../e2e && yarn install
```
### Running dev environment
```bash
# All-in-one (tmux):
./run_dev.sh
# Or manually in separate terminals:
cd server && NODE_ENV=development yarn startReload # Port 3001, nodemon watch
cd client && yarn start # Port 3000, proxies /api → 3001
```
### Building
```bash
cd types && yarn openapi-ts # Regenerate types from api.yml
cd server && yarn build # tsc → server/dist
cd client && yarn build # tsc --noEmit + vite build → client/dist
```
### Tests
```bash
# Server unit tests (Jest)
cd server && yarn test # All tests in server/src/tests/
cd server && yarn test dates # Run one file by name
cd server && yarn test -t "name" # Run by test name pattern
# E2E (Playwright) — requires prebuilt server
cd server && yarn build
cd e2e && yarn test # chromium + firefox, baseURL 127.0.0.1:3001
cd e2e && yarn test:ui # interactive UI mode
cd e2e && yarn report # open last HTML report
```
Jest setup (`server/src/tests/setupEnv.ts`) forces `STORAGE=memory`, deletes `MOCK_DATA`, and sets a fixed `JWT_SECRET`. Playwright auto-starts the prebuilt server and authenticates via the `remote-user: e2e-user` trusted-header path; locally uses `STORAGE=json` + `MOCK_DATA=true`, CI uses `STORAGE=redis`.
### CI pipeline
Gitea Actions — `.gitea/workflows/ci.yaml` (no `.github/` equivalent):
1. `generate-types` — runs `yarn openapi-ts`, uploads artifact
2. `server-test` — Jest
3. `server-build` + `client-build` — parallel tsc/vite builds
4. `e2e` — Playwright in `mcr.microsoft.com/playwright:v1.59.1-jammy` with a Redis service container; only Firefox installed in CI
5. `docker-build` — master branch only, uses `Dockerfile` with `--target runner-prebuilt` (skládá image z artefaktů `server-build` + `client-build`)
6. `notify` — Discord + ntfy webhooks
### Formatting
Prettier is installed in `client/` (devDependency only, no script or config) — invoke via `yarn prettier <path>` with defaults.
## Architecture
### API Types (types/)
- OpenAPI 3.0 spec in `types/api.yml` — all API endpoints and DTOs defined here
- `api.yml` is a thin aggregator — actual endpoint specs live in `types/paths/<domain>/*.yml`, shared schemas in `types/schemas/_index.yml`
- `yarn openapi-ts` generates `types/gen/` (client.gen.ts, sdk.gen.ts, types.gen.ts)
- Both server and client import from these generated types
- **When changing API contracts: update api.yml first, then regenerate**
### Server (server/src/)
- **Entry:** `index.ts` — Express app + Socket.io setup
- **Routes:** `routes/` — 9 modular Express route handlers (food, pizzaDay, voting, notifications, qr, stats, easterEgg, dev, changelog)
- **Services:** domain logic files at root level (`service.ts`, `pizza.ts`, `restaurants.ts`, `chefie.ts`, `voting.ts`, `stats.ts`, `qr.ts`, `notifikace.ts`)
- **Helpers:** `mock.ts` (fake menu data for `MOCK_DATA=true`), `pushReminder.ts` (push notification reminders), `utils.ts` (shared utilities)
- **Auth:** `auth.ts` — JWT + optional trusted-header authentication
- **Storage:** `storage/index.ts` factory selects implementation; backends: `json.ts` (file-based, dev), `redis.ts` (production), `memory.ts` (tests). Data keyed by date (YYYY-MM-DD).
- **Restaurant scrapers:** Cheerio-based HTML parsing for daily menus from multiple restaurants
- **Pizza Chefie integration:** `chefie.ts` scrapes pizzachefie.cz for Pizza day menus and salads (only active when a Pizza day is open)
- **WebSocket:** `websocket.ts` — Socket.io for real-time client updates
- **Mock mode:** `MOCK_DATA=true` env var returns fake menus (useful for weekend/holiday dev)
- **Config:** `.env.development` / `.env.production` (see `.env.template` for all options)
### Client (client/src/)
- **Entry:** `index.tsx` → `App.tsx` → `AppRoutes.tsx`; `Login.tsx` is the auth screen; `FallingLeaves.tsx` is a seasonal visual effect
- **Pages:** `pages/` (StatsPage)
- **Components:** `components/` (Header, Footer, Loader, PizzaOrderList, PizzaOrderRow, and modals in `components/modals/`)
- **Context providers:** `context/` — `auth.tsx`, `settings.tsx`, `socket.js`, `eggs.tsx` (note: `socket.js` is the only non-TSX context file)
- **Hooks:** `hooks/` (`usePushReminder.ts`)
- **Utils:** `utils/` (`parsePrice.ts`)
- **Styling:** Bootstrap 5 + React Bootstrap + custom SCSS files (co-located with components)
- **API calls:** use OpenAPI-generated SDK from `types/gen/`
- **Routing:** React Router DOM v7
### Data Flow
1. Client calls API via generated SDK → Express routes
2. Server scrapes restaurant websites or returns cached data
3. Storage: Redis (production) or JSON file (development)
4. Socket.io broadcasts changes to all connected clients
## Environment
- **Server env files:** `server/.env.development`, `server/.env.production` (see `server/.env.template`)
- Key vars: `JWT_SECRET`, `STORAGE` (json/redis/memory), `MOCK_DATA`, `REDIS_HOST`, `REDIS_PORT`
- **Docker:** multi-stage Dockerfile, `compose.yml` for app + Redis. Timezone: Europe/Prague.
## Conventions
- Czech naming for domain variables and UI strings; English for infrastructure code
- TypeScript strict mode in both client and server
- Server module resolution: Node16; Client: ESNext/bundler
- `TODO.md` tracks open bugs and roadmap items — worth scanning before starting non-trivial work
## Changelog (user-facing "What's new")
When you add or change a user-visible feature, you MUST also record it in `server/changelogs/` so users see it via the in-app changelog (served by `server/src/routes/changelogRoutes.ts`, sorted newest-first with a `?since=` filter).
Steps:
1. Create or open the file for today's date: `server/changelogs/YYYY-MM-DD.json` (use today's date; one file per day).
2. The file content is a **JSON array of Czech strings**, one short user-facing sentence per change. Example:
```json
[
"Proklik na nabídku podniku ze stránky objednávek",
"Možnost přidat URL pro sledování stavu doručení pro Bolt Food"
]
```
3. If a file for today already exists, append your entry to its array instead of creating a new file.
4. Write entries in Czech, phrased for end users (describe the benefit, not the implementation). Skip purely internal/refactor changes that users won't notice.
5. The route caches files in memory at read time, so a server restart is needed to pick up new/changed changelogs in a running dev instance.
+62 -13
View File
@@ -1,8 +1,18 @@
# Builder ARG NODE_VERSION="node:26-alpine"
FROM node:18-alpine3.18 AS builder
# ─── Builder ──────────────────────────────────────────────────────────────────
FROM ${NODE_VERSION} AS builder
WORKDIR /build WORKDIR /build
# Zkopírování závislostí - OpenAPI generátor
COPY types/package.json ./types/
COPY types/yarn.lock ./types/
COPY types/api.yml ./types/
COPY types/schemas ./types/schemas/
COPY types/paths ./types/paths/
COPY types/openapi-ts.config.ts ./types/
# Zkopírování závislostí - server # Zkopírování závislostí - server
COPY server/package.json ./server/ COPY server/package.json ./server/
COPY server/yarn.lock ./server/ COPY server/yarn.lock ./server/
@@ -11,6 +21,10 @@ COPY server/yarn.lock ./server/
COPY client/package.json ./client/ COPY client/package.json ./client/
COPY client/yarn.lock ./client/ COPY client/yarn.lock ./client/
# Instalace závislostí - OpenAPI generátor
WORKDIR /build/types
RUN yarn install --frozen-lockfile
# Instalace závislostí - server # Instalace závislostí - server
WORKDIR /build/server WORKDIR /build/server
RUN yarn install --frozen-lockfile RUN yarn install --frozen-lockfile
@@ -34,7 +48,11 @@ COPY client/src ./client/src
COPY client/public ./client/public COPY client/public ./client/public
# Zkopírování společných typů # Zkopírování společných typů
COPY types ./types/ COPY types/index.ts ./types/
# Vygenerování společných typů z OpenAPI
WORKDIR /build/types
RUN yarn openapi-ts
# Sestavení serveru # Sestavení serveru
WORKDIR /build/server WORKDIR /build/server
@@ -44,13 +62,28 @@ RUN yarn build
WORKDIR /build/client WORKDIR /build/client
RUN yarn build RUN yarn build
# Runner # ─── Runner base ──────────────────────────────────────────────────────────────
FROM node:18-alpine3.18 # Společný základ pro oba runner targety – nastaví prostředí a metadata běhu.
ENV LANG cs_CZ.UTF-8 FROM ${NODE_VERSION} AS runner-base
ENV NODE_ENV production
RUN apk add --no-cache tzdata
ENV TZ=Europe/Prague \
LC_ALL=cs_CZ.UTF-8 \
NODE_ENV=production
WORKDIR /app WORKDIR /app
# Export /data/db.json do složky /data
VOLUME ["/data"]
EXPOSE 3001
CMD [ "node", "./server/src/index.js" ]
# ─── Runner (default) ─────────────────────────────────────────────────────────
# Použití: docker build . (lokální sestavení – vše se buildí uvnitř image)
FROM runner-base AS runner
# Vykopírování sestaveného serveru # Vykopírování sestaveného serveru
COPY --from=builder /build/server/node_modules ./server/node_modules COPY --from=builder /build/server/node_modules ./server/node_modules
COPY --from=builder /build/server/dist ./ COPY --from=builder /build/server/dist ./
@@ -59,14 +92,30 @@ COPY --from=builder /build/server/dist ./
COPY --from=builder /build/client/dist ./public COPY --from=builder /build/client/dist ./public
# Zkopírování produkčních .env serveru # Zkopírování produkčních .env serveru
COPY /server/.env.production ./server/src COPY /server/.env.production ./server
# Zkopírování changelogů (seznamu novinek)
COPY /server/changelogs ./server/changelogs
# Zkopírování konfigurace easter eggů # Zkopírování konfigurace easter eggů
RUN if [ -f /server/.easter-eggs.json ]; then cp /server/.easter-eggs.json ./server/; fi RUN if [ -f ./server/.easter-eggs.json ]; then cp ./server/.easter-eggs.json ./server/; fi
# Export /data/db.json do složky /data # ─── Runner (prebuilt) ────────────────────────────────────────────────────────
VOLUME ["/data"] # Použití: docker build --target runner-prebuilt .
# Očekává předem sestavené artefakty v build kontextu (server/dist,
# client/dist, server/node_modules) – využívá Gitea Actions, kde se
# server i klient buildí v separátních jobech a sem se jen kopírují.
FROM runner-base AS runner-prebuilt
EXPOSE 3000 # Vykopírování sestaveného serveru
COPY ./server/node_modules ./server/node_modules
COPY ./server/dist ./
CMD [ "node", "./server/src/index.js" ] # Vykopírování sestaveného klienta
COPY ./client/dist ./public
# Zkopírování changelogů (seznamu novinek)
COPY ./server/changelogs ./server/changelogs
# Zkopírování konfigurace easter eggů
RUN if [ -f ./server/.easter-eggs.json ]; then cp ./server/.easter-eggs.json ./server/; fi
-21
View File
@@ -1,21 +0,0 @@
FROM node:18-alpine3.18
ENV LANG=cs_CZ.UTF-8
ENV NODE_ENV=production
WORKDIR /app
# Vykopírování sestaveného serveru
COPY ./server/node_modules ./server/node_modules
COPY ./server/dist ./
# TODO tohle není dobře, má to být součástí serveru
# COPY ./server/resources ./resources
# Vykopírování sestaveného klienta
COPY ./client/dist ./public
# Zkopírování konfigurace easter eggů
RUN if [ -f ./server/.easter-eggs.json ]; then cp ./server/.easter-eggs.json ./server/; fi
EXPOSE 3000
CMD [ "node", "./server/src/index.js" ]
+13 -3
View File
@@ -1,7 +1,9 @@
# Luncher # Luncher
Aplikace pro profesionální management obědů. Aplikace pro profesionální management obědů.
Aplikace sestává ze dvou modulů + společných TypeScript definic (adresář `types`). Aplikace sestává ze tří modulů.
- types
- OpenAPI definice společných typů, generované přes [openapi-ts](https://github.com/hey-api/openapi-ts)
- server - server
- backend psaný v [node.js](https://nodejs.dev) - backend psaný v [node.js](https://nodejs.dev)
- client - client
@@ -10,19 +12,27 @@ Aplikace sestává ze dvou modulů + společných TypeScript definic (adresář
## Spuštění pro vývoj ## Spuštění pro vývoj
### Závislosti ### Závislosti
#### Klient/server #### Klient/server
- [Node.js 18.x](https://nodejs.dev) - [Node.js 22.x (>= 22.11)](https://nodejs.dev)
- [Yarn 1.22.x (Classic)](https://classic.yarnpkg.com) - [Yarn 1.22.x (Classic)](https://classic.yarnpkg.com)
### Spuštění na *nix platformách ### Spuštění na *nix platformách
- Nainstalovat závislosti viz předchozí bod - Nainstalovat závislosti viz předchozí bod
- Zkopírovat `server/.env.template` do `server/.env.development` a upravit dle potřeby - Zkopírovat `server/.env.template` do `server/.env.development` a upravit dle potřeby
- Spustit `./run_dev.sh`. Na jiných platformách se lze inspirovat jeho obsahem, postup by měl být víceméně stejný. - Vygenerovat společné TypeScript typy
- `cd types && yarn install && yarn openapi-ts`
- Server
- `cd server && yarn install && export NODE_ENV=development && yarn startReload`
- Klient
- `cd client && yarn install && yarn start`
## Sestavení a spuštění produkční verze v Docker ## Sestavení a spuštění produkční verze v Docker
### Závislosti ### Závislosti
- [Docker](https://www.docker.com) - [Docker](https://www.docker.com)
- [Docker Compose](https://docs.docker.com/compose) - [Docker Compose](https://docs.docker.com/compose)
### Spuštění
- `docker compose up --build -d`
### Spuštení s traefik ### Spuštení s traefik
- `docker compose -f compose-traefik.yml up --build -d` - `docker compose -f compose-traefik.yml up --build -d`
+33 -28
View File
@@ -1,39 +1,44 @@
# TODO # TODO
- [ ] HTTP_REMOTE_TRUSTED_IPS se nikde nevalidují, hlavičky jsou přijímány odkudkoli
- [ ] V případě zapnutí přihlašování přes trusted headers nefunguje standardní přihlášení (nevrátí žádnou odpověď) ## HA / multi-replica follow-ups
- [ ] 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í) - [ ] `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`)
- [ ] Možnost úhrady celé útraty jednou osobou - [ ] `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í
- [X] HTTP_REMOTE_TRUSTED_IPS se nikde nevalidují, hlavičky jsou přijímány odkudkoli — kontroluje se skutečná adresa spojení (trustedHeaders.ts)
- [X] V případě zapnutí přihlašování přes trusted headers nefunguje standardní přihlášení (nevrátí žádnou odpověď)
- [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í)
- [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í
Binary file not shown.
+26
View File
@@ -9,7 +9,33 @@
<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>
(function() {
try {
var saved = localStorage.getItem('theme_preference');
var theme;
if (saved === 'dark') {
theme = 'dark';
} else if (saved === 'light') {
theme = 'light';
} else {
// 'system' nebo neuloženo - použij systémové nastavení
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-bs-theme', theme);
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) {
// Fallback pokud localStorage není dostupný
document.documentElement.setAttribute('data-bs-theme', 'light');
}
})();
</script>
</head> </head>
<body> <body>
+28 -22
View File
@@ -6,33 +6,39 @@
"type": "module", "type": "module",
"homepage": ".", "homepage": ".",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.4.0", "@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^6.4.0", "@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0", "@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^3.1.0",
"@types/jest": "^29.5.12", "@sentry/react": "^10.65.0",
"@types/node": "^20.11.20", "@types/jest": "^30.0.0",
"@types/react": "^19.0.0", "@types/node": "^24.10.0",
"@types/react-dom": "^19.0.0", "@types/react": "^19.2.2",
"@vitejs/plugin-react": "^4.3.4", "@types/react-dom": "^19.2.2",
"bootstrap": "^5.2.3", "@vitejs/plugin-react": "^5.1.0",
"react": "^19.0.0", "bootstrap": "^5.3.8",
"react-bootstrap": "^2.7.2", "react": "^19.2.0",
"react-dom": "^19.0.0", "react-bootstrap": "^2.10.10",
"react-jwt": "^1.2.0", "react-datepicker": "^9.1.0",
"react-modal": "^3.16.1", "react-dom": "^19.2.0",
"react-jwt": "^1.3.0",
"react-modal": "^3.16.3",
"react-router": "^7.9.5",
"react-router-dom": "^7.9.5",
"react-select-search": "^4.1.6", "react-select-search": "^4.1.6",
"react-snowfall": "^2.2.0", "react-snow-overlay": "^1.0.14",
"react-toastify": "^10.0.4", "react-snowfall": "^2.3.0",
"sass": "^1.80.6", "react-toastify": "^11.0.5",
"recharts": "^3.4.1",
"sass": "^1.93.3",
"socket.io-client": "^4.6.1", "socket.io-client": "^4.6.1",
"typescript": "^5.3.3", "typescript": "^5.9.3",
"vite": "^6.0.3", "vite": "^7.2.2",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
}, },
"scripts": { "scripts": {
"start": "yarn vite", "start": "yarn vite",
"build": "yarn vite build" "build": "tsc --noEmit && yarn vite build"
}, },
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [
@@ -53,6 +59,6 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"prettier": "^3.2.5" "prettier": "^3.6.2"
} }
} }
+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

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

After

Width:  |  Height:  |  Size: 2.3 KiB

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

After

Width:  |  Height:  |  Size: 2.3 KiB

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

After

Width:  |  Height:  |  Size: 2.3 KiB

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

After

Width:  |  Height:  |  Size: 2.3 KiB

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

After

Width:  |  Height:  |  Size: 2.3 KiB

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

After

Width:  |  Height:  |  Size: 2.3 KiB

+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

+48
View File
@@ -0,0 +1,48 @@
// Service Worker pro Web Push notifikace (připomínka výběru oběda, doručení objednávky)
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? { title: 'Luncher', body: 'Ještě nemáte zvolený oběd!' };
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/favicon.ico',
tag: data.tag ?? 'lunch-reminder',
data: { login: data.login, token: data.token, url: data.url },
// Token posílá jen připomínka oběda — ostatní notifikace tlačítko nemají.
actions: data.token
? [{ action: 'neobedvam', title: 'Mám vlastní/neobědvám' }]
: [],
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'neobedvam') {
const { login, token } = event.notification.data ?? {};
if (login && token) {
event.waitUntil(
fetch('/api/notifications/push/quickChoice', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ login, token }),
})
);
}
return;
}
const url = event.notification.data?.url ?? '/';
event.waitUntil(
self.clients.matchAll({ type: 'window' }).then((clientList) => {
for (const client of clientList) {
if (client.url.includes(self.location.origin) && 'focus' in client) {
if ('navigate' in client) client.navigate(url).catch(() => {});
return client.focus();
}
}
return self.clients.openWindow(url);
})
);
});
+1142 -83
View File
File diff suppressed because it is too large. Load diff
+618 -263
View File
File diff suppressed because it is too large. Load diff
+57
View File
@@ -0,0 +1,57 @@
import { Routes, Route } from "react-router-dom";
import { ProvideSettings, useSettings } from "./context/settings";
import FlyingButterflies, { BUTTERFLY_PRESETS } from "./FlyingButterflies";
import "./FlyingButterflies.scss";
import { ToastContainer } from "react-toastify";
import { SocketContext, socket } from "./context/socket";
import StatsPage from "./pages/StatsPage";
import OrderGroupsPage from "./pages/OrderGroupsPage";
import SuggestionsPage from "./pages/SuggestionsPage";
import App from "./App";
import HellGirlFlames from "./components/HellGirlFlames";
export const STATS_URL = '/stats';
export const OBJEDNANI_URL = '/objednani';
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() {
return (
<ProvideSettings>
<StyleThemeEffects />
<Routes>
<Route path={STATS_URL} element={<StatsPage />} />
<Route path={NAVRHY_URL} element={<SuggestionsPage />} />
<Route path={OBJEDNANI_URL} element={
<SocketContext.Provider value={socket}>
<OrderGroupsPage />
<ToastContainer />
</SocketContext.Provider>
} />
<Route path="/" element={
<SocketContext.Provider value={socket}>
<>
<Butterflies />
<App />
</>
<ToastContainer />
</SocketContext.Provider>
} />
</Routes>
</ProvideSettings>
);
}
+31
View File
@@ -0,0 +1,31 @@
.falling-leaves {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
z-index: 0;
overflow: hidden;
}
.leaf-scene {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 100%;
transform-style: preserve-3d;
div {
position: absolute;
top: 0;
left: 0;
width: 20px;
height: 20px;
background-repeat: no-repeat;
background-size: 100%;
transform-style: preserve-3d;
backface-visibility: visible;
}
}
+317
View File
@@ -0,0 +1,317 @@
import React, { useEffect, useRef, useCallback } from 'react';
// Různé barevné varianty listů
const LEAF_VARIANTS = [
'leaf.svg', // Původní tmavě hnědá
'leaf-orange.svg', // Oranžová
'leaf-yellow.svg', // Žlutá
'leaf-red.svg', // Červená
'leaf-brown.svg', // Světle hnědá
'leaf-green.svg', // Zelená
] as const;
interface LeafData {
el: HTMLDivElement;
x: number;
y: number;
z: number;
rotation: {
axis: 'X' | 'Y' | 'Z';
value: number;
speed: number;
x: number;
};
xSpeedVariation: number;
ySpeed: number;
path: {
type: number;
start: number;
};
image: number;
}
interface WindOptions {
magnitude: number;
maxSpeed: number;
duration: number;
start: number;
speed: (t: number, y: number) => number;
}
interface LeafSceneOptions {
numLeaves: number;
wind: WindOptions;
}
interface FallingLeavesProps {
/** Počet padających listů (výchozí: 20) */
numLeaves?: number;
/** CSS třída pro kontejner (výchozí: 'falling-leaves') */
className?: string;
/** Barevné varianty listů k použití (výchozí: všechny) */
leafVariants?: readonly string[];
}
class LeafScene {
private viewport: HTMLElement;
private world: HTMLDivElement;
private leaves: LeafData[] = [];
private options: LeafSceneOptions;
private width: number;
private height: number;
private timer: number = 0;
private animationId: number | null = null;
private leafVariants: readonly string[];
constructor(el: HTMLElement, numLeaves: number = 20, leafVariants: readonly string[] = LEAF_VARIANTS) {
this.viewport = el;
this.world = document.createElement('div');
this.leafVariants = leafVariants;
this.options = {
numLeaves,
wind: {
magnitude: 1.2,
maxSpeed: 12,
duration: 300,
start: 0,
speed: () => 0
},
};
this.width = this.viewport.offsetWidth;
this.height = this.viewport.offsetHeight;
}
private resetLeaf = (leaf: LeafData): LeafData => {
// place leaf towards the top left
leaf.x = this.width * 2 - Math.random() * this.width * 1.75;
leaf.y = -10;
leaf.z = Math.random() * 200;
if (leaf.x > this.width) {
leaf.x = this.width + 10;
leaf.y = Math.random() * this.height / 2;
}
// at the start, the leaf can be anywhere
if (this.timer === 0) {
leaf.y = Math.random() * this.height;
}
// Choose axis of rotation.
// If axis is not X, chose a random static x-rotation for greater variability
leaf.rotation.speed = Math.random() * 10;
const randomAxis = Math.random();
if (randomAxis > 0.5) {
leaf.rotation.axis = 'X';
} else if (randomAxis > 0.25) {
leaf.rotation.axis = 'Y';
leaf.rotation.x = Math.random() * 180 + 90;
} else {
leaf.rotation.axis = 'Z';
leaf.rotation.x = Math.random() * 360 - 180;
// looks weird if the rotation is too fast around this axis
leaf.rotation.speed = Math.random() * 3;
}
// random speed
leaf.xSpeedVariation = Math.random() * 0.8 - 0.4;
leaf.ySpeed = Math.random() + 1.5;
// randomly select leaf color variant
const randomVariantIndex = Math.floor(Math.random() * this.leafVariants.length);
leaf.image = randomVariantIndex;
// apply the background image to the leaf element
const leafVariant = this.leafVariants[randomVariantIndex];
leaf.el.style.backgroundImage = `url(${leafVariant})`;
return leaf;
};
private updateLeaf = (leaf: LeafData): void => {
const leafWindSpeed = this.options.wind.speed(this.timer - this.options.wind.start, leaf.y);
const xSpeed = leafWindSpeed + leaf.xSpeedVariation;
leaf.x -= xSpeed;
leaf.y += leaf.ySpeed;
leaf.rotation.value += leaf.rotation.speed;
const transform = `translateX(${leaf.x}px) translateY(${leaf.y}px) translateZ(${leaf.z}px) rotate${leaf.rotation.axis}(${leaf.rotation.value}deg)${leaf.rotation.axis !== 'X' ? ` rotateX(${leaf.rotation.x}deg)` : ''
}`;
leaf.el.style.transform = transform;
// reset if out of view
if (leaf.x < -10 || leaf.y > this.height + 10) {
this.resetLeaf(leaf);
}
};
private updateWind = (): void => {
// wind follows a sine curve: asin(b*time + c) + a
// where a = wind magnitude as a function of leaf position, b = wind.duration, c = offset
// wind duration should be related to wind magnitude, e.g. higher windspeed means longer gust duration
if (this.timer === 0 || this.timer > (this.options.wind.start + this.options.wind.duration)) {
this.options.wind.magnitude = Math.random() * this.options.wind.maxSpeed;
this.options.wind.duration = this.options.wind.magnitude * 50 + (Math.random() * 20 - 10);
this.options.wind.start = this.timer;
const screenHeight = this.height;
this.options.wind.speed = function (t: number, y: number) {
// should go from full wind speed at the top, to 1/2 speed at the bottom, using leaf Y
const a = this.magnitude / 2 * (screenHeight - 2 * y / 3) / screenHeight;
return a * Math.sin(2 * Math.PI / this.duration * t + (3 * Math.PI / 2)) + a;
};
}
};
public init = (): void => {
// Clear existing leaves
this.leaves = [];
this.world.innerHTML = '';
for (let i = 0; i < this.options.numLeaves; i++) {
const leaf: LeafData = {
el: document.createElement('div'),
x: 0,
y: 0,
z: 0,
rotation: {
axis: 'X',
value: 0,
speed: 0,
x: 0
},
xSpeedVariation: 0,
ySpeed: 0,
path: {
type: 1,
start: 0,
},
image: 1
};
this.resetLeaf(leaf);
this.leaves.push(leaf);
this.world.appendChild(leaf.el);
}
this.world.className = 'leaf-scene';
this.viewport.appendChild(this.world);
// set perspective
this.world.style.perspective = "400px";
// reset window height/width on resize
const handleResize = (): void => {
this.width = this.viewport.offsetWidth;
this.height = this.viewport.offsetHeight;
};
window.addEventListener('resize', handleResize);
};
public render = (): void => {
this.updateWind();
for (let i = 0; i < this.leaves.length; i++) {
this.updateLeaf(this.leaves[i]);
}
this.timer++;
this.animationId = requestAnimationFrame(this.render);
};
public destroy = (): void => {
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
if (this.world && this.world.parentNode) {
this.world.parentNode.removeChild(this.world);
}
window.removeEventListener('resize', () => {
this.width = this.viewport.offsetWidth;
this.height = this.viewport.offsetHeight;
});
};
}
/**
* Komponenta pro zobrazení padajících listů na pozadí stránky
*
* @param numLeaves - Počet padajících listů (výchozí: 20)
* @param className - CSS třída pro kontejner (výchozí: 'falling-leaves')
* @param leafVariants - Barevné varianty listů k použití (výchozí: všechny)
*
* @example
* // Základní použití s výchozím počtem listů
* <FallingLeaves />
*
* @example
* // Použití s vlastním počtem listů
* <FallingLeaves numLeaves={50} />
*
* @example
* // Použití s vlastní CSS třídou a pouze podzimními barvami
* <FallingLeaves
* numLeaves={15}
* className="autumn-leaves"
* leafVariants={['leaf-orange.svg', 'leaf-red.svg', 'leaf-yellow.svg']}
* />
*/
const FallingLeaves: React.FC<FallingLeavesProps> = ({
numLeaves = 20,
className = 'falling-leaves',
leafVariants = LEAF_VARIANTS
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const leafSceneRef = useRef<LeafScene | null>(null);
const initializeLeafScene = useCallback(() => {
if (containerRef.current) {
leafSceneRef.current = new LeafScene(containerRef.current, numLeaves, leafVariants);
leafSceneRef.current.init();
leafSceneRef.current.render();
}
}, [numLeaves, leafVariants]);
useEffect(() => {
initializeLeafScene();
return () => {
if (leafSceneRef.current) {
leafSceneRef.current.destroy();
leafSceneRef.current = null;
}
};
}, [initializeLeafScene]);
return <div ref={containerRef} className={className} />;
};
// Přednastavení pro různé účely
export const LEAF_PRESETS = {
LIGHT: 10, // Lehký podzimní efekt
NORMAL: 20, // Standardní množství
HEAVY: 40, // Silný podzimní vítr
BLIZZARD: 80 // Hustý pád listí
} as const;
// Přednastavené barevné kombinace
export const LEAF_COLOR_THEMES = {
ALL: LEAF_VARIANTS, // Všechny barvy
AUTUMN: ['leaf-orange.svg', 'leaf-red.svg', 'leaf-yellow.svg', 'leaf-brown.svg'] as const, // Podzimní barvy
WARM: ['leaf-orange.svg', 'leaf-red.svg', 'leaf-brown.svg'] as const, // Teplé barvy
CLASSIC: ['leaf.svg', 'leaf-brown.svg'] as const, // Klasické hnědé odstíny
BRIGHT: ['leaf-yellow.svg', 'leaf-orange.svg'] as const, // Světlé barvy
} as const;
export default FallingLeaves;
+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
+92 -6
View File
@@ -1,13 +1,99 @@
.login { .login-page {
height: 100%; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; align-items: center;
text-align: center;
justify-content: center; justify-content: center;
background: var(--luncher-bg);
padding: 24px;
} }
.login-inner { .login-card {
background: var(--luncher-bg-card);
border-radius: var(--luncher-radius-xl);
box-shadow: var(--luncher-shadow-lg);
padding: 48px;
max-width: 420px;
width: 100%;
text-align: center;
border: 1px solid var(--luncher-border-light);
}
.login-logo {
font-size: 2.5rem;
font-weight: 700;
color: var(--luncher-text);
margin-bottom: 8px;
letter-spacing: -0.5px;
}
.login-subtitle {
color: var(--luncher-text-secondary);
font-size: 1rem;
margin-bottom: 40px;
line-height: 1.5;
}
.login-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; gap: 20px;
}
.login-form label {
display: block;
text-align: left;
font-weight: 500;
color: var(--luncher-text);
margin-bottom: 8px;
}
.login-form .hint {
font-size: 0.85rem;
color: var(--luncher-text-muted);
margin-top: 8px;
text-align: left;
line-height: 1.5;
}
.login-form input[type="text"] {
width: 100%;
padding: 14px 18px;
font-size: 1rem;
border: 2px solid var(--luncher-border);
border-radius: var(--luncher-radius-sm);
background: var(--luncher-bg);
color: var(--luncher-text);
transition: var(--luncher-transition);
}
.login-form input[type="text"]:hover {
border-color: var(--luncher-text-muted);
}
.login-form input[type="text"]:focus {
border-color: var(--luncher-primary);
box-shadow: 0 0 0 3px var(--luncher-primary-light);
outline: none;
}
.login-form input[type="text"]::placeholder {
color: var(--luncher-text-muted);
}
.login-form .btn {
width: 100%;
padding: 14px 24px;
font-size: 1rem;
font-weight: 600;
margin-top: 8px;
}
.login-card > .hint {
color: var(--luncher-text-muted);
}
.login-error {
color: var(--luncher-danger);
line-height: 1.5;
margin: 0;
} }
+75 -29
View File
@@ -1,56 +1,102 @@
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 './api/Api'; 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)
login().then(token => { setTrustedError(undefined);
login().then(response => {
const token = response.data;
if (token) { if (token) {
auth?.setToken(token); auth.setToken(token as unknown as string); // TODO vyřešit, API definice je špatně, je to skutečně string
} 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 && loginRef?.current?.value.length && loginRef.current.value.replace(/\s/g, '').length const length = loginRef?.current?.value.length && loginRef.current.value.replaceAll(/\s/g, '').length
if (length) { if (length) {
// TODO odchytávat cokoliv mimo 200 const response = await login({ body: { login: loginRef.current?.value } });
const token = await login(loginRef.current.value); if (response.data) {
if (token) { auth?.setToken(response.data as unknown as string); // TODO vyřešit
auth?.setToken(token);
} }
} }
}, [auth]); }, [auth]);
if (!auth || !auth.login) { if (auth?.login) {
return <div className='login'> return <div>Neplatný stav</div>
<h1>Luncher</h1> }
<h4 style={{ marginBottom: "50px" }}>Aplikace pro profesionální management obědů</h4>
<div className='login-inner'> const header = <>
<p style={{ fontSize: "12px", marginTop: "10px" }}> <h1 className='login-logo'>Luncher</h1>
Zobrazované jméno by mělo být vaše jméno nebo přezdívka, pod kterou vás kolegové dokáží snadno identifikovat. Jméno je možné kdykoli změnit. <p className='login-subtitle'>Aplikace pro profesionální management obědů</p>
</p> </>;
Zobrazované jméno: <input style={{ marginTop: "10px" }} ref={loginRef} type='text' onKeyDown={event => {
// Přihlašuje proxy — formulář se jménem nedává smysl
if (!loaded || trustedHeaders) {
return (
<div className='login-page'>
<div className='login-card'>
{header}
{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>
<label htmlFor="login-input">Zobrazované jméno</label>
<input
id="login-input"
ref={loginRef}
type='text'
placeholder="Např. Jan Novák"
onKeyDown={event => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
doLogin() doLogin()
} }
}} /> }}
<Button onClick={doLogin} style={{ marginTop: "20px" }}>Uložit</Button> />
<p className='hint'>
Zadejte jméno nebo přezdívku, pod kterou vás kolegové snadno identifikují.
Jméno je možné kdykoli změnit.
</p>
</div>
<Button onClick={doLogin}>Pokračovat</Button>
</div> </div>
</div> </div>
} </div>
return <div>Neplatný stav</div> );
} }
+63 -2
View File
@@ -16,8 +16,8 @@ export const storeToken = (token: string) => {
* *
* @returns token nebo null * @returns token nebo null
*/ */
export const getToken = (): string | null => { export const getToken = (): string | undefined => {
return localStorage.getItem(TOKEN_KEY); return localStorage.getItem(TOKEN_KEY) ?? undefined;
} }
/** /**
@@ -62,3 +62,64 @@ export function isInTheFuture(time: DepartureTime) {
return true; return true;
} }
/**
* Vrátí index dne v týdnu, kde pondělí=0, neděle=6
*
* @param date datum
* @returns index dne v týdnu
*/
export const getDayOfWeekIndex = (date: Date) => {
// https://stackoverflow.com/a/4467559
return (((date.getDay() - 1) % 7) + 7) % 7;
}
/** Vrátí první pracovní den v týdnu předaného data. */
export function getFirstWorkDayOfWeek(date: Date) {
const firstDay = new Date(date);
firstDay.setDate(date.getDate() - getDayOfWeekIndex(date));
return firstDay;
}
/** Vrátí poslední pracovní den v týdnu předaného data. */
export function getLastWorkDayOfWeek(date: Date) {
const lastDay = new Date(date);
lastDay.setDate(date.getDate() + (4 - getDayOfWeekIndex(date)));
return lastDay;
}
/** Vrátí datum v ISO formátu. */
export function formatDate(date: Date, format?: string) {
let day = String(date.getDate()).padStart(2, '0');
let month = String(date.getMonth() + 1).padStart(2, "0");
let year = String(date.getFullYear());
const f = format ?? 'YYYY-MM-DD';
return f.replace('DD', day).replace('MM', month).replace('YYYY', year);
}
/** Vrátí human-readable reprezentaci předaného data pro zobrazení. */
export function getHumanDate(date: Date) {
let currentDay = String(date.getDate()).padStart(2, '0');
let currentMonth = String(date.getMonth() + 1).padStart(2, "0");
let currentYear = date.getFullYear();
return `${currentDay}.${currentMonth}.${currentYear}`;
}
/** Převede datum ve formátu YYYY-MM-DD na DD.MM.YYYY */
export function formatDateString(dateString: string): string {
const [year, month, day] = dateString.split('-');
return `${day}.${month}.${year}`;
}
/**
* Očistí zprávu (účel platby) pro QR platbu – musí odpovídat serverové logice (qr.ts):
* transliteruje diakritiku na základní písmena (š→s, č→c, ...), odstraní znaky mimo
* ISO 8859-1 a hvězdičku (oddělovač polí v QR platbě) a ořízne na max. 60 znaků.
*/
export function sanitizeQrMessage(message: string): string {
const sanitized = message
.normalize('NFD').replace(/[\u0300-\u036f]/g, '') // diakritika → základní písmeno
.replace(/[^\x00-\xff]/g, '') // znaky mimo ISO 8859-1
.replace(/\*/g, ''); // '*' je v QR platbě oddělovač
return sanitized.length > 60 ? sanitized.substring(0, 60) : sanitized;
}
-83
View File
@@ -1,83 +0,0 @@
import { toast } from "react-toastify";
import { getToken } from "../Utils";
/**
* Wrapper pro volání API, u kterých chceme automaticky zobrazit toaster s chybou ze serveru.
*
* @param apiFunction volaná API funkce
*/
export function errorHandler<T>(apiFunction: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
apiFunction().then((result) => {
resolve(result);
}).catch(e => {
toast.error(e.message, { theme: "colored" });
});
});
}
async function request<TResponse>(
url: string,
config: RequestInit = {}
): Promise<TResponse> {
config.headers = config?.headers ? new Headers(config.headers) : new Headers();
config.headers.set("Authorization", `Bearer ${getToken()}`);
try {
const response = await fetch(url, config);
if (!response.ok) {
// TODO tohle je blbě, jelikož automaticky očekáváme, že v případě chyby přijde vždy JSON, což není pravda
const json = await response.json();
// Vyhodíme samotnou hlášku z odpovědi, odchytí si jí errorHandler
throw new Error(json.error);
}
const contentType = response.headers.get("content-type");
if (contentType && contentType.indexOf("application/json") !== -1) {
return response.json() as TResponse;
} else {
return response.text() as TResponse;
}
} catch (e) {
return Promise.reject(e);
}
}
async function blobRequest(
url: string,
config: RequestInit = {}
): Promise<Blob> {
config.headers = config?.headers ? new Headers(config.headers) : new Headers();
config.headers.set("Authorization", `Bearer ${getToken()}`);
try {
const response = await fetch(url, config);
if (!response.ok) {
const json = await response.json();
// Vyhodíme samotnou hlášku z odpovědi, odchytí si jí errorHandler
throw new Error(json.error);
}
return response.blob()
} catch (e) {
return Promise.reject(e);
}
}
export const api = {
get: <TResponse>(url: string) => request<TResponse>(url),
blobGet: (url: string) => blobRequest(url),
post: <TBody, TResponse>(url: string, body?: TBody) => request<TResponse>(url, { method: 'POST', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } }),
}
export const getQrUrl = (login: string) => {
return `/api/qr?login=${login}`;
}
export const getData = async (dayIndex?: number) => {
let url = '/api/data';
if (dayIndex != null) {
url += '?dayIndex=' + dayIndex;
}
return await api.get<any>(url);
}
export const login = async (login?: string) => {
return await api.post<any, any>('/api/login', { login });
}
-12
View File
@@ -1,12 +0,0 @@
import { EasterEgg } from "../../../types";
import { api } from "./Api";
const EASTER_EGGS_API_PREFIX = '/api/easterEggs';
export const getEasterEgg = async (): Promise<EasterEgg | undefined> => {
return await api.get<EasterEgg>(`${EASTER_EGGS_API_PREFIX}`);
}
export const getImage = async (url: string) => {
return await api.blobGet(`${EASTER_EGGS_API_PREFIX}/${url}`);
}
-36
View File
@@ -1,36 +0,0 @@
import {
AddChoiceRequest,
ChangeDepartureTimeRequest,
JdemeObedRequest,
LocationKey,
RemoveChoiceRequest,
RemoveChoicesRequest,
UpdateNoteRequest
} from "../../../types";
import { api } from "./Api";
const FOOD_API_PREFIX = '/api/food';
export const addChoice = async (locationKey: LocationKey, foodIndex?: number, dayIndex?: number) => {
return await api.post<AddChoiceRequest, void>(`${FOOD_API_PREFIX}/addChoice`, { locationKey, foodIndex, dayIndex });
}
export const removeChoices = async (locationKey: LocationKey, dayIndex?: number) => {
return await api.post<RemoveChoicesRequest, void>(`${FOOD_API_PREFIX}/removeChoices`, { locationKey, dayIndex });
}
export const removeChoice = async (locationKey: LocationKey, foodIndex: number, dayIndex?: number) => {
return await api.post<RemoveChoiceRequest, void>(`${FOOD_API_PREFIX}/removeChoice`, { locationKey, foodIndex, dayIndex });
}
export const updateNote = async (note?: string, dayIndex?: number) => {
return await api.post<UpdateNoteRequest, void>(`${FOOD_API_PREFIX}/updateNote`, { note, dayIndex });
}
export const changeDepartureTime = async (time: string, dayIndex?: number) => {
return await api.post<ChangeDepartureTimeRequest, void>(`${FOOD_API_PREFIX}/changeDepartureTime`, { time, dayIndex });
}
export const jdemeObed = async (locationKey: LocationKey) => {
return await api.post<JdemeObedRequest, void>(`${FOOD_API_PREFIX}/jdemeObed`, { locationKey });
}
-44
View File
@@ -1,44 +0,0 @@
import { AddPizzaRequest, FinishDeliveryRequest, PizzaOrder, RemovePizzaRequest, UpdatePizzaDayNoteRequest, UpdatePizzaFeeRequest } from "../../../types";
import { api } from "./Api";
const PIZZADAY_API_PREFIX = '/api/pizzaDay';
export const createPizzaDay = async () => {
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/create`);
}
export const deletePizzaDay = async () => {
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/delete`);
}
export const lockPizzaDay = async () => {
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/lock`);
}
export const unlockPizzaDay = async () => {
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/unlock`);
}
export const finishOrder = async () => {
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/finishOrder`);
}
export const finishDelivery = async (bankAccount?: string, bankAccountHolder?: string) => {
return await api.post<FinishDeliveryRequest, void>(`${PIZZADAY_API_PREFIX}/finishDelivery`, { bankAccount, bankAccountHolder });
}
export const addPizza = async (pizzaIndex: number, pizzaSizeIndex: number) => {
return await api.post<AddPizzaRequest, void>(`${PIZZADAY_API_PREFIX}/add`, { pizzaIndex, pizzaSizeIndex });
}
export const removePizza = async (pizzaOrder: PizzaOrder) => {
return await api.post<RemovePizzaRequest, void>(`${PIZZADAY_API_PREFIX}/remove`, { pizzaOrder });
}
export const updatePizzaDayNote = async (note?: string) => {
return await api.post<UpdatePizzaDayNoteRequest, void>(`${PIZZADAY_API_PREFIX}/updatePizzaDayNote`, { note });
}
export const updatePizzaFee = async (login: string, text?: string, price?: number) => {
return await api.post<UpdatePizzaFeeRequest, void>(`${PIZZADAY_API_PREFIX}/updatePizzaFee`, { login, text, price });
}
-12
View File
@@ -1,12 +0,0 @@
import { FeatureRequest, UpdateFeatureVoteRequest } from "../../../types";
import { api } from "./Api";
const VOTING_API_PREFIX = '/api/voting';
export const getFeatureVotes = async () => {
return await api.get<FeatureRequest[]>(`${VOTING_API_PREFIX}/getVotes`);
}
export const updateFeatureVote = async (option: FeatureRequest, active: boolean) => {
return await api.post<UpdateFeatureVoteRequest, void>(`${VOTING_API_PREFIX}/updateVote`, { option, active });
}
+166
View File
@@ -0,0 +1,166 @@
// Pozvánka Kreslení nad pozvánkami ostatních her, aby se nepřekrývaly
.duel-invite.draw-invite {
bottom: 224px;
}
.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>
);
}
+10 -5
View File
@@ -1,7 +1,12 @@
import { Navbar } from "react-bootstrap";
export default function Footer() { export default function Footer() {
return <Navbar className="text-light" variant='dark' expand="lg" style={{ display: "flex", justifyContent: "center" }}> return (
<span>🄯 Žádná práva nevyhrazena. TODO a zdrojové kódy dostupné <a href="https://gitea.melancholik.eu/mates/Luncher">zde</a>.</span> <footer className="footer">
</Navbar > <span>
Zdroj. kódy dostupné na{' '}
<a href="https://gitea.melancholik.eu/mates/Luncher" target="_blank" rel="noopener noreferrer">
Gitea
</a>
</span>
</footer>
);
} }
+178 -40
View File
@@ -1,43 +1,98 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Navbar, Nav, NavDropdown } 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 { useSettings } from "../context/settings"; import ReactionDuel from "./ReactionDuel";
import FeaturesVotingModal from "./modals/FeaturesVotingModal"; import SnakeGame from "./SnakeGame";
import { FeatureRequest } from "../../../types"; import TetrisGame from "./TetrisGame";
import { errorHandler } from "../api/Api"; import PongGame from "./PongGame";
import { getFeatureVotes, updateFeatureVote } from "../api/VotingApi"; import DrawingGame from "./DrawingGame";
import TankGame from "./TankGame";
import { useMinigameSecret } from "../hooks/useMinigameSecret";
import { useSettings, ThemePreference } from "../context/settings";
import HuePicker from "./HuePicker";
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal"; import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
import RefreshMenuModal from "./modals/RefreshMenuModal";
import GenerateQrModal from "./modals/GenerateQrModal";
import GenerateMockDataModal from "./modals/GenerateMockDataModal";
import ClearMockDataModal from "./modals/ClearMockDataModal";
import { useNavigate } from "react-router";
import { STATS_URL, OBJEDNANI_URL, NAVRHY_URL } from "../AppRoutes";
import { LunchChoices, getChangelogs } from "../../../types";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
import { formatDateString } from "../Utils";
const LAST_SEEN_CHANGELOG_KEY = "lastChangelogDate";
export default function Header() { const IS_DEV = process.env.NODE_ENV === 'development';
type Props = {
choices?: LunchChoices;
dayIndex?: number;
};
export default function Header({ choices, dayIndex }: Props) {
const auth = useAuth(); const auth = useAuth();
const settings = useSettings(); const settings = useSettings();
const navigate = useNavigate();
const [settingsModalOpen, setSettingsModalOpen] = useState<boolean>(false); const [settingsModalOpen, setSettingsModalOpen] = useState<boolean>(false);
const [votingModalOpen, setVotingModalOpen] = useState<boolean>(false);
const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false); const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false);
const [featureVotes, setFeatureVotes] = useState<FeatureRequest[]>([]); const [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState<boolean>(false);
const [changelogModalOpen, setChangelogModalOpen] = useState<boolean>(false);
const [changelogEntries, setChangelogEntries] = useState<Record<string, string[]>>({});
const [qrModalOpen, setQrModalOpen] = useState<boolean>(false);
const [generateMockModalOpen, setGenerateMockModalOpen] = useState<boolean>(false);
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
const effectiveDark = settings?.effectiveDark ?? false;
const minigameSecret = useMinigameSecret("/");
useEffect(() => { useEffect(() => {
if (auth?.login) { if (!auth?.login) return;
getFeatureVotes().then(votes => { const lastSeen = localStorage.getItem(LAST_SEEN_CHANGELOG_KEY) ?? undefined;
setFeatureVotes(votes); getChangelogs({ query: lastSeen ? { since: lastSeen } : {} }).then(response => {
}) const entries = response.data;
} if (!entries || Object.keys(entries).length === 0) return;
setChangelogEntries(entries);
setChangelogModalOpen(true);
const newestDate = Object.keys(entries).sort((a, b) => b.localeCompare(a))[0];
localStorage.setItem(LAST_SEEN_CHANGELOG_KEY, newestDate);
});
}, [auth?.login]); }, [auth?.login]);
const closeSettingsModal = () => { const closeSettingsModal = () => {
setSettingsModalOpen(false); setSettingsModalOpen(false);
} }
const closeVotingModal = () => {
setVotingModalOpen(false);
}
const closePizzaModal = () => { const closePizzaModal = () => {
setPizzaModalOpen(false); setPizzaModalOpen(false);
} }
const closeRefreshMenuModal = () => {
setRefreshMenuModalOpen(false);
}
const closeQrModal = () => {
setQrModalOpen(false);
}
const handleQrMenuClick = () => {
if (!settings?.bankAccount || !settings?.holderName) {
alert('Pro generování QR kódů je nutné mít v nastavení vyplněné číslo účtu a jméno držitele účtu.');
return;
}
setQrModalOpen(true);
}
// 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 newTheme: ThemePreference = effectiveDark ? 'light' : 'dark';
settings?.setThemePreference(newTheme);
}
const isValidInteger = (str: string) => { const isValidInteger = (str: string) => {
str = str.trim(); str = str.trim();
if (!str) { if (!str) {
@@ -48,19 +103,19 @@ export default function Header() {
return n !== Infinity && String(n) === str && n >= 0; return n !== Infinity && String(n) === str && n >= 0;
} }
const saveSettings = (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean) => { const saveSettings = (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean, themePreference?: ThemePreference) => {
if (bankAccountNumber) { if (bankAccountNumber) {
try { try {
// Validace kódu banky // Validace kódu banky
if (bankAccountNumber.indexOf('/') < 0) { if (!bankAccountNumber.includes('/')) {
throw Error("Číslo účtu neobsahuje lomítko/kód banky") throw new Error("Číslo účtu neobsahuje lomítko/kód banky")
} }
const split = bankAccountNumber.split("/"); const split = bankAccountNumber.split("/");
if (split[1].length !== 4) { if (split[1].length !== 4) {
throw Error("Kód banky musí být 4 číslice") throw new Error("Kód banky musí být 4 číslice")
} }
if (!isValidInteger(split[1])) { if (!isValidInteger(split[1])) {
throw Error("Kód banky není číslo") throw new Error("Kód banky není číslo")
} }
// Validace čísla a předčíslí // Validace čísla a předčíslí
@@ -70,20 +125,20 @@ export default function Header() {
cislo = cislo.replace('-', ''); cislo = cislo.replace('-', '');
} }
if (!isValidInteger(cislo)) { if (!isValidInteger(cislo)) {
throw Error("Předčíslí nebo číslo účtu neobsahuje pouze číslice") throw new Error("Předčíslí nebo číslo účtu neobsahuje pouze číslice")
} }
if (cislo.length < 16) { if (cislo.length < 16) {
cislo = cislo.padStart(16, '0'); cislo = cislo.padStart(16, '0');
} }
let sum = 0; let sum = 0;
for (var i = 0; i < cislo.length; i++) { for (let i = 0; i < cislo.length; i++) {
const char = cislo.charAt(i); const char = cislo.charAt(i);
const order = (cislo.length - 1) - i; const order = (cislo.length - 1) - i;
const weight = (2 ** order) % 11; const weight = (2 ** order) % 11;
sum += Number.parseInt(char) * weight sum += Number.parseInt(char) * weight
} }
if (sum % 11 !== 0) { if (sum % 11 !== 0) {
throw Error("Číslo účtu je neplatné") throw new Error("Číslo účtu je neplatné")
} }
} catch (e: any) { } catch (e: any) {
alert(e.message) alert(e.message)
@@ -93,36 +148,119 @@ export default function Header() {
settings?.setBankAccountNumber(bankAccountNumber); settings?.setBankAccountNumber(bankAccountNumber);
settings?.setBankAccountHolderName(bankAccountHolderName); settings?.setBankAccountHolderName(bankAccountHolderName);
settings?.setHideSoupsOption(hideSoupsOption); settings?.setHideSoupsOption(hideSoupsOption);
if (themePreference) {
settings?.setThemePreference(themePreference);
}
closeSettingsModal(); closeSettingsModal();
} }
const saveFeatureVote = async (option: FeatureRequest, active: boolean) => {
await errorHandler(() => updateFeatureVote(option, active));
const votes = [...featureVotes];
if (active) {
votes.push(option);
} else {
votes.splice(votes.indexOf(option), 1);
}
setFeatureVotes(votes);
}
return <Navbar variant='dark' expand="lg"> return <Navbar variant='dark' expand="lg">
<Navbar.Brand>Luncher</Navbar.Brand> <Navbar.Brand href="/" 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">
<button
className="theme-toggle"
onClick={toggleTheme}
title={effectiveDark ? 'Přepnout na světlý režim' : 'Přepnout na tmavý režim'}
aria-label="Přepnout světlý/tmavý režim"
>
<FontAwesomeIcon icon={effectiveDark ? faSun : faMoon} />
</button>
{isSimpleStyle && <HuePicker
accentHue={settings?.accentHue ?? 142}
isDark={effectiveDark}
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={() => setVotingModalOpen(true)}>Hlasovat o nových funkcích</NavDropdown.Item> <NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(NAVRHY_URL)}>Návrhy na vylepšení</NavDropdown.Item>
<NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item> <NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item>
<NavDropdown.Item onClick={handleQrMenuClick}>Generování QR kódů</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(OBJEDNANI_URL)}>Objednání</NavDropdown.Item>
<NavDropdown.Item onClick={() => {
getChangelogs().then(response => {
const entries = response.data ?? {};
setChangelogEntries(entries);
setChangelogModalOpen(true);
const dates = Object.keys(entries).sort((a, b) => b.localeCompare(a));
if (dates.length > 0) {
localStorage.setItem(LAST_SEEN_CHANGELOG_KEY, dates[0]);
}
});
}}>Novinky</NavDropdown.Item>
{IS_DEV && (
<>
<NavDropdown.Divider />
<NavDropdown.Item onClick={() => setGenerateMockModalOpen(true)}>🔧 Generovat mock data</NavDropdown.Item>
<NavDropdown.Item onClick={() => setClearMockModalOpen(true)}>🔧 Smazat mock data</NavDropdown.Item>
</>
)}
<NavDropdown.Divider /> <NavDropdown.Divider />
<NavDropdown.Item onClick={auth?.logout}>Odhlásit se</NavDropdown.Item> <NavDropdown.Item onClick={auth?.logout}>Odhlásit se</NavDropdown.Item>
</NavDropdown> </NavDropdown>
</Nav> </Nav>
</Navbar.Collapse> </Navbar.Collapse>
<ReactionDuel />
<SnakeGame />
<TetrisGame />
<PongGame />
<DrawingGame />
<TankGame />
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} /> <SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
<FeaturesVotingModal isOpen={votingModalOpen} onClose={closeVotingModal} onChange={saveFeatureVote} initialValues={featureVotes} /> <RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} /> <PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
{choices && settings?.bankAccount && settings?.holderName && (
<GenerateQrModal
isOpen={qrModalOpen}
onClose={closeQrModal}
choices={choices}
bankAccount={settings.bankAccount}
bankAccountHolder={settings.holderName}
/>
)}
{IS_DEV && (
<>
<GenerateMockDataModal
isOpen={generateMockModalOpen}
onClose={() => setGenerateMockModalOpen(false)}
currentDayIndex={dayIndex}
/>
<ClearMockDataModal
isOpen={clearMockModalOpen}
onClose={() => setClearMockModalOpen(false)}
currentDayIndex={dayIndex}
/>
</>
)}
<Modal show={changelogModalOpen} onHide={() => setChangelogModalOpen(false)} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Novinky</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{Object.keys(changelogEntries).sort((a, b) => b.localeCompare(a)).map(date => (
<div key={date}>
<strong>{formatDateString(date)}</strong>
<ul>
{changelogEntries[date].map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
))}
{Object.keys(changelogEntries).length === 0 && (
<p>Žádné novinky.</p>
)}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => setChangelogModalOpen(false)}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
</Navbar> </Navbar>
} }
+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;
+141
View File
@@ -0,0 +1,141 @@
.hue-picker-dropdown {
display: flex;
align-items: center;
.dropdown-toggle {
background: transparent !important;
border: none !important;
color: var(--luncher-navbar-text) !important;
padding: 8px 12px;
font-size: 1.1rem;
display: flex;
align-items: center;
cursor: pointer;
border-radius: var(--luncher-radius-sm);
transition: var(--luncher-transition);
&::after {
display: none;
}
&:hover {
background: rgba(255, 255, 255, 0.1) !important;
transform: scale(1.1);
}
&:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.3) !important;
}
}
}
.hue-picker-panel {
padding: 0 !important;
min-width: 240px;
.hue-picker-inner {
padding: 14px 16px;
}
.hue-picker-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--luncher-text-secondary);
margin-bottom: 12px;
}
}
.hue-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 12px;
border-radius: 6px;
background: linear-gradient(
to right,
hsl(0 70% 50%), hsl(30 70% 50%), hsl(60 70% 50%), hsl(90 70% 50%),
hsl(120 70% 50%), hsl(150 70% 50%), hsl(180 70% 50%), hsl(210 70% 50%),
hsl(240 70% 50%), hsl(270 70% 50%), hsl(300 70% 50%), hsl(330 70% 50%), hsl(360 70% 50%)
);
outline: none;
cursor: pointer;
margin-bottom: 14px;
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: white;
border: 2px solid rgba(0, 0, 0, 0.25);
cursor: pointer;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
transition: transform 0.15s ease;
&:hover {
transform: scale(1.15);
}
}
&::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: white;
border: 2px solid rgba(0, 0, 0, 0.25);
cursor: pointer;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
}
}
.hue-presets {
display: flex;
gap: 8px;
margin-bottom: 14px;
.hue-swatch {
width: 26px;
height: 26px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
padding: 0;
transition: transform 0.15s ease, border-color 0.15s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
&:hover {
transform: scale(1.15);
}
&.active {
border-color: var(--luncher-text);
transform: scale(1.1);
}
}
}
.hue-preview {
display: flex;
align-items: center;
gap: 10px;
padding-top: 4px;
border-top: 1px solid var(--luncher-border);
.hue-preview-chip {
width: 32px;
height: 32px;
border-radius: var(--luncher-radius-sm);
flex-shrink: 0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: background 0.2s ease;
}
span {
font-size: 0.8rem;
color: var(--luncher-text-secondary);
}
}
+71
View File
@@ -0,0 +1,71 @@
import { Dropdown } from 'react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPalette } from '@fortawesome/free-solid-svg-icons';
import './HuePicker.scss';
const PRESETS = [
{ hue: 142, label: 'Zelená' },
{ hue: 217, label: 'Modrá' },
{ hue: 263, label: 'Fialová' },
{ hue: 0, label: 'Červená' },
{ hue: 28, label: 'Oranžová' },
{ hue: 340, label: 'Růžová' },
];
type Props = {
accentHue: number;
isDark: boolean;
onChange: (hue: number) => void;
};
function swatchColor(hue: number, isDark: boolean): string {
return `hsl(${hue} 70% ${isDark ? 55 : 38}%)`;
}
export default function HuePicker({ accentHue, isDark, onChange }: Props) {
return (
<Dropdown align="end" autoClose="outside" className="hue-picker-dropdown">
<Dropdown.Toggle
as="button"
className="theme-toggle"
aria-label="Barva zvýraznění"
title="Barva zvýraznění"
>
<FontAwesomeIcon icon={faPalette} />
</Dropdown.Toggle>
<Dropdown.Menu className="hue-picker-panel">
<div className="hue-picker-inner">
<div className="hue-picker-label">Barva zvýraznění</div>
<input
type="range"
min={0}
max={360}
value={accentHue}
onChange={e => onChange(parseInt(e.target.value, 10))}
className="hue-slider"
aria-label="Odstín barvy zvýraznění"
/>
<div className="hue-presets">
{PRESETS.map(p => (
<button
key={p.hue}
className={`hue-swatch${accentHue === p.hue ? ' active' : ''}`}
style={{ background: swatchColor(p.hue, isDark) }}
title={p.label}
onClick={() => onChange(p.hue)}
aria-label={p.label}
/>
))}
</div>
<div className="hue-preview">
<div
className="hue-preview-chip"
style={{ background: swatchColor(accentHue, isDark) }}
/>
<span>Aktuální barva zvýraznění</span>
</div>
</div>
</Dropdown.Menu>
</Dropdown>
);
}
+10 -8
View File
@@ -2,18 +2,20 @@ import { IconDefinition } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
type Props = { type Props = {
title?: String, title?: string,
icon: IconDefinition, icon: IconDefinition,
description: String, description: string,
animation?: String, animation?: string,
} }
function Loader(props: Props) { function Loader(props: Readonly<Props>) {
return <div className='loader'> return (
<h1>{props.title || 'Prosím čekejte...'}</h1> <div className='loader'>
<FontAwesomeIcon icon={props.icon} className={`loader-icon mb-3 ` + (props.animation ?? '')} /> <FontAwesomeIcon icon={props.icon} className={`loader-icon ${props.animation ?? ''}`} />
<p>{props.description}</p> <h2 className='loader-title'>{props.title ?? 'Prosím čekejte...'}</h2>
<p className='loader-description'>{props.description}</p>
</div> </div>
);
} }
export default Loader; export default Loader;
+35
View File
@@ -0,0 +1,35 @@
.lobby-chat {
margin-top: 12px;
.lobby-chat-messages {
height: 140px;
overflow-y: auto;
border: 1px solid rgba(127, 127, 127, 0.35);
border-radius: 6px;
padding: 6px 8px;
margin-bottom: 6px;
font-size: 0.9rem;
word-break: break-word;
}
.lobby-chat-emojis {
display: flex;
flex-wrap: wrap;
gap: 2px;
margin-bottom: 4px;
button {
border: none;
background: none;
padding: 0 3px;
font-size: 1.2rem;
line-height: 1.4;
border-radius: 4px;
cursor: pointer;
&:hover {
background: rgba(127, 127, 127, 0.2);
}
}
}
}
+64
View File
@@ -0,0 +1,64 @@
import { FormEvent, useEffect, useRef, useState } from "react";
import { Form } from "react-bootstrap";
import { socket } from "../context/socket";
import { getToken } from "../Utils";
import "./LobbyChat.scss";
const EMOJIS = ['😀', '😂', '😎', '😢', '😡', '👍', '👎', '👋', '🎉', '🔥', '❤️', '🍕', '🍔', '🍺', '⏳'];
type LobbyChatMessage = { game: string, login: string, text: string };
/** Jednoduchý chat v lobby minihry — zprávy se drží jen po dobu zobrazení lobby. */
export default function LobbyChat({ game }: { game: string }) {
const [messages, setMessages] = useState<LobbyChatMessage[]>([]);
const [text, setText] = useState('');
const listRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const onMessage = (m: LobbyChatMessage) => {
if (m.game === game) setMessages(prev => [...prev.slice(-99), m]);
};
socket.on('lobbychat:message', onMessage);
return () => { socket.off('lobbychat:message', onMessage); };
}, [game]);
useEffect(() => {
listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
}, [messages]);
const submit = (e: FormEvent) => {
e.preventDefault();
if (!text.trim()) return;
socket.emit('lobbychat:send', { token: getToken(), game, text });
setText('');
};
const addEmoji = (emoji: string) => {
setText(prev => (prev + emoji).slice(0, 200));
inputRef.current?.focus();
};
return (
<div className="lobby-chat">
<div className="lobby-chat-messages" ref={listRef}>
{messages.length === 0 && <div className="text-muted">Zatím žádné zprávy…</div>}
{messages.map((m, i) => <div key={i}><strong>{m.login}: </strong>{m.text}</div>)}
</div>
<div className="lobby-chat-emojis">
{EMOJIS.map(e => <button key={e} type="button" onClick={() => addEmoji(e)}>{e}</button>)}
</div>
<Form onSubmit={submit}>
<Form.Control
ref={inputRef}
size="sm"
value={text}
onChange={e => setText(e.target.value)}
placeholder="Napište zprávu…"
maxLength={200}
onKeyDown={e => e.stopPropagation()}
/>
</Form>
</div>
);
}
+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>
);
}
+71
View File
@@ -0,0 +1,71 @@
.tracking-progress {
display: flex;
align-items: flex-start;
max-width: 400px;
.tracking-step {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
flex: 1;
min-width: 64px;
.tracking-dot {
position: relative;
z-index: 1;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--luncher-border, #ced4da);
}
.tracking-label {
margin-top: 2px;
font-size: 0.7em;
color: var(--luncher-text-muted, #6c757d);
white-space: nowrap;
}
// Spojnice k předchozímu kroku – vede od středu této tečky doleva ke středu předchozí.
// Tečka má průměr 12px, takže její střed je 6px od levého okraje segmentu.
&:not(:first-child)::before {
content: '';
position: absolute;
top: 5px;
left: calc(6px - 100%);
width: 100%;
height: 2px;
background: var(--luncher-border, #ced4da);
}
&.done {
.tracking-dot {
background: var(--bs-success, #198754);
}
&:not(:first-child)::before {
background: var(--bs-success, #198754);
}
}
&.active .tracking-label {
font-weight: 600;
color: inherit;
}
}
// Pulzování aktivního kroku, dokud sledování běží
&.live .tracking-step.active .tracking-dot {
animation: tracking-pulse 2s ease-in-out infinite;
}
}
@keyframes tracking-pulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(25, 135, 84, 0.5);
}
50% {
box-shadow: 0 0 0 5px rgba(25, 135, 84, 0);
}
}
+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>
);
}
+102
View File
@@ -0,0 +1,102 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Modal } from 'react-bootstrap';
import { PendingQr, dismissQr } from '../../../types';
import { formatDateString } from '../Utils';
import ConfirmModal from './modals/ConfirmModal';
type Props = {
pendingQrs?: PendingQr[];
login?: string;
// Zavolá se po úspěšném potvrzení platby, aby si rodič mohl znovu načíst data
onDismissed?: () => void | Promise<void>;
};
// Sekce "Nevyřízené platby" – zobrazí QR kódy neuhrazených plateb přihlášeného uživatele
// včetně tlačítka "Zaplatil jsem" a potvrzovacího dialogu. Sdíleno hlavní stránkou i stránkou objednávek.
// Při příchodu nových nevyřízených plateb se navíc automaticky otevře modální dialog,
// aby si uživatel QR kódů určitě všiml (často si jich nevšimnou, protože sekce je dole na stránce).
export default function PendingPayments({ pendingQrs, login, onDismissed }: Readonly<Props>) {
const [dismissQrId, setDismissQrId] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);
// ID QR kódů, pro které už byl v rámci tohoto načtení stránky automaticky zobrazen
// modální dialog. Drží se jen v paměti (ne v sessionStorage), takže se při každém
// ručním přenačtení stránky vynuluje a dialog se znovu otevře, dokud uživatel platby
// neuhradí. Zároveň se nepřekrývá při pouhém obnovení dat či příchodu už zobrazeného QR.
const autoShownQrIds = useRef<Set<string>>(new Set());
const qrIdsKey = (pendingQrs ?? []).map(qr => qr.id).join(',');
// Automaticky otevřeme modální dialog, jakmile přijdou nové (dosud nezobrazené) platby.
useEffect(() => {
const ids = (pendingQrs ?? []).map(qr => qr.id);
if (ids.length === 0) return;
const unseen = ids.filter(id => !autoShownQrIds.current.has(id));
if (unseen.length > 0) {
setModalOpen(true);
unseen.forEach(id => autoShownQrIds.current.add(id));
}
}, [qrIdsKey, pendingQrs]);
if (!pendingQrs || pendingQrs.length === 0) return null;
// Vykreslení jednoho QR kódu i s tlačítkem "Zaplatil jsem" – sdíleno sekcí i modálem.
const renderQr = (qr: PendingQr) => (
<div key={qr.id} className='qr-code mb-3'>
<p>
<strong>{formatDateString(qr.date)}</strong> — {qr.creator} ({qr.totalPrice / 100} Kč)
{qr.purpose && <><br /><span className="text-muted">{qr.purpose}</span></>}
</p>
<img src={`/api/qr?login=${login}&id=${qr.id}`} alt='QR kód' />
<div className='mt-2'>
<Button variant="success" onClick={() => setDismissQrId(qr.id)}>
Zaplatil jsem
</Button>
</div>
</div>
);
return (
<>
<div className='pizza-section fade-in mt-4'>
<h3>Nevyřízené platby</h3>
<p>
Máte neuhrazené platby.{' '}
<Button variant="link" className="p-0 align-baseline" onClick={() => setModalOpen(true)}>
Zobrazit QR kódy
</Button>
</p>
{pendingQrs.map(renderQr)}
</div>
<Modal show={modalOpen} onHide={() => setModalOpen(false)} centered scrollable>
<Modal.Header closeButton>
<Modal.Title>Nevyřízené platby</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>Máte neuhrazené platby. Naskenujte QR kód pro zaplacení.</p>
{pendingQrs.map(renderQr)}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => setModalOpen(false)}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
<ConfirmModal
isOpen={dismissQrId !== null}
title="Potvrzení platby"
message="Opravdu jste zaplatili? QR kód bude odstraněn."
confirmLabel="Zaplatil jsem"
confirmVariant="success"
onClose={() => setDismissQrId(null)}
onConfirm={async () => {
if (!dismissQrId) return;
const id = dismissQrId;
setDismissQrId(null);
await dismissQr({ body: { id } });
await onDismissed?.();
}}
/>
</>
);
}
+31 -20
View File
@@ -1,46 +1,57 @@
import { Table } from "react-bootstrap"; import { Table } from "react-bootstrap";
import { Order, PizzaDayState, PizzaOrder } from "../../../types";
import PizzaOrderRow from "./PizzaOrderRow"; import PizzaOrderRow from "./PizzaOrderRow";
import { updatePizzaFee } from "../api/PizzaDayApi"; import { PizzaDayState, PizzaOrder, PizzaVariant, updatePizzaFee } from "../../../types";
type Props = { type Props = {
state: PizzaDayState, state: PizzaDayState,
orders: Order[], orders: PizzaOrder[],
onDelete: (pizzaOrder: PizzaOrder) => void, onDelete: (pizzaOrder: PizzaVariant) => void,
creator: string, creator: string,
} }
export default function PizzaOrderList({ state, orders, onDelete, creator }: Props) { export default function PizzaOrderList({ state, orders, onDelete, creator }: Readonly<Props>) {
const saveFees = async (customer: string, text?: string, price?: number) => { const saveFees = async (customer: string, text?: string, price?: number) => {
await updatePizzaFee(customer, text, price); await updatePizzaFee({ body: { login: customer, text, price } });
} }
if (!orders?.length) { if (!orders?.length) {
return <p className="mt-3"><i>Zatím žádné objednávky...</i></p> return <p className="mt-4" style={{ color: 'var(--luncher-text-muted)', fontStyle: 'italic' }}>Zatím žádné objednávky...</p>
} }
const total = orders.reduce((total, order) => total + order.totalPrice, 0); const total = orders.reduce((total, order) => total + order.totalPrice, 0);
return <> return (
<Table className="mt-3" striped bordered hover> <div className="mt-4" style={{
<thead> background: 'var(--luncher-bg-card)',
borderRadius: 'var(--luncher-radius-lg)',
overflow: 'hidden',
border: '1px solid var(--luncher-border-light)',
boxShadow: 'var(--luncher-shadow)'
}}>
<Table className="mb-0" style={{ color: 'var(--luncher-text)' }}>
<thead style={{ background: 'var(--luncher-primary-light)' }}>
<tr> <tr>
<th>Jméno</th> <th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none' }}>Jméno</th>
<th>Objednávka</th> <th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none' }}>Objednávka</th>
<th>Poznámka</th> <th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none' }}>Poznámka</th>
<th>Příplatek</th> <th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none' }}>Příplatek</th>
<th>Cena</th> <th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none', textAlign: 'right' }}>Cena</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{orders.map(order => <tr key={order.customer}> {orders.map(order => <tr key={order.customer} style={{ borderColor: 'var(--luncher-border-light)' }}>
<PizzaOrderRow creator={creator} state={state} order={order} onDelete={onDelete} onFeeModalSave={saveFees} /> <PizzaOrderRow creator={creator} state={state} order={order} onDelete={onDelete} onFeeModalSave={saveFees} />
</tr>)} </tr>)}
<tr style={{ fontWeight: 'bold' }}> <tr style={{
<td colSpan={4}>Celkem</td> fontWeight: 700,
<td>{`${total} Kč`}</td> background: 'var(--luncher-bg-hover)',
borderTop: '2px solid var(--luncher-border)'
}}>
<td colSpan={4} style={{ padding: '16px 20px', border: 'none' }}>Celkem</td>
<td style={{ padding: '16px 20px', border: 'none', textAlign: 'right', color: 'var(--luncher-primary)' }}>{`${total / 100} Kč`}</td>
</tr> </tr>
</tbody> </tbody>
</Table> </Table>
</> </div>
);
} }
+16 -14
View File
@@ -2,44 +2,46 @@ import React, { useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faMoneyBill1, faTrashCan } from "@fortawesome/free-regular-svg-icons"; import { faMoneyBill1, faTrashCan } from "@fortawesome/free-regular-svg-icons";
import { useAuth } from "../context/auth"; import { useAuth } from "../context/auth";
import { Order, PizzaDayState, PizzaOrder } from "../../../types";
import PizzaAdditionalFeeModal from "./modals/PizzaAdditionalFeeModal"; import PizzaAdditionalFeeModal from "./modals/PizzaAdditionalFeeModal";
import { PizzaDayState, PizzaOrder, PizzaVariant } from "../../../types";
type Props = { type Props = {
creator: string, creator: string,
order: Order, order: PizzaOrder,
state: PizzaDayState, state: PizzaDayState,
onDelete: (order: PizzaOrder) => void, onDelete: (order: PizzaVariant) => void,
onFeeModalSave: (customer: string, name?: string, price?: number) => void, onFeeModalSave: (customer: string, name?: string, price?: number) => void,
} }
export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeModalSave }: Props) { export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeModalSave }: Readonly<Props>) {
const auth = useAuth(); const auth = useAuth();
const [isFeeModalOpen, setFeeModalOpen] = useState<boolean>(false); const [isFeeModalOpen, setIsFeeModalOpen] = useState<boolean>(false);
const saveFees = (customer: string, text?: string, price?: number) => { const saveFees = (customer: string, text?: string, price?: number) => {
onFeeModalSave(customer, text, price); onFeeModalSave(customer, text, price);
setFeeModalOpen(false); setIsFeeModalOpen(false);
} }
return <> return <>
<td>{order.customer}</td> <td>{order.customer}</td>
<td>{order.pizzaList.map<React.ReactNode>((pizzaOrder, index) => <td>{order.pizzaList!.map<React.ReactNode>(pizzaOrder =>
<span key={index}> <span key={pizzaOrder.name}>
{`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price} Kč)`} {`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price / 100} Kč)`}
{auth?.login === order.customer && state === PizzaDayState.CREATED && {auth?.login === order.customer && state === PizzaDayState.CREATED &&
<span title='Odstranit'>
<FontAwesomeIcon onClick={() => { <FontAwesomeIcon onClick={() => {
onDelete(pizzaOrder); onDelete(pizzaOrder);
}} title='Odstranit' className='action-icon' icon={faTrashCan} /> }} className='action-icon' icon={faTrashCan} />
</span>
} }
</span>) </span>)
.reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])} .reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])}
</td> </td>
<td style={{ maxWidth: "200px" }}>{order.note || '-'}</td> <td style={{ maxWidth: "200px" }}>{order.note ?? '-'}</td>
<td style={{ maxWidth: "200px" }}>{order.fee?.price ? `${order.fee.price} Kč${order.fee.text ? ` (${order.fee.text})` : ''}` : '-'}</td> <td style={{ maxWidth: "200px" }}>{order.fee?.price ? `${order.fee.price / 100} Kč${order.fee.text ? ` (${order.fee.text})` : ''}` : '-'}</td>
<td> <td>
{order.totalPrice} Kč{auth?.login === creator && state === PizzaDayState.CREATED && <FontAwesomeIcon onClick={() => { setFeeModalOpen(true) }} title='Nastavit příplatek' className='action-icon' icon={faMoneyBill1} />} {order.totalPrice / 100} Kč{auth?.login === creator && state === PizzaDayState.CREATED && <span title='Nastavit příplatek'><FontAwesomeIcon onClick={() => { setIsFeeModalOpen(true) }} className='action-icon' icon={faMoneyBill1} /></span>}
</td> </td>
<PizzaAdditionalFeeModal customerName={order.customer} isOpen={isFeeModalOpen} onClose={() => setFeeModalOpen(false)} onSave={saveFees} initialValues={{ text: order.fee?.text, price: order.fee?.price?.toString() }} /> <PizzaAdditionalFeeModal customerName={order.customer} isOpen={isFeeModalOpen} onClose={() => setIsFeeModalOpen(false)} onSave={saveFees} initialValues={{ text: order.fee?.text, price: order.fee?.price != null ? String(order.fee.price / 100) : undefined }} />
</> </>
} }
+71
View File
@@ -0,0 +1,71 @@
// Pozvánka Pongu nad pozvánkami ostatních her, aby se nepřekrývaly
.duel-invite.pong-invite {
bottom: 172px;
}
.pong-game-modal {
.pong-scoreboard {
display: flex;
justify-content: center;
align-items: baseline;
gap: 24px;
font-size: 1.1rem;
margin-bottom: 8px;
strong {
font-size: 2rem;
}
}
.pong-board-wrap {
position: relative;
max-width: 800px;
margin: 0 auto;
// Tah prstem po hřišti hýbe pálkou, ne stránkou
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.pong-board {
display: block;
width: 100%;
height: auto;
border-radius: 8px;
}
.pong-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 5rem;
font-weight: 700;
color: #fff;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6);
pointer-events: none;
&.small {
font-size: 2.5rem;
}
}
.pong-bot-picker {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
.form-select {
width: auto;
}
}
.pong-status {
text-align: center;
opacity: 0.7;
margin-top: 8px;
}
}
+317
View File
@@ -0,0 +1,317 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Button, Form, Modal } from "react-bootstrap";
import { socket } from "../context/socket";
import { useAuth } from "../context/auth";
import { getToken } from "../Utils";
import { useGameIcon } from "../games/themeIcons";
import { useTouchDevice } from "../hooks/useTouchDevice";
import LobbyChat from "./LobbyChat";
import "./PongGame.scss";
// Typy musí odpovídat server/src/pongGame.ts (stav chodí přes websocket, ne přes OpenAPI)
type PongPhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished';
type BotLevel = 'easy' | 'medium' | 'hard' | 'impossible';
type PongPlayer = { login: string, side: 0 | 1, y: number, score: number, bot?: BotLevel };
type PongState = {
phase: PongPhase,
host?: string,
players: PongPlayer[],
ball: { x: number, y: number },
countdown?: number,
winner?: string,
width: number,
height: number,
};
const OPEN_EVENT = 'luncher:open-pong';
/** Otevře okno Pongu. */
export const openPongGame = () => window.dispatchEvent(new Event(OPEN_EVENT));
// Rozměry musí odpovídat serveru
const PADDLE_HEIGHT = 90;
const PADDLE_WIDTH = 12;
const PADDLE_MARGIN = 20;
const BALL_SIZE = 12;
const SIDE_COLORS = ['#60a5fa', '#f472b6'];
/** Jak často se posílá poloha myši (ms). */
const AIM_SEND_MS = 30;
const BOT_LEVELS: { value: BotLevel, label: string }[] = [
{ value: 'easy', label: 'Lehký' },
{ value: 'medium', label: 'Střední' },
{ value: 'hard', label: 'Těžký' },
{ value: 'impossible', label: 'Nemožný' },
];
const UP_KEYS = new Set(['ArrowUp', 'KeyW']);
const DOWN_KEYS = new Set(['ArrowDown', 'KeyS']);
function draw(canvas: HTMLCanvasElement, state: PongState, me?: string) {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const scale = canvas.width / state.width;
ctx.setTransform(scale, 0, 0, scale, 0, 0);
ctx.fillStyle = '#111827';
ctx.fillRect(0, 0, state.width, state.height);
// Středová čára
ctx.fillStyle = '#374151';
for (let y = 0; y < state.height; y += 30) ctx.fillRect(state.width / 2 - 2, y + 5, 4, 18);
for (const p of state.players) {
const x = p.side === 0 ? PADDLE_MARGIN : state.width - PADDLE_MARGIN - PADDLE_WIDTH;
ctx.fillStyle = SIDE_COLORS[p.side];
ctx.fillRect(x, p.y - PADDLE_HEIGHT / 2, PADDLE_WIDTH, PADDLE_HEIGHT);
if (p.login === me) {
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.strokeRect(x - 1, p.y - PADDLE_HEIGHT / 2 - 1, PADDLE_WIDTH + 2, PADDLE_HEIGHT + 2);
}
}
if (state.phase === 'playing') {
ctx.fillStyle = '#f9fafb';
ctx.beginPath();
ctx.arc(state.ball.x, state.ball.y, BALL_SIZE / 2, 0, Math.PI * 2);
ctx.fill();
}
ctx.setTransform(1, 0, 0, 1, 0, 0);
}
/** Pong 1v1 — fyziku drží server, klient posílá jen směr pálky. */
export default function PongGame() {
const auth = useAuth();
const login = auth?.login;
const [state, setState] = useState<PongState | null>(null);
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const prevPhase = useRef<PongPhase>('idle');
const icon = useGameIcon('pong');
const touch = useTouchDevice();
const [botLevel, setBotLevel] = useState<BotLevel>('medium');
/** Výška hřiště pro přepočet myši — přes ref, ať se efekt nerestartuje každým snímkem. */
const fieldHeight = useRef(500);
fieldHeight.current = state?.height ?? fieldHeight.current;
const send = useCallback((event: string, extra?: object) => {
setError(null);
socket.emit(event, { token: getToken(), ...extra });
}, []);
useEffect(() => {
const onState = (s: PongState) => setState(s);
const onError = (msg: string) => setError(msg);
const onOpen = () => setOpen(true);
const sync = () => socket.emit('pong:sync');
socket.on('pong:state', onState);
socket.on('pong:error', onError);
socket.on('connect', sync);
window.addEventListener(OPEN_EVENT, onOpen);
sync();
return () => {
socket.off('pong:state', onState);
socket.off('pong:error', onError);
socket.off('connect', sync);
window.removeEventListener(OPEN_EVENT, onOpen);
};
}, []);
// Snímky hry dostávají jen klienti s otevřeným oknem
useEffect(() => {
if (!open) return;
const watch = () => socket.emit('pong:watch');
watch();
socket.on('connect', watch);
return () => {
socket.off('connect', watch);
socket.emit('pong:unwatch');
};
}, [open]);
const isPlayer = !!login && !!state?.players.some(p => p.login === login);
const phase = state?.phase ?? 'idle';
const inGame = phase === 'countdown' || phase === 'playing';
useEffect(() => {
if (prevPhase.current === 'lobby' && phase === 'countdown' && isPlayer) {
setOpen(true);
}
prevPhase.current = phase;
}, [phase, isPlayer]);
// Ovládání: drží se klávesa → pálka jede, puštění → stojí
useEffect(() => {
if (!open || !inGame || !isPlayer) return;
const pressed = new Set<string>();
const update = () => {
const up = [...pressed].some(k => UP_KEYS.has(k));
const down = [...pressed].some(k => DOWN_KEYS.has(k));
send('pong:input', { input: up === down ? 'stop' : up ? 'up' : 'down' });
};
const onDown = (e: KeyboardEvent) => {
if (!UP_KEYS.has(e.code) && !DOWN_KEYS.has(e.code)) return;
e.preventDefault();
if (e.repeat || pressed.has(e.code)) return;
pressed.add(e.code);
update();
};
const onUp = (e: KeyboardEvent) => {
if (!pressed.delete(e.code)) return;
update();
};
const onBlur = () => {
pressed.clear();
update();
};
window.addEventListener('keydown', onDown);
window.addEventListener('keyup', onUp);
window.addEventListener('blur', onBlur);
return () => {
window.removeEventListener('keydown', onDown);
window.removeEventListener('keyup', onUp);
window.removeEventListener('blur', onBlur);
};
}, [open, inGame, isPlayer, send]);
// Ovládání myší či prstem: pálka sleduje výšku kurzoru nad hřištěm (kdekoli v okně, mimo hřiště se ořízne)
useEffect(() => {
if (!open || !inGame || !isPlayer) return;
let latest: number | undefined;
let sent: number | undefined;
const onMove = (e: PointerEvent) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const height = fieldHeight.current;
const y = ((e.clientY - rect.top) / rect.height) * height;
latest = Math.round(Math.min(height, Math.max(0, y)));
};
const id = window.setInterval(() => {
if (latest === undefined || latest === sent) return;
sent = latest;
send('pong:aim', { y: latest });
}, AIM_SEND_MS);
// Prst nemá "hover" — pálka skočí už na dotyk, ne až na první pohyb
window.addEventListener('pointerdown', onMove);
window.addEventListener('pointermove', onMove);
return () => {
window.clearInterval(id);
window.removeEventListener('pointerdown', onMove);
window.removeEventListener('pointermove', onMove);
};
}, [open, inGame, isPlayer, send]);
useEffect(() => {
if (canvasRef.current && state && phase !== 'idle' && phase !== 'lobby') {
draw(canvasRef.current, state, login);
}
}, [state, phase, login]);
if (!login) return null;
const left = state?.players.find(p => p.side === 0);
const right = state?.players.find(p => p.side === 1);
const showInvite = !open && phase === 'lobby' && !isPlayer && (state?.players.length ?? 0) < 2;
const showReturn = !open && isPlayer && inGame;
const renderBoard = () => state && (
<>
<div className="pong-scoreboard">
<span style={{ color: SIDE_COLORS[0] }}>{left?.login}</span>
<strong>{left?.score ?? 0} : {right?.score ?? 0}</strong>
<span style={{ color: SIDE_COLORS[1] }}>{right?.login}</span>
</div>
<div className="pong-board-wrap">
<canvas ref={canvasRef} className="pong-board" width={state.width} height={state.height} />
{phase === 'countdown' && <div className="pong-overlay">{state.countdown}</div>}
{phase === 'finished' && <div className="pong-overlay small">🏆 {state.winner}</div>}
</div>
<div className="pong-status">{isPlayer ? (touch ? 'Táhněte prstem po hřišti' : 'Myš, nebo ↑↓ / W S') : 'Sledujete zápas'}</div>
</>
);
const renderBody = () => {
switch (phase) {
case 'idle':
return (
<p>
Klasický Pong pro dva. Ovládání myší (pálka sleduje kurzor), prstem po hřišti nebo šipkami / W S, hraje se
do 5 bodů. Když není s kým hrát, přidejte si v lobby bota. Ostatní se mohou dívat.
</p>
);
case 'lobby':
return (
<>
<p>Lobby založil(a) <strong>{state?.host}</strong>.</p>
<ul>{state?.players.map(p => <li key={p.login}>{p.login}{p.login === state.host && ' 👑'}</li>)}</ul>
{(state?.players.length ?? 0) < 2 && <p className="text-muted">Čeká se na soupeře…</p>}
{login === state?.host && (state?.players.length ?? 0) < 2 && (
<div className="pong-bot-picker">
<span>Nebo hrát proti botovi:</span>
<Form.Select size="sm" value={botLevel} onChange={e => setBotLevel(e.target.value as BotLevel)}>
{BOT_LEVELS.map(l => <option key={l.value} value={l.value}>{l.label}</option>)}
</Form.Select>
<Button size="sm" variant="outline-primary" onClick={() => send('pong:addBot', { level: botLevel })}>
Přidat bota
</Button>
</div>
)}
{login === state?.host && state?.players.some(p => p.bot) && (
<Button size="sm" variant="outline-secondary" onClick={() => send('pong:removeBot')}>Odebrat bota</Button>
)}
{isPlayer && <LobbyChat game="pong" />}
</>
);
default:
return renderBoard();
}
};
const renderFooter = () => {
const buttons = [];
if (phase === 'idle' || phase === 'finished') {
buttons.push(<Button key="new" onClick={() => send('pong:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>);
}
if (phase === 'lobby') {
if (!isPlayer) {
if ((state?.players.length ?? 0) < 2) {
buttons.push(<Button key="join" onClick={() => send('pong:join')}>Připojit se</Button>);
}
} else {
buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('pong:leave')}>
{login === state?.host ? 'Zrušit lobby' : 'Odejít'}
</Button>);
if (login === state?.host) {
buttons.push(<Button key="start" onClick={() => send('pong:start')}
disabled={(state?.players.length ?? 0) < 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 pong-invite" onClick={() => setOpen(true)}>
{icon} <strong>{state?.host}</strong> hledá soupeře na Pong — připojit se
</button>
)}
{showReturn && (
<button className="duel-invite pong-invite" onClick={() => setOpen(true)}>
{icon} Pong běží — zpět do hry
</button>
)}
<Modal show={open} onHide={() => setOpen(false)} size="xl" fullscreen="sm-down" className="pong-game-modal">
<Modal.Header closeButton>
<Modal.Title><h2>{icon} Pong</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>
</>
);
}
+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>
);
}
+134
View File
@@ -0,0 +1,134 @@
.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); }
}
.reaction-duel-modal {
.duel-arena {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 280px;
border-radius: 12px;
color: #fff;
user-select: none;
cursor: pointer;
touch-action: manipulation;
transition: background-color 0.05s;
&.waiting { background: #b02a37; }
&.go { background: #198754; }
&.done { background: #495057; }
&.early { background: #fd7e14; }
}
.duel-round {
opacity: 0.85;
}
.duel-signal {
font-size: 3rem;
font-weight: 700;
margin: 8px 0;
}
.duel-hint {
opacity: 0.85;
font-size: 0.9rem;
}
.duel-players {
columns: 2;
}
.duel-podium {
display: flex;
justify-content: center;
align-items: flex-end;
gap: 12px;
margin-bottom: 16px;
}
.duel-place {
flex: 0 1 160px;
padding: 12px 8px;
border-radius: 10px;
text-align: center;
background: var(--bs-tertiary-bg);
// Vítěz uprostřed a nejvýš, jako na stupních vítězů
&.place-1 { order: 2; padding-top: 28px; }
&.place-2 { order: 1; }
&.place-3 { order: 3; }
}
.duel-medal {
font-size: 2.2rem;
}
.duel-place-name {
font-weight: 700;
overflow-wrap: anywhere;
}
.duel-place-time {
opacity: 0.75;
font-size: 0.9rem;
}
.duel-last {
text-align: center;
}
.duel-results .duel-loser td {
background: rgba(220, 53, 69, 0.15);
}
}
// 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); }
}
+266
View File
@@ -0,0 +1,266 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Button, 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 { useTouchDevice } from "../hooks/useTouchDevice";
import LobbyChat from "./LobbyChat";
import "./ReactionDuel.scss";
// Typy musí odpovídat server/src/reactionDuel.ts (stav chodí přes websocket, ne přes OpenAPI)
type DuelPhase = 'idle' | 'lobby' | 'waiting' | 'go' | 'roundResult' | 'finished';
type RoundResult = { ms: number | null, falseStart?: boolean };
type DuelPlayer = { login: string, rounds: RoundResult[], total: number };
type DuelState = {
phase: DuelPhase,
host?: string,
round: number,
totalRounds: number,
players: DuelPlayer[],
loser?: string,
winner?: string,
minPlayers: number,
};
const OPEN_EVENT = 'luncher:open-duel';
/** Otevře okno reakční hry. */
export const openReactionDuel = () => window.dispatchEvent(new Event(OPEN_EVENT));
const MEDALS = ['🥇', '🥈', '🥉'];
const formatResult = (r?: RoundResult) => {
if (!r) return '—';
if (r.falseStart) return 'předčasně';
if (r.ms == null) return 'nekliknuto';
return `${r.ms} ms`;
};
/** Reakční hra — lobby, kola a výsledky; časování drží server. */
export default function ReactionDuel() {
const auth = useAuth();
const login = auth?.login;
const [state, setState] = useState<DuelState | null>(null);
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
/** Kolo, ve kterém už tento klient kliknul (a zda předčasně). */
const [myClick, setMyClick] = useState<{ round: number, early: boolean } | null>(null);
const prevPhase = useRef<DuelPhase>('idle');
const icon = useGameIcon('reaction');
const touch = useTouchDevice();
const send = useCallback((event: string) => {
setError(null);
socket.emit(event, { token: getToken() });
}, []);
useEffect(() => {
const onState = (s: DuelState) => setState(s);
const onError = (msg: string) => setError(msg);
const onOpen = () => setOpen(true);
const sync = () => socket.emit('duel:sync');
socket.on('duel:state', onState);
socket.on('duel:error', onError);
socket.on('connect', sync);
window.addEventListener(OPEN_EVENT, onOpen);
sync();
return () => {
socket.off('duel:state', onState);
socket.off('duel:error', onError);
socket.off('connect', sync);
window.removeEventListener(OPEN_EVENT, onOpen);
};
}, []);
const isPlayer = !!login && !!state?.players.some(p => p.login === login);
const phase = state?.phase ?? 'idle';
const inRound = phase === 'waiting' || phase === 'go';
// Hráčům se okno otevře samo, jakmile hra začne
useEffect(() => {
if (prevPhase.current === 'lobby' && phase === 'waiting' && isPlayer) {
setOpen(true);
}
prevPhase.current = phase;
}, [phase, isPlayer]);
const click = useCallback(() => {
if (!state || !isPlayer || !inRound || myClick?.round === state.round) return;
setMyClick({ round: state.round, early: state.phase === 'waiting' });
send('duel:click');
}, [state, isPlayer, inRound, myClick, send]);
// Mezerník funguje stejně jako kliknutí
useEffect(() => {
if (!open || !inRound) return;
const onKey = (e: KeyboardEvent) => {
if (e.code === 'Space') {
e.preventDefault();
click();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, inRound, click]);
if (!login) return null;
const showInvite = !open && phase === 'lobby' && !isPlayer;
const showReturn = !open && isPlayer && phase !== 'finished';
const clickedThisRound = myClick?.round === state?.round;
const ranked = [...(state?.players ?? [])].sort((a, b) => a.total - b.total);
const renderArena = () => {
if (!state) return null;
let cls = 'waiting';
let text = 'Počkej…';
if (phase === 'go') {
cls = 'go';
text = 'TEĎ!';
}
if (isPlayer && clickedThisRound) {
cls = myClick?.early ? 'early' : 'done';
text = myClick?.early ? 'Předčasný start! 😬' : 'Kliknuto ✔';
}
return (
<div
className={`duel-arena ${cls}`}
onPointerDown={click}
role="button"
tabIndex={0}
>
<div className="duel-round">Kolo {state.round}/{state.totalRounds}</div>
<div className="duel-signal">{text}</div>
{isPlayer
? <div className="duel-hint">{touch ? 'Ťukněte, až zezelená.' : 'Klikněte nebo zmáčkněte mezerník, až zezelená.'}</div>
: <div className="duel-hint">Sledujete hru.</div>}
</div>
);
};
const renderResults = (final: boolean) => (
<Table size="sm" className="duel-results">
<thead>
<tr>
<th>#</th>
<th>Hráč</th>
{Array.from({ length: state?.totalRounds ?? 0 }, (_, i) => <th key={i}>{i + 1}. kolo</th>)}
<th>Celkem</th>
</tr>
</thead>
<tbody>
{ranked.map((p, i) => (
<tr key={p.login} className={final && p.login === state?.loser ? 'duel-loser' : undefined}>
<td>{final && i < 3 ? MEDALS[i] : i + 1}</td>
<td>{p.login}{p.login === login && ' (vy)'}</td>
{Array.from({ length: state?.totalRounds ?? 0 }, (_, r) => <td key={r}>{formatResult(p.rounds[r])}</td>)}
<td><strong>{p.rounds.length ? `${p.total} ms` : '—'}</strong></td>
</tr>
))}
</tbody>
</Table>
);
const renderBody = () => {
switch (phase) {
case 'idle':
return (
<>
<p>
Hra má {state?.totalRounds ?? 3} kola — po náhodné chvíli se plocha rozsvítí zeleně
a počítá se, jak rychle kliknete. Předčasný start nebo nekliknutí znamená trestné 2 s.
Vyhrává nejnižší součet časů.
</p>
<p className="text-muted">Po založení lobby dostanou ostatní online pozvánku.</p>
</>
);
case 'lobby':
return (
<>
<p>Lobby založil(a) <strong>{state?.host}</strong>. Hráči ({state?.players.length}):</p>
<ul className="duel-players">
{state?.players.map(p => <li key={p.login}>{p.login}{p.login === state.host && ' 👑'}</li>)}
</ul>
{state && state.players.length < state.minPlayers &&
<p className="text-muted">Čeká se na další hráče…</p>}
{isPlayer && <LobbyChat game="duel" />}
</>
);
case 'waiting':
case 'go':
return renderArena();
case 'roundResult':
return (
<>
<p>Kolo {state?.round}/{state?.totalRounds} skončilo, další za chvíli…</p>
{renderResults(false)}
</>
);
case 'finished':
return (
<>
<div className="duel-podium">
{ranked.slice(0, 3).map((p, i) => (
<div key={p.login} className={`duel-place place-${i + 1}`}>
<div className="duel-medal">{MEDALS[i]}</div>
<div className="duel-place-name">{p.login}</div>
<div className="duel-place-time">{p.total} ms</div>
</div>
))}
</div>
{state?.loser && ranked.length > 3 &&
<p className="duel-last">🐢 Poslední: <strong>{state.loser}</strong></p>}
{renderResults(true)}
</>
);
}
};
const renderFooter = () => {
const buttons = [];
if (phase === 'idle' || phase === 'finished') {
buttons.push(<Button key="new" onClick={() => send('duel:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>);
}
if (phase === 'lobby') {
if (!isPlayer) {
buttons.push(<Button key="join" onClick={() => send('duel:join')}>Připojit se</Button>);
} else {
buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('duel:leave')}>
{login === state?.host ? 'Zrušit lobby' : 'Odejít'}
</Button>);
if (login === state?.host) {
buttons.push(<Button key="start" onClick={() => send('duel: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" onClick={() => setOpen(true)}>
{icon} <strong>{state?.host}</strong> zve na reakční hru — připojit se
</button>
)}
{showReturn && (
<button className="duel-invite" onClick={() => setOpen(true)}>
{icon} Reakční hra běží — zpět do hry
</button>
)}
<Modal show={open} onHide={() => setOpen(false)} size="lg" fullscreen="sm-down" className="reaction-duel-modal">
<Modal.Header closeButton>
<Modal.Title><h2>{icon} Reakční hra</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>
</>
);
}
+82
View File
@@ -0,0 +1,82 @@
// Pozvánka hada nad pozvánkou reakční hry, aby se nepřekrývaly
.duel-invite.snake-invite {
bottom: 68px;
}
.snake-game-modal {
.snake-board-wrap {
position: relative;
margin: 0 auto;
max-width: 768px;
}
.snake-board {
display: block;
width: 100%;
height: auto;
border-radius: 8px;
image-rendering: pixelated;
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
// Kříž šipek pro dotykové ovládání
.snake-touch-pad {
display: grid;
grid-template-areas: ". up ." "left . right" ". down .";
grid-template-columns: repeat(3, calc(var(--touch-btn-size) + 8px));
grid-template-rows: repeat(3, var(--touch-btn-size));
gap: 4px;
justify-content: center;
margin: 8px 0 16px;
.up { grid-area: up; }
.left { grid-area: left; }
.right { grid-area: right; }
.down { grid-area: down; }
}
.snake-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 5rem;
font-weight: 700;
color: #fff;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6);
pointer-events: none;
}
.snake-status {
text-align: center;
opacity: 0.75;
margin: 8px 0;
}
.snake-scores {
max-width: 768px;
margin: 0 auto;
}
.snake-dead td {
opacity: 0.5;
}
.snake-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 6px;
}
.snake-players {
columns: 2;
list-style: none;
padding-left: 0;
}
}
+361
View File
@@ -0,0 +1,361 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Button, Modal, Table } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCaretDown, faCaretLeft, faCaretRight, faCaretUp } from "@fortawesome/free-solid-svg-icons";
import { socket } from "../context/socket";
import { useAuth } from "../context/auth";
import { getToken } from "../Utils";
import { useGameIcon } from "../games/themeIcons";
import { useTouchDevice } from "../hooks/useTouchDevice";
import TouchButton from "./TouchButton";
import LobbyChat from "./LobbyChat";
import "./SnakeGame.scss";
// Typy musí odpovídat server/src/snakeGame.ts (stav chodí přes websocket, ne přes OpenAPI)
type Cell = [number, number];
type Direction = 'up' | 'down' | 'left' | 'right';
type SnakePhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished';
type SnakePlayer = { login: string, color: number, body: Cell[], alive: boolean, score: number, place?: number };
type SnakeState = {
phase: SnakePhase,
host?: string,
players: SnakePlayer[],
food: Cell[],
tick: number,
maxTicks: number,
countdown?: number,
width: number,
height: number,
minPlayers: number,
};
const OPEN_EVENT = 'luncher:open-snake';
/** Otevře okno hada. */
export const openSnakeGame = () => window.dispatchEvent(new Event(OPEN_EVENT));
const COLORS = ['#4ade80', '#60a5fa', '#f472b6', '#facc15', '#a78bfa', '#fb923c', '#2dd4bf', '#f87171'];
const MEDALS = ['🥇', '🥈', '🥉'];
const TICK_MS = 120;
const KEY_DIRECTIONS: Record<string, Direction> = {
ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right',
KeyW: 'up', KeyS: 'down', KeyA: 'left', KeyD: 'right',
};
/** Délka tahu prstem po ploše (px), od které se bere jako zatočení. */
const SWIPE_PX = 24;
/** Vykreslí hrací plochu do canvasu. */
function draw(canvas: HTMLCanvasElement, state: SnakeState, me?: string) {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const cell = canvas.width / state.width;
ctx.fillStyle = '#111827';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#1f2937';
for (let x = 0; x < state.width; x++) {
for (let y = 0; y < state.height; y++) {
if ((x + y) % 2 === 0) ctx.fillRect(x * cell, y * cell, cell, cell);
}
}
for (const [x, y] of state.food) {
ctx.fillStyle = '#ef4444';
ctx.beginPath();
ctx.arc((x + 0.5) * cell, (y + 0.5) * cell, cell * 0.35, 0, Math.PI * 2);
ctx.fill();
}
for (const p of state.players) {
const color = COLORS[p.color % COLORS.length];
// Tělo tlumeně, hlava se kreslí zvlášť až nakonec
ctx.fillStyle = color;
ctx.globalAlpha = 0.65;
p.body.slice(1).forEach(([x, y]) => ctx.fillRect(x * cell + 2, y * cell + 2, cell - 4, cell - 4));
ctx.globalAlpha = 1;
if (p.body[0]) drawHead(ctx, p.body, cell, color, p.login === me);
}
}
/** Hlava: plná barva, zaoblená, přes okraj políčka a s očima ve směru pohybu. */
function drawHead(ctx: CanvasRenderingContext2D, body: Cell[], cell: number, color: string, mine: boolean) {
const [x, y] = body[0];
const neck = body[1];
const dx = neck ? Math.sign(x - neck[0]) : 1;
const dy = neck ? Math.sign(y - neck[1]) : 0;
const cx = (x + 0.5) * cell;
const cy = (y + 0.5) * cell;
const size = cell * 1.1;
ctx.fillStyle = color;
ctx.beginPath();
ctx.roundRect(cx - size / 2, cy - size / 2, size, size, cell * 0.35);
ctx.fill();
// Vlastní had má bílý obrys hlavy, ostatní tmavý — hlava se tak odliší od těla i pozadí
ctx.strokeStyle = mine ? '#fff' : '#0b0f19';
ctx.lineWidth = 2;
ctx.stroke();
// Oči: posunuté ve směru pohybu, rozložené kolmo na něj
const forward = cell * 0.18;
const side = cell * 0.22;
for (const s of [-1, 1]) {
const ex = cx + dx * forward + -dy * side * s;
const ey = cy + dy * forward + dx * side * s;
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(ex, ey, cell * 0.14, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.arc(ex + dx * cell * 0.05, ey + dy * cell * 0.05, cell * 0.07, 0, Math.PI * 2);
ctx.fill();
}
}
/** Multiplayerový had — lobby, hrací plocha a výsledky; simulaci drží server. */
export default function SnakeGame() {
const auth = useAuth();
const login = auth?.login;
const [state, setState] = useState<SnakeState | null>(null);
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const prevPhase = useRef<SnakePhase>('idle');
const icon = useGameIcon('snake');
const touch = useTouchDevice();
/** Rozpracovaný tah prstem — bod, od kterého se měří další zatočení, a poslední směr v tomto tahu. */
const swipe = useRef<{ pointerId: number, x: number, y: number, last?: Direction } | null>(null);
const send = useCallback((event: string, extra?: object) => {
setError(null);
socket.emit(event, { token: getToken(), ...extra });
}, []);
useEffect(() => {
const onState = (s: SnakeState) => setState(s);
const onError = (msg: string) => setError(msg);
const onOpen = () => setOpen(true);
const sync = () => socket.emit('snake:sync');
socket.on('snake:state', onState);
socket.on('snake:error', onError);
socket.on('connect', sync);
window.addEventListener(OPEN_EVENT, onOpen);
sync();
return () => {
socket.off('snake:state', onState);
socket.off('snake:error', onError);
socket.off('connect', sync);
window.removeEventListener(OPEN_EVENT, onOpen);
};
}, []);
// Průběžné snímky hry dostávají jen klienti s otevřeným oknem
useEffect(() => {
if (!open) return;
const watch = () => socket.emit('snake:watch');
watch();
socket.on('connect', watch);
return () => {
socket.off('connect', watch);
socket.emit('snake:unwatch');
};
}, [open]);
const isPlayer = !!login && !!state?.players.some(p => p.login === login);
const phase = state?.phase ?? 'idle';
const inGame = phase === 'countdown' || phase === 'playing';
const controllable = open && inGame && isPlayer;
const turn = useCallback((dir: Direction) => {
if (controllable) send('snake:turn', { dir });
}, [controllable, send]);
// Hráčům se okno otevře samo, jakmile hra začne
useEffect(() => {
if (prevPhase.current === 'lobby' && phase === 'countdown' && isPlayer) {
setOpen(true);
}
prevPhase.current = phase;
}, [phase, isPlayer]);
// Ovládání šipkami a WSAD
useEffect(() => {
if (!controllable) return;
const onKey = (e: KeyboardEvent) => {
const dir = KEY_DIRECTIONS[e.code];
if (!dir) return;
e.preventDefault();
if (!e.repeat) turn(dir);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [controllable, turn]);
// Ovládání tahem prstu po ploše; v jednom tahu jde zatočit i víckrát (cik-cak)
const onBoardPointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (e.pointerType === 'mouse' || !controllable) return;
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
swipe.current = { pointerId: e.pointerId, x: e.clientX, y: e.clientY };
};
const onBoardPointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
const s = swipe.current;
if (!s || s.pointerId !== e.pointerId) return;
const dx = e.clientX - s.x;
const dy = e.clientY - s.y;
if (Math.max(Math.abs(dx), Math.abs(dy)) < SWIPE_PX) return;
const dir: Direction = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : (dy > 0 ? 'down' : 'up');
if (dir !== s.last) turn(dir);
swipe.current = { ...s, x: e.clientX, y: e.clientY, last: dir };
};
const onBoardPointerEnd = () => {
swipe.current = null;
};
useEffect(() => {
if (canvasRef.current && state && (inGame || phase === 'finished')) {
draw(canvasRef.current, state, login);
}
}, [state, inGame, phase, login]);
if (!login) return null;
const showInvite = !open && phase === 'lobby' && !isPlayer;
const showReturn = !open && isPlayer && inGame;
const ranked = [...(state?.players ?? [])].sort((a, b) => (a.place ?? 99) - (b.place ?? 99) || b.score - a.score);
const secondsLeft = state ? Math.max(0, Math.ceil((state.maxTicks - state.tick) * TICK_MS / 1000)) : 0;
const renderBoard = () => state && (
<div className="snake-board-wrap">
<canvas ref={canvasRef} className="snake-board" width={state.width * 24} height={state.height * 24}
onPointerDown={onBoardPointerDown} onPointerMove={onBoardPointerMove}
onPointerUp={onBoardPointerEnd} onPointerCancel={onBoardPointerEnd} />
{phase === 'countdown' && <div className="snake-overlay">{state.countdown}</div>}
</div>
);
const renderScores = () => (
<Table size="sm" className="snake-scores">
<thead>
<tr><th>#</th><th>Hráč</th><th>Jídla</th><th>Délka</th></tr>
</thead>
<tbody>
{ranked.map((p, i) => (
<tr key={p.login} className={p.alive || phase !== 'playing' ? undefined : 'snake-dead'}>
<td>{phase === 'finished' && i < 3 ? MEDALS[i] : (phase === 'finished' ? i + 1 : '')}</td>
<td>
<span className="snake-dot" style={{ background: COLORS[p.color % COLORS.length] }} />
{p.login}{p.login === login && ' (vy)'}{!p.alive && inGame && ' 💀'}
</td>
<td>{p.score}</td>
<td>{p.body.length || '—'}</td>
</tr>
))}
</tbody>
</Table>
);
const renderBody = () => {
switch (phase) {
case 'idle':
return (
<p>
Klasický had pro více hráčů. Ovládání šipkami nebo WSAD, na dotykovém displeji tahem prstu
po ploše nebo tlačítky. Jezte jídlo a vyhněte se zdem,
sobě i ostatním — čelní srážka zabije oba. Vyhrává ten, kdo přežije nejdéle;
hra trvá nejvýš 3 minuty. Hrát se dá i sólo.
</p>
);
case 'lobby':
return (
<>
<p>Lobby založil(a) <strong>{state?.host}</strong>. Hráči ({state?.players.length}):</p>
<ul className="snake-players">
{state?.players.map(p => (
<li key={p.login}>
<span className="snake-dot" style={{ background: COLORS[p.color % COLORS.length] }} />
{p.login}{p.login === state.host && ' 👑'}
</li>
))}
</ul>
{isPlayer && <LobbyChat game="snake" />}
</>
);
case 'countdown':
case 'playing':
return (
<>
{renderBoard()}
<div className="snake-status">
{isPlayer ? (touch ? 'Táhněte prstem po ploše' : 'Šipky / WSAD') : 'Sledujete hru'} · zbývá {secondsLeft} s
</div>
{touch && isPlayer && (
<div className="snake-touch-pad">
<TouchButton label="Nahoru" className="up" onPress={() => turn('up')}><FontAwesomeIcon icon={faCaretUp} /></TouchButton>
<TouchButton label="Doleva" className="left" onPress={() => turn('left')}><FontAwesomeIcon icon={faCaretLeft} /></TouchButton>
<TouchButton label="Doprava" className="right" onPress={() => turn('right')}><FontAwesomeIcon icon={faCaretRight} /></TouchButton>
<TouchButton label="Dolů" className="down" onPress={() => turn('down')}><FontAwesomeIcon icon={faCaretDown} /></TouchButton>
</div>
)}
{renderScores()}
</>
);
case 'finished':
return (
<>
{renderBoard()}
{renderScores()}
</>
);
}
};
const renderFooter = () => {
const buttons = [];
if (phase === 'idle' || phase === 'finished') {
buttons.push(<Button key="new" onClick={() => send('snake:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>);
}
if (phase === 'lobby') {
if (!isPlayer) {
buttons.push(<Button key="join" onClick={() => send('snake:join')}>Připojit se</Button>);
} else {
buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('snake:leave')}>
{login === state?.host ? 'Zrušit lobby' : 'Odejít'}
</Button>);
if (login === state?.host) {
buttons.push(<Button key="start" onClick={() => send('snake:start')}
disabled={(state?.players.length ?? 0) < (state?.minPlayers ?? 1)}>Spustit hru</Button>);
}
}
}
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
return buttons;
};
return (
<>
{showInvite && (
<button className="duel-invite snake-invite" onClick={() => setOpen(true)}>
{icon} <strong>{state?.host}</strong> zve na hada — připojit se
</button>
)}
{showReturn && (
<button className="duel-invite snake-invite" onClick={() => setOpen(true)}>
{icon} Had běží — zpět do hry
</button>
)}
<Modal show={open} onHide={() => setOpen(false)} size="xl" fullscreen="sm-down" className="snake-game-modal">
<Modal.Header closeButton>
<Modal.Title><h2>{icon} Had</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>
</>
);
}
+154
View File
@@ -0,0 +1,154 @@
// Pozvánka tanků nad pozvánkami ostatních her, aby se nepřekrývaly
.duel-invite.tanks-invite {
bottom: 276px;
}
.tanks-game-modal {
.tanks-board-wrap {
position: relative;
max-width: 960px;
margin: 0 auto;
}
.tanks-board {
display: block;
width: 100%;
height: auto;
border-radius: 8px;
cursor: crosshair;
}
.tanks-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 5rem;
font-weight: 700;
color: #fff;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6);
pointer-events: none;
&.small {
font-size: 2rem;
}
}
.tanks-status {
text-align: center;
opacity: 0.75;
margin: 8px 0;
}
.tanks-scores {
max-width: 960px;
margin: 0 auto;
}
.tanks-out td {
opacity: 0.5;
}
.tanks-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 6px;
}
.tanks-players {
list-style: none;
padding-left: 0;
&.two-columns {
columns: 2;
}
}
.tanks-switch {
padding: 0 6px;
line-height: 1;
text-decoration: none;
}
.tanks-teams {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.tanks-teamscore {
display: flex;
justify-content: center;
align-items: baseline;
gap: 24px;
font-size: 1.1rem;
margin-bottom: 8px;
strong {
font-size: 1.8rem;
}
}
.tanks-equipment {
text-align: center;
font-size: 1.1rem;
margin-top: 8px;
}
.tanks-items {
white-space: nowrap;
}
.tanks-pickups {
columns: 2;
list-style: none;
padding-left: 0;
}
.tanks-config {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 8px;
.form-label {
margin-bottom: 2px;
font-size: 0.85rem;
}
.form-select {
min-width: 190px;
}
}
// Volby hry pod sebou: popisek vlevo, ovládání vpravo
.tanks-options {
display: grid;
grid-template-columns: max-content 1fr;
align-items: center;
gap: 8px 16px;
margin-bottom: 12px;
.form-check {
margin-bottom: 0;
}
}
.tanks-option-label {
font-size: 0.85rem;
opacity: 0.8;
}
.tanks-bot-picker {
display: flex;
gap: 6px;
.form-select {
width: auto;
min-width: 130px;
}
}
}
+511
View File
@@ -0,0 +1,511 @@
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 {
BIOMES, BotLevel, drawTanks, FLASH_MS, MuzzleFlash, PICKUPS, PickupType, tankColor, TankMode, TankPhase,
TankPlayer, TankState, TEAM_COLORS, TEAM_NAMES,
} from "../games/tankRender";
import LobbyChat from "./LobbyChat";
import "./TankGame.scss";
const OPEN_EVENT = 'luncher:open-tanks';
/** Otevře okno tanků. */
export const openTankGame = () => window.dispatchEvent(new Event(OPEN_EVENT));
/** Jak často se posílá poloha myši (ms). */
const AIM_SEND_MS = 30;
const MAX_TEAM_SIZE = 3;
const MEDALS = ['🥇', '🥈', '🥉'];
const MODES: { value: TankMode, label: string, description: string }[] = [
{ value: 'lastman', label: 'Poslední přeživší', description: 'Každý má 3 životy, vyhrává ten, komu nějaký zbyde.' },
{ value: 'deathmatch', label: 'Na čas', description: '3 minuty, po zničení se tank vrací do hry. Vyhrává nejvíc bodů.' },
];
const BOT_LEVELS: { value: BotLevel, label: string }[] = [
{ value: 'easy', label: 'Lehký' },
{ value: 'medium', label: 'Střední' },
{ value: 'hard', label: 'Těžký' },
];
const FORWARD_KEYS = new Set(['ArrowUp', 'KeyW']);
const BACK_KEYS = new Set(['ArrowDown', 'KeyS']);
const LEFT_KEYS = new Set(['ArrowLeft', 'KeyA']);
const RIGHT_KEYS = new Set(['ArrowRight', 'KeyD']);
const FIRE_KEYS = new Set(['Space']);
const MINE_KEYS = new Set(['KeyE']);
const GAME_KEYS = new Set([...FORWARD_KEYS, ...BACK_KEYS, ...LEFT_KEYS, ...RIGHT_KEYS, ...FIRE_KEYS, ...MINE_KEYS]);
const formatTime = (ms: number) => {
const s = Math.ceil(ms / 1000);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
};
/** Aktivní efekty a munice hráče jako ikony (pro výpis výbavy a tabulku). */
function equipment(p: TankPlayer) {
const items: string[] = [];
const effect = (on: boolean, type: PickupType) => { if (on) items.push(PICKUPS[type].icon); };
effect(p.shield, 'shield');
effect(p.triple, 'triple');
effect(p.turbo, 'turbo');
effect(p.ghost, 'ghost');
effect(p.homing, 'homing');
if (p.rockets) items.push(`${PICKUPS.rocket.icon}${p.rockets}`);
if (p.mines) items.push(`${PICKUPS.mine.icon}${p.mines}`);
return items;
}
/** Tanky pro více hráčů — lobby, hrací plocha a výsledky; simulaci drží server. */
export default function TankGame() {
const auth = useAuth();
const login = auth?.login;
const [state, setState] = useState<TankState | null>(null);
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [botLevel, setBotLevel] = useState<BotLevel>('medium');
const canvasRef = useRef<HTMLCanvasElement>(null);
const prevPhase = useRef<TankPhase>('idle');
const flashes = useRef<MuzzleFlash[]>([]);
const prevShots = useRef(new Map<string, number>());
const icon = useGameIcon('tanks');
const send = useCallback((event: string, extra?: object) => {
setError(null);
socket.emit(event, { token: getToken(), ...extra });
}, []);
useEffect(() => {
const onState = (s: TankState) => setState(s);
const onError = (msg: string) => setError(msg);
const onOpen = () => setOpen(true);
const sync = () => socket.emit('tanks:sync');
socket.on('tanks:state', onState);
socket.on('tanks:error', onError);
socket.on('connect', sync);
window.addEventListener(OPEN_EVENT, onOpen);
sync();
return () => {
socket.off('tanks:state', onState);
socket.off('tanks:error', onError);
socket.off('connect', sync);
window.removeEventListener(OPEN_EVENT, onOpen);
};
}, []);
// Průběžné snímky hry dostávají jen klienti s otevřeným oknem
useEffect(() => {
if (!open) return;
const watch = () => socket.emit('tanks:watch');
watch();
socket.on('connect', watch);
return () => {
socket.off('connect', watch);
socket.emit('tanks:unwatch');
};
}, [open]);
const isPlayer = !!login && !!state?.players.some(p => p.login === login);
const isHost = !!login && login === state?.host;
const phase = state?.phase ?? 'idle';
const inGame = phase === 'countdown' || phase === 'playing';
const me = state?.players.find(p => p.login === login);
// Hráčům se okno otevře samo, jakmile hra začne
useEffect(() => {
if (prevPhase.current === 'lobby' && phase === 'countdown' && isPlayer) {
setOpen(true);
}
prevPhase.current = phase;
}, [phase, isPlayer]);
// Ovládání: držené klávesy a tlačítko myši → stav ovládání, posílá se jen při změně
useEffect(() => {
if (!open || !inGame || !isPlayer) return;
const pressed = new Set<string>();
let mouseFire = false;
let sent = '';
const any = (keys: Set<string>) => [...pressed].some(k => keys.has(k));
const update = () => {
const input = {
drive: (any(FORWARD_KEYS) ? 1 : 0) - (any(BACK_KEYS) ? 1 : 0),
turn: (any(RIGHT_KEYS) ? 1 : 0) - (any(LEFT_KEYS) ? 1 : 0),
fire: any(FIRE_KEYS) || mouseFire,
};
const key = JSON.stringify(input);
if (key === sent) return;
sent = key;
send('tanks:input', input);
};
const onDown = (e: KeyboardEvent) => {
if (!GAME_KEYS.has(e.code)) return;
// Mezerník by jinak "klikl" na tlačítko s fokusem
e.preventDefault();
if (pressed.has(e.code)) return;
pressed.add(e.code);
if (MINE_KEYS.has(e.code)) send('tanks:mine');
update();
};
const onUp = (e: KeyboardEvent) => {
if (GAME_KEYS.has(e.code)) e.preventDefault();
if (!pressed.delete(e.code)) return;
update();
};
// Levé tlačítko nad hrací plochou střílí, dokud se nepustí (puštění kdekoli); pravé položí minu
const onMouseDown = (e: MouseEvent) => {
if (e.target !== canvasRef.current) return;
if (e.button === 2) {
e.preventDefault();
send('tanks:mine');
return;
}
if (e.button !== 0) return;
e.preventDefault();
mouseFire = true;
update();
};
const onMouseUp = (e: MouseEvent) => {
if (e.button !== 0 || !mouseFire) return;
mouseFire = false;
update();
};
const onContextMenu = (e: MouseEvent) => {
if (e.target === canvasRef.current) e.preventDefault();
};
const onBlur = () => {
pressed.clear();
mouseFire = false;
update();
};
(document.activeElement as HTMLElement | null)?.blur?.();
window.addEventListener('keydown', onDown);
window.addEventListener('keyup', onUp);
window.addEventListener('mousedown', onMouseDown);
window.addEventListener('mouseup', onMouseUp);
window.addEventListener('contextmenu', onContextMenu);
window.addEventListener('blur', onBlur);
window.addEventListener('pagehide', onBlur);
return () => {
// Server si pamatuje poslední stav — bez puštění by tank jel dál (zavřené okno, pád stránky)
onBlur();
window.removeEventListener('pagehide', onBlur);
window.removeEventListener('keydown', onDown);
window.removeEventListener('keyup', onUp);
window.removeEventListener('mousedown', onMouseDown);
window.removeEventListener('mouseup', onMouseUp);
window.removeEventListener('contextmenu', onContextMenu);
window.removeEventListener('blur', onBlur);
};
}, [open, inGame, isPlayer, send]);
// Míření myší: kurzor nad hrací plochou natáčí věž, mimo ni se věž vrátí do směru jízdy
useEffect(() => {
if (!open || !inGame || !isPlayer) return;
let latest: [number, number] | null = null;
let sent = '';
const onMove = (e: PointerEvent) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
latest = inside
? [Math.round((e.clientX - rect.left) / rect.width * canvas.width), Math.round((e.clientY - rect.top) / rect.height * canvas.height)]
: null;
};
const id = window.setInterval(() => {
const key = JSON.stringify(latest);
if (key === sent) return;
sent = key;
send('tanks:aim', latest ? { x: latest[0], y: latest[1] } : { x: null });
}, AIM_SEND_MS);
window.addEventListener('pointermove', onMove);
return () => {
window.clearInterval(id);
window.removeEventListener('pointermove', onMove);
};
}, [open, inGame, isPlayer, send]);
// Záblesky výstřelů: podle změny počítadla výstřelů u hráče
useEffect(() => {
if (!state) return;
const now = performance.now();
for (const p of state.players) {
const before = prevShots.current.get(p.login);
if (before !== undefined && p.shots > before && state.phase === 'playing') {
flashes.current.push({ login: p.login, start: now });
}
prevShots.current.set(p.login, p.shots);
}
flashes.current = flashes.current.filter(f => now - f.start < FLASH_MS);
}, [state]);
useEffect(() => {
if (canvasRef.current && state && (inGame || phase === 'finished')) {
drawTanks(canvasRef.current, state, flashes.current, login);
}
}, [state, inGame, phase, login]);
if (!login) return null;
const mode = MODES.find(m => m.value === state?.mode) ?? MODES[0];
const mapOf = (id?: string) => state?.maps.find(m => m.id === id);
const mapLabel = (m?: { name: string, biome: keyof typeof BIOMES }) => m ? `${BIOMES[m.biome].icon} ${m.name}` : '🎲 Náhodná';
const mapName = mapLabel(state?.mapId === 'random' ? undefined : mapOf(state?.mapId));
const showInvite = !open && phase === 'lobby' && !isPlayer && (state?.players.length ?? 0) < (state?.maxPlayers ?? 0);
const showReturn = !open && isPlayer && inGame;
const ranked = [...(state?.players ?? [])].sort((a, b) => phase === 'finished'
? (a.place ?? 99) - (b.place ?? 99)
: state?.mode === 'lastman'
? (b.lives ?? 0) - (a.lives ?? 0) || b.kills - a.kills
: b.score - a.score || a.deaths - b.deaths);
const teamDot = (team: 0 | 1) => <span className="tanks-dot" style={{ background: TEAM_COLORS[team][0] }} />;
const winnerText = () => {
if (!state?.teams) return `🏆 ${ranked[0]?.login}`;
if (state.winnerTeam === undefined || state.winnerTeam < 0) return '🤝 Remíza';
return `🏆 Vyhrává tým ${TEAM_NAMES[state.winnerTeam]}`;
};
const renderBoard = () => state && (
<div className="tanks-layout">
{state.teams && state.teamScores && (
<div className="tanks-teamscore">
<span style={{ color: TEAM_COLORS[0][0] }}>{TEAM_NAMES[0]}</span>
<strong>{state.teamScores[0]} : {state.teamScores[1]}</strong>
<span style={{ color: TEAM_COLORS[1][0] }}>{TEAM_NAMES[1]}</span>
</div>
)}
<div className="tanks-board-wrap">
<canvas ref={canvasRef} className="tanks-board" width={state.width} height={state.height} />
{phase === 'countdown' && <div className="tanks-overlay">{state.countdown}</div>}
{phase === 'playing' && me && !me.alive && me.respawnIn !== undefined && (
<div className="tanks-overlay small">Zničen — návrat za {Math.ceil(me.respawnIn / 1000)} s</div>
)}
{phase === 'playing' && me && !me.alive && me.respawnIn === undefined && (
<div className="tanks-overlay small">Vypadli jste — sledujete hru</div>
)}
{phase === 'finished' && <div className="tanks-overlay small">{winnerText()}</div>}
</div>
{inGame && me?.alive && (
<div className="tanks-equipment">
Výbava: {equipment(me).length ? equipment(me).join(' ') : <span className="text-muted">nic — sbírejte bonusy</span>}
</div>
)}
<div className="tanks-status">
{mode.label}{state.teams && ' v týmech'}{state.bounce && ' · odrazy'}{state.hardcore && ' · 🌑 hardcore'} · {mapName}{inGame && ` · zbývá ${formatTime(state.timeLeftMs)}`}
{inGame && (isPlayer
? ' · ↑↓ / W S jízda, ←→ / A D otáčení, myš míření, klik / mezerník střelba, E / pravé tlačítko mina'
: ' · sledujete hru')}
</div>
{renderScores()}
</div>
);
const renderScores = () => (
<Table size="sm" className="tanks-scores">
<thead>
<tr>
<th>#</th>
<th>Hráč</th>
{state?.mode === 'lastman' ? <th>Životy</th> : <th>Body</th>}
<th>Zničil(a)</th>
<th>Zničen(a)</th>
</tr>
</thead>
<tbody>
{ranked.map((p, i) => (
<tr key={p.login} className={state?.mode === 'lastman' && p.lives === 0 && inGame ? 'tanks-out' : undefined}>
<td>{phase === 'finished' ? (i < 3 ? MEDALS[i] : i + 1) : ''}</td>
<td>
<span className="tanks-dot" style={{ background: tankColor(p) }} />
{p.login}{p.login === login && ' (vy)'}
{state?.teams && p.team !== undefined && <span className="text-muted"> · {TEAM_NAMES[p.team]}</span>}
{p.alive && inGame && equipment(p).length > 0 && <span className="tanks-items"> {equipment(p).join(' ')}</span>}
</td>
<td>{state?.mode === 'lastman' ? '❤️'.repeat(p.lives ?? 0) || '💀' : p.score}</td>
<td>{p.kills}</td>
<td>{p.deaths}</td>
</tr>
))}
</tbody>
</Table>
);
const renderPlayer = (p: TankPlayer) => (
<li key={p.login}>
<span className="tanks-dot" style={{ background: tankColor(p) }} />
{p.login}{p.login === state?.host && ' 👑'}
{state?.teams && (isHost || p.login === login) && (
<Button size="sm" variant="link" className="tanks-switch" title="Přesunout do druhého týmu"
onClick={() => send('tanks:team', { target: p.login })}>⇄</Button>
)}
</li>
);
const renderLobby = () => state && (
<>
<p>Lobby založil(a) <strong>{state.host}</strong>. Hráči ({state.players.length}/{state.maxPlayers}):</p>
{state.teams ? (
<div className="tanks-teams">
{([0, 1] as const).map(team => {
const members = state.players.filter(p => p.team === team);
return (
<div key={team}>
<strong>{teamDot(team)}{TEAM_NAMES[team]}</strong> ({members.length}/{MAX_TEAM_SIZE})
<ul className="tanks-players">{members.map(renderPlayer)}</ul>
</div>
);
})}
</div>
) : (
<ul className="tanks-players two-columns">{state.players.map(renderPlayer)}</ul>
)}
{isHost ? (
<>
<div className="tanks-config">
<Form.Group>
<Form.Label>Režim</Form.Label>
<Form.Select size="sm" value={state.mode} onChange={e => send('tanks:configure', { mode: e.target.value })}>
{MODES.map(m => <option key={m.value} value={m.value}>{m.label}</option>)}
</Form.Select>
</Form.Group>
<Form.Group>
<Form.Label>Mapa</Form.Label>
<Form.Select size="sm" value={state.mapId} onChange={e => send('tanks:configure', { mapId: e.target.value })}>
<option value="random">{mapLabel()}</option>
{state.maps.map(m => <option key={m.id} value={m.id}>{mapLabel(m)}</option>)}
</Form.Select>
</Form.Group>
</div>
<div className="tanks-options">
<span className="tanks-option-label">Hráči</span>
<div>
<Form.Check inline type="radio" id="tanks-solo" name="tanks-teams" label="Každý sám za sebe"
checked={!state.teams} onChange={() => send('tanks:configure', { teams: false })} />
<Form.Check inline type="radio" id="tanks-teams" name="tanks-teams" label="Dva týmy"
checked={state.teams} onChange={() => send('tanks:configure', { teams: true })} />
</div>
<span className="tanks-option-label">Střely</span>
<Form.Check type="switch" id="tanks-bounce" label="Odráží se od zdí" checked={state.bounce}
onChange={e => send('tanks:configure', { bounce: e.target.checked })} />
<span className="tanks-option-label">Hardcore</span>
<Form.Check type="switch" id="tanks-hardcore" label="🌑 Tma — vidět je jen okolí tanku" checked={state.hardcore}
onChange={e => send('tanks:configure', { hardcore: e.target.checked })} />
{(state.players.length < state.maxPlayers || state.players.some(p => p.bot)) && (
<>
<span className="tanks-option-label">Boti</span>
<div className="tanks-bot-picker">
{state.players.length < state.maxPlayers && (
<>
<Form.Select size="sm" value={botLevel} onChange={e => setBotLevel(e.target.value as BotLevel)}>
{BOT_LEVELS.map(l => <option key={l.value} value={l.value}>{l.label}</option>)}
</Form.Select>
<Button size="sm" variant="outline-primary" onClick={() => send('tanks:addBot', { level: botLevel })}>
Přidat bota
</Button>
</>
)}
{state.players.some(p => p.bot) && (
<Button size="sm" variant="outline-secondary" onClick={() => send('tanks:removeBots')}>Odebrat boty</Button>
)}
</div>
</>
)}
</div>
</>
) : (
<p>
Režim: <strong>{mode.label}{state.teams && ' v týmech'}{state.bounce && ', odrazy'}{state.hardcore && ', 🌑 hardcore'}</strong>
{' · '}mapa: <strong>{mapName}</strong>
</p>
)}
<p className="text-muted small mb-0">
{mode.description}{state.teams && ' Spoluhráči se navzájem nezraní, týmy začínají každý na své půlce mapy.'}
{state.bounce && ' Střely se odráží od zdí — pozor na vlastní!'}
{state.hardcore && ' V aréně je tma: vidíte jen okolí tanku a kužel reflektoru ve směru věže, světlo zastaví zdi a bedny.'
+ (state.teams ? ' Co osvítí spoluhráč, vidíte také.' : '') + ' Výbuchy a výstřely prosvítí i tmou.'}
</p>
{isPlayer && <LobbyChat game="tanks" />}
</>
);
const renderBody = () => {
switch (phase) {
case 'idle':
return (
<>
<p>
Tanková bitva pro více hráčů. Jezdí se šipkami nebo WSAD, věž míří za myší a střílí se
kliknutím nebo mezerníkem. Bedny se dají rozstřílet, přes vodu se nedá projet (ale dá se
přes ni střílet) a bláto, sníh, písek nebo porost zpomalují. Hraje se na poslední přeživší
nebo na čas, sólo i v týmech, volitelně s odrážením střel od zdí nebo potmě (hardcore);
když není s kým hrát, přidejte si do lobby boty.
</p>
<p className="mb-1">Bonusy:</p>
<ul className="tanks-pickups">
{Object.values(PICKUPS).map(p => <li key={p.icon}>{p.icon} {p.label}</li>)}
</ul>
</>
);
case 'lobby':
return renderLobby();
default:
return renderBoard();
}
};
const startBlocked = () => {
if (!state) return true;
if (state.players.length < state.minPlayers) return true;
return state.teams && state.minPlayers > 1 && [0, 1].some(t => !state.players.some(p => p.team === t));
};
const renderFooter = () => {
const buttons = [];
if (phase === 'idle' || phase === 'finished') {
buttons.push(<Button key="new" onClick={() => send('tanks:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>);
}
if (phase === 'lobby') {
if (!isPlayer) {
buttons.push(<Button key="join" onClick={() => send('tanks:join')}
disabled={(state?.players.length ?? 0) >= (state?.maxPlayers ?? 0)}>Připojit se</Button>);
} else {
buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('tanks:leave')}>
{isHost ? 'Zrušit lobby' : 'Odejít'}
</Button>);
if (isHost) {
buttons.push(<Button key="start" onClick={() => send('tanks:start')} disabled={startBlocked()}>Spustit hru</Button>);
}
}
}
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
return buttons;
};
return (
<>
{showInvite && (
<button className="duel-invite tanks-invite" onClick={() => setOpen(true)}>
{icon} <strong>{state?.host}</strong> zve na tanky — připojit se
</button>
)}
{showReturn && (
<button className="duel-invite tanks-invite" onClick={() => setOpen(true)}>
{icon} Tanky běží — zpět do hry
</button>
)}
<Modal show={open} onHide={() => setOpen(false)} size="xl" className="tanks-game-modal">
<Modal.Header closeButton>
<Modal.Title><h2>{icon} Tanky</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>
</>
);
}
+193
View File
@@ -0,0 +1,193 @@
// Pozvánka Tetrisu nad pozvánkami ostatních her, aby se nepřekrývaly
.duel-invite.tetris-invite {
bottom: 120px;
}
.tetris-game-modal {
.tetris-layout {
display: flex;
flex-wrap: wrap;
gap: 24px;
justify-content: center;
align-items: flex-start;
}
.tetris-own-col {
display: flex;
flex-direction: column;
gap: 12px;
}
.tetris-own {
display: flex;
gap: 16px;
// Ovládání pod deskou může být na tabletu širší než deska s postranním panelem
align-self: center;
}
.tetris-board-wrap {
position: relative;
}
.tetris-board {
display: block;
border-radius: 6px;
// Gesta prstem nesmí posouvat ani přibližovat stránku
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.tetris-touch-pad {
display: flex;
justify-content: space-between;
gap: 12px;
}
.tetris-touch-group {
display: flex;
gap: calc(var(--touch-btn-size) * 0.12);
}
.tetris-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 5rem;
font-weight: 700;
color: #fff;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6);
background: rgba(0, 0, 0, 0.25);
pointer-events: none;
&.small {
font-size: 2.2rem;
}
}
.tetris-flash {
position: absolute;
top: 8px;
left: 0;
right: 0;
text-align: center;
font-weight: 700;
color: #fca5a5;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.8);
pointer-events: none;
}
.tetris-side {
min-width: 110px;
}
.tetris-label {
font-size: 0.8rem;
text-transform: uppercase;
opacity: 0.6;
margin-top: 10px;
}
.tetris-value {
font-size: 1.4rem;
font-weight: 700;
}
.tetris-help {
margin-top: 16px;
font-size: 0.8rem;
opacity: 0.7;
line-height: 1.5;
}
.tetris-opponents {
display: flex;
flex-wrap: wrap;
gap: 12px;
max-width: 440px;
}
.tetris-mini {
text-align: center;
canvas {
display: block;
border-radius: 4px;
}
&.dead canvas {
opacity: 0.35;
}
}
.tetris-mini-name {
font-size: 0.8rem;
font-weight: 600;
max-width: 90px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tetris-mini-score {
font-size: 0.75rem;
opacity: 0.7;
}
.tetris-players {
columns: 2;
}
// Na dotykovém displeji se deska zmenší, aby se i s ovládáním vešla na výšku telefonu
@media (any-pointer: coarse) {
.tetris-own {
gap: 12px;
}
.tetris-board {
width: auto;
// Výška okna bez hlavičky, patičky, odstupů a řady tlačítek
height: clamp(280px, calc(100dvh - 278px - var(--touch-btn-size)), #{20 * 26px});
}
.tetris-side {
min-width: 90px;
}
}
@media (max-width: 575.98px) {
.modal-body {
padding: 12px;
}
.tetris-layout {
gap: 12px;
}
}
}
// Bootstrap dává oknu mezi 576 a 992 px (tablet na výšku) jen 500 px a větší dotyková tlačítka by se nevešla
@media (min-width: 576px) and (max-width: 991.98px) {
.tetris-game-modal .modal-dialog {
--bs-modal-width: 640px;
}
}
.tetris-game-modal .tetris-settings {
display: flex;
flex-wrap: wrap;
gap: 16px;
max-width: 640px;
> div {
flex: 1 1 180px;
}
.form-label {
margin-bottom: 2px;
font-weight: 600;
}
}
+663
View File
@@ -0,0 +1,663 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Button, Form, Modal, Table } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faAnglesDown, faCaretDown, faCaretLeft, faCaretRight, faRotateLeft, faRotateRight } from "@fortawesome/free-solid-svg-icons";
import { socket } from "../context/socket";
import { useAuth } from "../context/auth";
import { getToken } from "../Utils";
import { TetrisSession, WIDTH, HEIGHT, pieceCells, Piece } from "../games/tetrisEngine";
import { useGameIcon } from "../games/themeIcons";
import { useTouchDevice } from "../hooks/useTouchDevice";
import TouchButton from "./TouchButton";
import LobbyChat from "./LobbyChat";
import "./TetrisGame.scss";
// Typy musí odpovídat server/src/tetrisGame.ts (stav chodí přes websocket, ne přes OpenAPI)
type TetrisPhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished';
type TetrisPlayer = { login: string, board: string, score: number, lines: number, alive: boolean, place?: number };
type TetrisTargeting = 'random' | 'even' | 'payback';
type TetrisWinMode = 'survival' | 'score';
type TetrisSettings = { targeting: TetrisTargeting, winMode: TetrisWinMode, timeLimit: number };
type TetrisState = {
phase: TetrisPhase,
host?: string,
players: TetrisPlayer[],
seed?: number,
countdown?: number,
minPlayers: number,
settings: TetrisSettings,
remainingMs?: number,
};
type GarbageEvent = { target: string, from: string, lines: number };
const OPEN_EVENT = 'luncher:open-tetris';
const TARGETING_LABELS: Record<TetrisTargeting, { label: string, help: string }> = {
random: { label: 'Náhodně (Random)', help: 'útok jde náhodnému soupeři' },
even: { label: 'Rovnoměrně (Even)', help: 'útok jde soupeři, který zatím dostal nejméně odpadu' },
payback: { label: 'Oplácení (Payback)', help: 'útok jde tomu, kdo vás naposledy napadl, jinak rovnoměrně' },
};
const WIN_MODE_LABELS: Record<TetrisWinMode, string> = {
survival: 'Poslední ve hře (Last man standing)',
score: 'Nejvyšší skóre',
};
/** Musí odpovídat TIME_LIMITS na serveru (s). */
const TIME_LIMITS = [120, 180, 300];
const formatTime = (ms: number) => {
const total = Math.ceil(ms / 1000);
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
};
/** Otevře okno Tetrisu. */
export const openTetrisGame = () => window.dispatchEvent(new Event(OPEN_EVENT));
/** Barvy kostek I, O, T, S, Z, J, L a odpadu (index = hodnota buňky). */
const COLORS = ['', '#22d3ee', '#facc15', '#a855f7', '#22c55e', '#ef4444', '#3b82f6', '#f97316', '#6b7280'];
const MEDALS = ['🥇', '🥈', '🥉'];
const CELL = 26;
const MINI_CELL = 9;
/** Jak často se soupeřům posílá deska s padající kostkou (ms). */
const SNAPSHOT_MS = 250;
/**
* Deska se posílá i časovačem — běží i v neaktivním tabu (na rozdíl od requestAnimationFrame),
* takže server pozná, že hráč pořád hraje. Musí být výrazně kratší než PLAYER_TIMEOUT_MS na serveru.
*/
const HEARTBEAT_MS = 2000;
type Action = 'left' | 'right' | 'down' | 'rotate' | 'rotateBack' | 'drop';
const KEYS: Record<string, Action> = {
ArrowLeft: 'left', KeyA: 'left',
ArrowRight: 'right', KeyD: 'right',
ArrowDown: 'down', KeyS: 'down',
ArrowUp: 'rotate', KeyW: 'rotate', KeyX: 'rotate',
KeyZ: 'rotateBack', KeyY: 'rotateBack',
Space: 'drop',
};
/** Akce, které se opakují při podržení klávesy. */
const REPEATABLE = new Set<Action>(['left', 'right', 'down']);
// Dotyková gesta na desce: ťuknutí otočí, tažení posouvá o políčka, švihnutí dolů shodí
/** Posun prstu (px), do kterého se dotyk ještě bere jako ťuknutí. */
const TAP_SLOP_PX = 10;
/** Delší dotyk bez pohybu už není ťuknutí. */
const TAP_MAX_MS = 300;
/** Nejmenší krok tažení o jedno políčko (px) — na malé desce by jinak byl příliš citlivý. */
const MIN_STEP_PX = 14;
/** Švihnutí = pohyb dolů aspoň touto rychlostí (px/ms) za posledních FLICK_WINDOW_MS. */
const FLICK_SPEED = 0.7;
const FLICK_MIN_PX = 30;
const FLICK_WINDOW_MS = 100;
/** Rozpracované dotykové gesto na desce. */
type Gesture = {
pointerId: number,
/** Kostka, se kterou gesto začalo — když mezitím dopadne, zbytek gesta se ignoruje. */
pieceNo: number,
startX: number,
startY: number,
startTime: number,
/** Bod, od kterého se měří další krok o políčko. */
anchorX: number,
anchorY: number,
moved: boolean,
/** Poslední polohy prstu pro rychlost švihnutí. */
trail: { y: number, t: number }[],
};
function drawCell(ctx: CanvasRenderingContext2D, x: number, y: number, size: number, value: number) {
ctx.fillStyle = COLORS[value];
ctx.fillRect(x * size + 1, y * size + 1, size - 2, size - 2);
}
/** Vykreslí desku zadanou řetězcem (soupeři) nebo polem (vlastní hra). */
function drawBoard(canvas: HTMLCanvasElement, cells: ArrayLike<number | string>, size: number, piece?: Piece | null, ghost?: Piece | null) {
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.fillStyle = '#111827';
ctx.fillRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < WIDTH * HEIGHT; i++) {
const v = Number(cells[i]);
if (v) drawCell(ctx, i % WIDTH, Math.floor(i / WIDTH), size, v);
}
if (ghost) {
ctx.globalAlpha = 0.25;
pieceCells(ghost).forEach(([x, y]) => y >= 0 && drawCell(ctx, x, y, size, ghost.type + 1));
ctx.globalAlpha = 1;
}
if (piece) {
pieceCells(piece).forEach(([x, y]) => y >= 0 && drawCell(ctx, x, y, size, piece.type + 1));
}
}
/** Náhled příští kostky. */
function drawNext(canvas: HTMLCanvasElement, type: number) {
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
const cells = pieceCells({ type, rot: 0, x: 0, y: 0 });
const minX = Math.min(...cells.map(c => c[0]));
const minY = Math.min(...cells.map(c => c[1]));
cells.forEach(([x, y]) => drawCell(ctx, x - minX + 0.5, y - minY + 0.5, 20, type + 1));
}
/** Malá deska soupeře. */
function MiniBoard({ player }: { player: TetrisPlayer }) {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (ref.current) drawBoard(ref.current, player.board, MINI_CELL);
}, [player.board]);
return (
<div className={`tetris-mini ${player.alive ? '' : 'dead'}`}>
<canvas ref={ref} width={WIDTH * MINI_CELL} height={HEIGHT * MINI_CELL} />
<div className="tetris-mini-name">{player.login}{!player.alive && ' 💀'}</div>
<div className="tetris-mini-score">{player.score}</div>
</div>
);
}
/** Multiplayerový Tetris (battle) — vlastní deska běží v prohlížeči, server koordinuje hru. */
export default function TetrisGame() {
const auth = useAuth();
const login = auth?.login;
const [state, setState] = useState<TetrisState | null>(null);
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [flash, setFlash] = useState<string | null>(null);
/** Překreslení postranních údajů (skóre, řádky) po změně v session. */
const [, setVersion] = useState(0);
/** Trénink: hra jen v prohlížeči, bez serveru a soupeřů. */
const [practice, setPractice] = useState(false);
const sessionRef = useRef<TetrisSession | null>(null);
const sessionSeed = useRef<number | undefined>(undefined);
const boardRef = useRef<HTMLCanvasElement>(null);
const nextRef = useRef<HTMLCanvasElement>(null);
const prevPhase = useRef<TetrisPhase>('idle');
/** Konec hry na skóre v čase prohlížeče (ze zbývajícího času, ať nevadí rozdíl hodin se serverem). */
const deadline = useRef<number | undefined>(undefined);
const [now, setNow] = useState(() => Date.now());
const icon = useGameIcon('tetris');
const touch = useTouchDevice();
const gesture = useRef<Gesture | null>(null);
const send = useCallback((event: string, extra?: object) => {
setError(null);
socket.emit(event, { token: getToken(), ...extra });
}, []);
const sendSnapshot = useCallback(() => {
const s = sessionRef.current;
if (s) send('tetris:update', { board: s.snapshot(), score: s.score, lines: s.lines });
}, [send]);
useEffect(() => {
const onState = (s: TetrisState) => {
deadline.current = s.remainingMs !== undefined ? Date.now() + s.remainingMs : undefined;
setState(s);
};
const onError = (msg: string) => setError(msg);
const onOpen = () => setOpen(true);
const sync = () => socket.emit('tetris:sync');
socket.on('tetris:state', onState);
socket.on('tetris:error', onError);
socket.on('connect', sync);
window.addEventListener(OPEN_EVENT, onOpen);
sync();
return () => {
socket.off('tetris:state', onState);
socket.off('tetris:error', onError);
socket.off('connect', sync);
window.removeEventListener(OPEN_EVENT, onOpen);
};
}, []);
// Průběžné desky soupeřů a útoky dostávají jen klienti s otevřeným oknem
useEffect(() => {
if (!open) return;
const watch = () => socket.emit('tetris:watch');
watch();
socket.on('connect', watch);
return () => {
socket.off('connect', watch);
socket.emit('tetris:unwatch');
};
}, [open]);
useEffect(() => {
const onGarbage = (e: GarbageEvent) => {
if (e.target !== login || !sessionRef.current?.alive) return;
sessionRef.current.receiveGarbage(e.lines);
setFlash(`⚠️ +${e.lines} od ${e.from}`);
window.setTimeout(() => setFlash(null), 1500);
};
socket.on('tetris:garbage', onGarbage);
return () => { socket.off('tetris:garbage', onGarbage); };
}, [login]);
const isPlayer = !!login && !!state?.players.some(p => p.login === login);
const phase = state?.phase ?? 'idle';
const inGame = phase === 'countdown' || phase === 'playing';
const controllable = open && (practice || (phase === 'playing' && isPlayer));
// Hráčům se okno otevře samo, jakmile hra začne
useEffect(() => {
if (prevPhase.current === 'lobby' && phase === 'countdown' && isPlayer) {
setPractice(false);
setOpen(true);
}
prevPhase.current = phase;
}, [phase, isPlayer]);
// Start vlastní hry se seedem od serveru
useEffect(() => {
if (practice || phase !== 'playing' || !isPlayer || state?.seed === undefined || sessionSeed.current === state.seed) return;
sessionSeed.current = state.seed;
sessionRef.current = new TetrisSession(state.seed, {
onLock: (cleared, attack) => {
if (attack > 0) send('tetris:cleared', { count: cleared, sent: attack });
sendSnapshot();
setVersion(v => v + 1);
},
onDeath: () => {
sendSnapshot();
send('tetris:died');
setVersion(v => v + 1);
},
});
setVersion(v => v + 1);
}, [practice, phase, isPlayer, state?.seed, send, sendSnapshot]);
// Herní smyčka: gravitace, vykreslení a průběžné posílání desky (trénink se při zavřeném okně pozastaví)
useEffect(() => {
if (practice ? !open : phase !== 'playing') return;
if (!sessionRef.current) return;
let raf = 0;
let lastFall = performance.now();
let lastSnapshot = 0;
const loop = (now: number) => {
const s = sessionRef.current;
if (!s) return;
if (s.alive && now - lastFall >= s.gravityMs) {
s.tick();
lastFall = now;
}
if (!practice && s.alive && now - lastSnapshot >= SNAPSHOT_MS) {
sendSnapshot();
lastSnapshot = now;
}
if (boardRef.current) drawBoard(boardRef.current, s.board, CELL, s.active, s.ghost);
if (nextRef.current) drawNext(nextRef.current, s.nextType);
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [practice, phase, open, sendSnapshot, state?.seed]);
// Odpočet zbývajícího času hry na skóre
useEffect(() => {
if (phase !== 'playing' || state?.settings.winMode !== 'score') return;
const id = window.setInterval(() => setNow(Date.now()), 500);
return () => window.clearInterval(id);
}, [phase, state?.settings.winMode]);
// Heartbeat pro server (viz HEARTBEAT_MS)
useEffect(() => {
if (practice || phase !== 'playing' || !isPlayer) return;
const id = window.setInterval(() => {
if (sessionRef.current?.alive) sendSnapshot();
}, HEARTBEAT_MS);
return () => window.clearInterval(id);
}, [practice, phase, isPlayer, sendSnapshot]);
/** Herní akce z klávesnice i dotykového ovládání. */
const control = useCallback((action: Action) => {
const s = sessionRef.current;
if (!controllable || !s?.alive) return;
switch (action) {
case 'left': s.move(-1); break;
case 'right': s.move(1); break;
case 'down': s.softDrop(); break;
case 'rotate': s.rotate(1); break;
case 'rotateBack': s.rotate(-1); break;
case 'drop': s.hardDrop(); break;
}
}, [controllable]);
// Deska mohla zmizet s prstem na ní (konec hry, zavření okna) — rozpracované gesto se zahodí
useEffect(() => {
if (!controllable) gesture.current = null;
}, [controllable]);
// Ovládání klávesnicí
useEffect(() => {
if (!controllable) return;
const onKey = (e: KeyboardEvent) => {
const action = KEYS[e.code];
if (!action || !sessionRef.current) return;
e.preventDefault();
if (e.repeat && !REPEATABLE.has(action)) return;
control(action);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [controllable, control]);
// Po skončení hry se session zahodí, ať se příště začíná načisto
useEffect(() => {
if (!practice && (phase === 'lobby' || phase === 'idle')) {
sessionRef.current = null;
sessionSeed.current = undefined;
}
}, [practice, phase]);
const startPractice = useCallback(() => {
sessionSeed.current = undefined;
sessionRef.current = new TetrisSession(Math.floor(Math.random() * 2 ** 31), {
onLock: () => setVersion(v => v + 1),
onDeath: () => setVersion(v => v + 1),
});
setPractice(true);
setVersion(v => v + 1);
}, []);
const stopPractice = useCallback(() => {
sessionRef.current = null;
setPractice(false);
}, []);
// Dotyková gesta na desce (myš se ignoruje, aby kliknutí do desky na počítači neotáčelo kostkou)
const onBoardPointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
const s = sessionRef.current;
if (e.pointerType === 'mouse' || !controllable || !s?.alive) return;
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
gesture.current = {
pointerId: e.pointerId, pieceNo: s.pieceNo,
startX: e.clientX, startY: e.clientY, startTime: e.timeStamp,
anchorX: e.clientX, anchorY: e.clientY, moved: false,
trail: [{ y: e.clientY, t: e.timeStamp }],
};
};
const onBoardPointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
const g = gesture.current;
const s = sessionRef.current;
if (!g || g.pointerId !== e.pointerId || !s || s.pieceNo !== g.pieceNo) return;
if (Math.abs(e.clientX - g.startX) > TAP_SLOP_PX || Math.abs(e.clientY - g.startY) > TAP_SLOP_PX) g.moved = true;
const step = Math.max(MIN_STEP_PX, e.currentTarget.getBoundingClientRect().width / WIDTH);
for (; e.clientX - g.anchorX >= step; g.anchorX += step) control('right');
for (; g.anchorX - e.clientX >= step; g.anchorX -= step) control('left');
// Tažení dolů kostku nezamkne (to udělá gravitace nebo švihnutí), aby tah nepokračoval na další kostce
for (; e.clientY - g.anchorY >= step; g.anchorY += step) {
if (!s.grounded) control('down');
}
g.anchorY = Math.min(g.anchorY, e.clientY);
g.trail.push({ y: e.clientY, t: e.timeStamp });
while (e.timeStamp - g.trail[0].t > FLICK_WINDOW_MS) g.trail.shift();
};
const onBoardPointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
const g = gesture.current;
if (!g || g.pointerId !== e.pointerId) return;
gesture.current = null;
if (sessionRef.current?.pieceNo !== g.pieceNo) return;
if (!g.moved) {
if (e.timeStamp - g.startTime <= TAP_MAX_MS) control('rotate');
return;
}
const from = g.trail.find(p => e.timeStamp - p.t <= FLICK_WINDOW_MS);
if (!from || e.timeStamp <= from.t) return;
const dy = e.clientY - from.y;
if (dy >= FLICK_MIN_PX && dy / (e.timeStamp - from.t) >= FLICK_SPEED) control('drop');
};
const onBoardPointerCancel = () => {
gesture.current = null;
};
if (!login) return null;
const session = sessionRef.current;
// Připojit se jde i během odpočtu před startem
const joinable = phase === 'lobby' || phase === 'countdown';
const showInvite = !open && joinable && !isPlayer;
const showReturn = !open && isPlayer && inGame;
const others = practice ? [] : (state?.players ?? []).filter(p => !(isPlayer && p.login === login));
const ranked = [...(state?.players ?? [])].sort((a, b) => (a.place ?? 99) - (b.place ?? 99));
const settings = state?.settings;
const isHost = login === state?.host;
const remaining = !practice && phase === 'playing' && deadline.current !== undefined
? Math.max(0, deadline.current - now) : undefined;
const configure = (change: Partial<TetrisSettings>) => send('tetris:configure', change);
const renderSettings = () => settings && (
<div className="tetris-settings">
<Form.Group>
<Form.Label>Míření odpadu</Form.Label>
{isHost ? (
<Form.Select size="sm" value={settings.targeting}
onChange={e => configure({ targeting: e.target.value as TetrisTargeting })}>
{Object.entries(TARGETING_LABELS).map(([value, { label }]) => <option key={value} value={value}>{label}</option>)}
</Form.Select>
) : <div><strong>{TARGETING_LABELS[settings.targeting].label}</strong></div>}
<Form.Text>{TARGETING_LABELS[settings.targeting].help}</Form.Text>
</Form.Group>
<Form.Group>
<Form.Label>Vítěz</Form.Label>
{isHost ? (
<Form.Select size="sm" value={settings.winMode}
onChange={e => configure({ winMode: e.target.value as TetrisWinMode })}>
{Object.entries(WIN_MODE_LABELS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</Form.Select>
) : <div><strong>{WIN_MODE_LABELS[settings.winMode]}</strong></div>}
</Form.Group>
{settings.winMode === 'score' && (
<Form.Group>
<Form.Label>Délka hry</Form.Label>
{isHost ? (
<Form.Select size="sm" value={settings.timeLimit}
onChange={e => configure({ timeLimit: Number(e.target.value) })}>
{TIME_LIMITS.map(t => <option key={t} value={t}>{t / 60} min</option>)}
</Form.Select>
) : <div><strong>{settings.timeLimit / 60} min</strong></div>}
</Form.Group>
)}
</div>
);
const renderPlaying = () => (
<div className="tetris-layout">
{(isPlayer || practice) && (
<div className="tetris-own-col">
<div className="tetris-own">
<div className="tetris-board-wrap">
<canvas ref={boardRef} className="tetris-board" width={WIDTH * CELL} height={HEIGHT * CELL}
onPointerDown={onBoardPointerDown} onPointerMove={onBoardPointerMove}
onPointerUp={onBoardPointerUp} onPointerCancel={onBoardPointerCancel} />
{!practice && phase === 'countdown' && <div className="tetris-overlay">{state?.countdown}</div>}
{session && !session.alive && <div className="tetris-overlay small">Konec 💀</div>}
{flash && <div className="tetris-flash">{flash}</div>}
</div>
<div className="tetris-side">
{remaining !== undefined && (
<>
<div className="tetris-label">Zbývá</div>
<div className="tetris-value">{formatTime(remaining)}</div>
</>
)}
<div className="tetris-label">Další</div>
<canvas ref={nextRef} width={100} height={60} />
<div className="tetris-label">Skóre</div>
<div className="tetris-value">{session?.score ?? 0}</div>
<div className="tetris-label">Řádky</div>
<div className="tetris-value">{session?.lines ?? 0}</div>
<div className="tetris-label">Úroveň</div>
<div className="tetris-value">{session?.level ?? 0}</div>
<div className="tetris-help">
{touch
? <>ťuknutí otočí<br />tažení posouvá<br />švihnutí dolů shodí</>
: <>←→ / AD posun<br />↑ / W otočit<br />↓ / S dolů<br />mezerník shodit</>}
</div>
</div>
</div>
{touch && (
<div className="tetris-touch-pad">
<div className="tetris-touch-group">
<TouchButton label="Doleva" repeat onPress={() => control('left')}><FontAwesomeIcon icon={faCaretLeft} /></TouchButton>
<TouchButton label="Dolů" repeat onPress={() => control('down')}><FontAwesomeIcon icon={faCaretDown} /></TouchButton>
<TouchButton label="Doprava" repeat onPress={() => control('right')}><FontAwesomeIcon icon={faCaretRight} /></TouchButton>
</div>
<div className="tetris-touch-group">
<TouchButton label="Otočit po směru hodinových ručiček" onPress={() => control('rotateBack')}><FontAwesomeIcon icon={faRotateRight} /></TouchButton>
<TouchButton label="Otočit proti směru hodinových ručiček" onPress={() => control('rotate')}><FontAwesomeIcon icon={faRotateLeft} /></TouchButton>
<TouchButton label="Shodit" onPress={() => control('drop')}><FontAwesomeIcon icon={faAnglesDown} /></TouchButton>
</div>
</div>
)}
</div>
)}
{others.length > 0 && (
<div className="tetris-opponents">
{others.map(p => <MiniBoard key={p.login} player={p} />)}
</div>
)}
</div>
);
const renderBody = () => {
if (practice) {
return (
<>
<p className="text-muted text-center">🎯 Trénink — hra jen pro vás, nikdo ji nevidí.</p>
{renderPlaying()}
</>
);
}
switch (phase) {
case 'idle':
return (
<p>
Tetris pro více hráčů. Všichni dostávají stejné kostky; kdo smaže 2, 3 nebo 4 řádky naráz,
pošle soupeři 1, 2 nebo 4 odpadní řádky. Smazáním řádků lze zároveň odrazit odpad,
který na vás čeká. Zakladatel lobby volí, komu útoky půjdou a zda vyhrává poslední
hráč ve hře, nebo nejvyšší skóre po časovém limitu.
Hrát se dá i sólo, nebo si kdykoli zatrénovat jen pro sebe.
</p>
);
case 'lobby':
return (
<>
<p>Lobby založil(a) <strong>{state?.host}</strong>. Hráči ({state?.players.length}):</p>
<ul className="tetris-players">
{state?.players.map(p => <li key={p.login}>{p.login}{p.login === state.host && ' 👑'}</li>)}
</ul>
{state && state.players.length < state.minPlayers &&
<p className="text-muted">Čeká se na další hráče… Pro hru o samotě použijte Trénink.</p>}
{renderSettings()}
{isPlayer && <LobbyChat game="tetris" />}
</>
);
case 'countdown':
return (
<>
{!isPlayer && (
<p className="text-center">
Hra začne za <strong>{state?.countdown} s</strong> — ještě se můžete připojit.
</p>
)}
{renderPlaying()}
</>
);
case 'playing':
return renderPlaying();
case 'finished':
return (
<>
{settings && (
<p className="text-muted">
Hodnocení: {WIN_MODE_LABELS[settings.winMode]}
{settings.winMode === 'score' && ` (${settings.timeLimit / 60} min)`}
{' · '}míření: {TARGETING_LABELS[settings.targeting].label}
</p>
)}
<Table size="sm" className="tetris-results">
<thead>
<tr><th>#</th><th>Hráč</th><th>Skóre</th><th>Řádky</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>
<td>{p.lines}</td>
</tr>
))}
</tbody>
</Table>
</>
);
}
};
const renderFooter = () => {
const buttons = [];
if (practice) {
if (session && !session.alive) {
buttons.push(<Button key="again" onClick={startPractice}>Znovu</Button>);
}
buttons.push(<Button key="stop" variant="outline-secondary" onClick={stopPractice}>Ukončit trénink</Button>);
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
return buttons;
}
// Trénink jde spustit, kdykoli zrovna nehrajete online hru
if (!(isPlayer && (phase === 'lobby' || inGame))) {
buttons.push(<Button key="practice" variant="outline-primary" onClick={startPractice}>Trénink</Button>);
}
if (phase === 'idle' || phase === 'finished') {
buttons.push(<Button key="new" onClick={() => send('tetris:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>);
}
if (joinable && !isPlayer) {
buttons.push(<Button key="join" onClick={() => send('tetris:join')}>Připojit se</Button>);
}
if (phase === 'lobby' && isPlayer) {
buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('tetris:leave')}>
{login === state?.host ? 'Zrušit lobby' : 'Odejít'}
</Button>);
if (login === state?.host) {
buttons.push(<Button key="start" onClick={() => send('tetris: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 tetris-invite" onClick={() => setOpen(true)}>
{icon} {phase === 'countdown'
? <>Tetris začíná za <strong>{state?.countdown} s</strong> — připojit se</>
: <><strong>{state?.host}</strong> zve na Tetris — připojit se</>}
</button>
)}
{showReturn && (
<button className="duel-invite tetris-invite" onClick={() => setOpen(true)}>
{icon} Tetris běží — zpět do hry
</button>
)}
<Modal show={open} onHide={() => setOpen(false)} size="xl" fullscreen="sm-down" className="tetris-game-modal">
<Modal.Header closeButton>
<Modal.Title><h2>{icon} Tetris</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>
</>
);
}
+37
View File
@@ -0,0 +1,37 @@
// Velikost dotykových tlačítek — podle ní se řídí i rozložení ovládání v jednotlivých hrách
:root {
--touch-btn-size: 52px;
}
// Tablet (na rozdíl od telefonu na šířku je vysoký i široký) — na velkém displeji jsou malá tlačítka špatně trefitelná
@media (any-pointer: coarse) and (min-width: 700px) and (min-height: 600px) {
:root {
--touch-btn-size: 76px;
}
}
// Dotyková tlačítka miniher — bez zoomu dvojitým ťuknutím, výběru textu a kontextové nabídky při podržení
.touch-btn {
display: flex;
align-items: center;
justify-content: center;
min-width: var(--touch-btn-size);
height: var(--touch-btn-size);
padding: 0 10px;
border: 1px solid var(--luncher-border, rgba(127, 127, 127, 0.4));
border-radius: calc(var(--touch-btn-size) * 0.23);
background: var(--bs-tertiary-bg);
color: var(--luncher-text, inherit);
font-size: calc(var(--touch-btn-size) * 0.45);
line-height: 1;
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
&:active {
background: var(--luncher-primary, #198754);
color: #fff;
}
}
+52
View File
@@ -0,0 +1,52 @@
import { ReactNode, useEffect, useRef } from "react";
import "./TouchButton.scss";
/** Prodleva před opakováním a interval opakování při podržení tlačítka (ms), podobně jako u klávesnice. */
const REPEAT_DELAY_MS = 170;
const REPEAT_MS = 50;
type Props = {
label: string,
onPress: () => void,
/** Při podržení se akce opakuje (posun, pád). */
repeat?: boolean,
className?: string,
children: ReactNode,
};
/**
* Tlačítko dotykového ovládání miniher. Reaguje hned na dotyk (ne až na click) a je mimo pořadí
* fokusu, aby mezerník nebo šipky z klávesnice nespouštěly tlačítko místo hry.
*/
export default function TouchButton({ label, onPress, repeat, className, children }: Props) {
const timer = useRef<number | undefined>(undefined);
const onPressRef = useRef(onPress);
onPressRef.current = onPress;
const stop = () => {
window.clearTimeout(timer.current);
timer.current = undefined;
};
useEffect(() => stop, []);
const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
stop();
onPressRef.current();
if (!repeat) return;
const tick = () => {
onPressRef.current();
timer.current = window.setTimeout(tick, REPEAT_MS);
};
timer.current = window.setTimeout(tick, REPEAT_DELAY_MS);
};
return (
<button type="button" tabIndex={-1} aria-label={label} className={`touch-btn ${className ?? ''}`}
onPointerDown={onPointerDown} onPointerUp={stop} onPointerCancel={stop} onLostPointerCapture={stop}
onContextMenu={e => e.preventDefault()}>
{children}
</button>
);
}
+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,
);
}
@@ -0,0 +1,79 @@
import { useState } from "react";
import { Modal, Button, Form } from "react-bootstrap";
type Props = {
isOpen: boolean;
onClose: () => void;
onSubmit: (title: string, description: string) => Promise<void>;
};
/** Modální dialog pro přidání nového návrhu na vylepšení. */
export default function AddSuggestionModal({ isOpen, onClose, onSubmit }: Readonly<Props>) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [submitting, setSubmitting] = useState(false);
const reset = () => {
setTitle("");
setDescription("");
};
const handleClose = () => {
reset();
onClose();
};
const handleSubmit = async () => {
if (!title.trim() || !description.trim()) return;
setSubmitting(true);
try {
await onSubmit(title.trim(), description.trim());
reset();
onClose();
} finally {
setSubmitting(false);
}
};
return (
<Modal show={isOpen} onHide={handleClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Nový návrh na vylepšení</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
<Form.Group className="mb-3">
<Form.Label>Název</Form.Label>
<Form.Control
type="text"
placeholder="Stručný název návrhu"
value={title}
maxLength={120}
onChange={e => setTitle(e.target.value)}
onKeyDown={e => e.stopPropagation()}
autoFocus
/>
<Form.Text className="text-muted">Krátký, výstižný název navrhované úpravy.</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Popis</Form.Label>
<Form.Control
as="textarea"
rows={5}
placeholder="Detailní popis navrhované úpravy, řešení apod."
value={description}
onChange={e => setDescription(e.target.value)}
onKeyDown={e => e.stopPropagation()}
/>
</Form.Group>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={handleClose}>
Storno
</Button>
<Button onClick={handleSubmit} disabled={submitting || !title.trim() || !description.trim()}>
Přidat
</Button>
</Modal.Footer>
</Modal>
);
}
@@ -0,0 +1,174 @@
import { useState } from "react";
import { Modal, Button, Form, Alert, Badge } from "react-bootstrap";
import {
OrderGroup,
simulateBoltTracking, advanceBoltTracking, setBoltTrackingState,
pollBoltTracking, stopBoltTrackingSimulation,
} from "../../../../types";
type Props = {
isOpen: boolean;
onClose: () => void;
group: OrderGroup;
};
/** 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 }[] = [
{ key: 'accepted', label: 'Přijato', order_state: 'accepted' },
{ key: 'preparing', label: 'Příprava', order_state: 'preparing' },
{ key: 'waiting_delivery', label: 'Příprava (čeká na vyzvednutí)', order_state: 'waiting_delivery' },
{ key: 'pickup', label: 'Vyzvedávání', order_state: 'waiting_delivery', courier_state: 'arrived_to_provider' },
{ key: 'in_delivery', label: 'Na cestě', order_state: 'in_delivery', courier_state: 'heading_to_client' },
{ key: 'delivered', label: 'Doručeno', order_state: 'delivered' },
{ key: 'cancelled', label: 'Zrušeno', order_state: 'cancelled' },
];
/** Modální dialog pro simulaci sledování Bolt objednávky (pouze DEV). */
export default function BoltSimulationModal({ isOpen, onClose, group }: Readonly<Props>) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [manualKey, setManualKey] = useState<string>('preparing');
const running = !!group.trackingCode;
/** Obecný runner — spustí akci, ošetří chybu a krátce zobrazí výsledek. */
const run = async (action: () => Promise<{ error?: unknown }>, okMsg: string) => {
setError(null);
setInfo(null);
setLoading(true);
try {
const response = await action();
if (response.error) {
setError((response.error as any)?.error || 'Akce simulace selhala');
} else {
setInfo(okMsg);
}
} catch (e: any) {
setError(e?.message || 'Akce simulace selhala');
} finally {
setLoading(false);
}
};
const handleStart = () => run(
() => simulateBoltTracking({ body: { groupId: group.id } }),
'Simulace spuštěna — objednávka přijata.',
);
const handleAdvance = () => run(
() => advanceBoltTracking({ body: { groupId: group.id } }),
'Posunuto na další krok.',
);
const handleSetState = () => {
const opt = STATE_OPTIONS.find(o => o.key === manualKey);
if (!opt) return;
return run(
() => setBoltTrackingState({
body: {
groupId: group.id,
order_state: opt.order_state,
courier_state: opt.courier_state,
},
}),
`Stav nastaven na „${opt.label}".`,
);
};
const handlePoll = () => run(
() => pollBoltTracking(),
'Scheduler spuštěn (poll proběhl).',
);
const handleStop = () => run(
() => stopBoltTrackingSimulation({ body: { groupId: group.id } }),
'Simulace ukončena.',
);
const handleClose = () => {
setError(null);
setInfo(null);
onClose();
};
return (
<Modal show={isOpen} onHide={handleClose}>
<Modal.Header closeButton>
<Modal.Title><h2>Simulace sledování Bolt</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
<Alert variant="warning">
<strong>DEV režim</strong> – simuluje sledování objednávky Bolt pro skupinu{' '}
<strong>{group.name}</strong> bez reálné objednávky.
</Alert>
{error && (
<Alert variant="danger" onClose={() => setError(null)} dismissible>
{error}
</Alert>
)}
{info && (
<Alert variant="success" onClose={() => setInfo(null)} dismissible>
{info}
</Alert>
)}
<p className="mb-2">
Stav simulace:{' '}
{running
? <Badge bg="success">běží{group.trackingOrderState ? ` — ${group.trackingOrderState}` : ''}</Badge>
: <Badge bg="secondary">neběží</Badge>}
</p>
{!running ? (
<p className="text-muted">
Spuštěním se skupina přepne do stavu „Objednáno", přiřadí se simulovaný
sledovací token a provede se první poll (stav „Přijato").
</p>
) : (
<>
<p className="text-muted">
Krokuj objednávku tlačítkem <strong>Další krok</strong> (Přijato → Příprava →
Vyzvedávání → Na cestě → Doručeno) nebo nastav konkrétní stav ručně:
</p>
<Form.Group className="mb-3 d-flex gap-2 align-items-end">
<div className="flex-grow-1">
<Form.Label>Konkrétní stav</Form.Label>
<Form.Select
value={manualKey}
onChange={e => setManualKey(e.target.value)}
>
{STATE_OPTIONS.map(o => (
<option key={o.key} value={o.key}>{o.label}</option>
))}
</Form.Select>
</div>
<Button variant="outline-primary" onClick={handleSetState} disabled={loading}>
Nastavit
</Button>
</Form.Group>
</>
)}
</Modal.Body>
<Modal.Footer className="d-flex flex-wrap gap-2">
{!running ? (
<Button variant="primary" onClick={handleStart} disabled={loading}>
{loading ? 'Spouštím…' : 'Spustit simulaci'}
</Button>
) : (
<>
<Button variant="success" onClick={handleAdvance} disabled={loading}>
Další krok
</Button>
<Button variant="outline-secondary" onClick={handlePoll} disabled={loading}>
Aktualizovat teď
</Button>
<Button variant="outline-danger" onClick={handleStop} disabled={loading}>
Ukončit simulaci
</Button>
</>
)}
<Button variant="secondary" onClick={handleClose} disabled={loading}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
);
}
@@ -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>
);
}
@@ -0,0 +1,134 @@
import { useState } from "react";
import { Modal, Button, Form, Alert } from "react-bootstrap";
import { clearMockData, DayIndex } from "../../../../types";
type Props = {
isOpen: boolean;
onClose: () => void;
currentDayIndex?: number;
};
const DAY_NAMES = ['Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek'];
/** 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). */
export default function ClearMockDataModal({ isOpen, onClose, currentDayIndex }: Readonly<Props>) {
const [daySelection, setDaySelection] = useState<string>(currentDayIndex !== undefined ? String(currentDayIndex) : '');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const wholeWeek = daySelection === WHOLE_WEEK;
const handleClear = async () => {
setError(null);
setLoading(true);
try {
const body: any = {};
if (wholeWeek) {
body.wholeWeek = true;
} else if (daySelection !== '') {
body.dayIndex = parseInt(daySelection, 10) as DayIndex;
}
const response = await clearMockData({ body });
if (response.error) {
setError((response.error as any).error || 'Nastala chyba při mazání dat');
} else {
setSuccess(true);
setTimeout(() => {
onClose();
setSuccess(false);
}, 1500);
}
} catch (e: any) {
setError(e.message || 'Nastala chyba při mazání dat');
} finally {
setLoading(false);
}
};
const handleClose = () => {
setError(null);
setSuccess(false);
onClose();
};
// 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 (
<Modal show={isOpen} onHide={handleClose}>
<Modal.Header closeButton>
<Modal.Title><h2>Smazat mock data</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{success ? (
<Alert variant="success">
Data byla úspěšně smazána!
</Alert>
) : (
<>
<Alert variant="warning">
<strong>DEV režim</strong> - Tato funkce je dostupná pouze ve vývojovém prostředí.
</Alert>
{error && (
<Alert variant="danger" onClose={() => setError(null)} dismissible>
{error}
</Alert>
)}
<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>
Opravdu chcete smazat všechny volby stravování pro <strong>{scopeLabel}</strong>?
</p>
<p className="text-muted">
Tato akce je nevratná.
</p>
</>
)}
</Modal.Body>
<Modal.Footer>
{!success && (
<>
<Button variant="secondary" onClick={handleClose} disabled={loading}>
Ne, zrušit
</Button>
<Button variant="danger" onClick={handleClear} disabled={loading}>
{loading ? 'Mažu...' : 'Ano, smazat'}
</Button>
</>
)}
{success && (
<Button variant="secondary" onClick={handleClose}>
Zavřít
</Button>
)}
</Modal.Footer>
</Modal>
);
}
@@ -0,0 +1,26 @@
import { Modal, Button } from "react-bootstrap";
type Props = {
isOpen: boolean;
title: string;
message: string;
confirmLabel?: string;
confirmVariant?: string;
onConfirm: () => void;
onClose: () => void;
};
export default function ConfirmModal({ isOpen, title, message, confirmLabel = "Potvrdit", confirmVariant = "primary", onConfirm, onClose }: Readonly<Props>) {
return (
<Modal show={isOpen} onHide={onClose}>
<Modal.Header closeButton>
<Modal.Title>{title}</Modal.Title>
</Modal.Header>
<Modal.Body>{message}</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>Zrušit</Button>
<Button variant={confirmVariant} onClick={onConfirm}>{confirmLabel}</Button>
</Modal.Footer>
</Modal>
);
}
@@ -0,0 +1,193 @@
import { useState, useEffect } from "react";
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
import { updateGroupFees, OrderGroup, OrderGroupMember } from "../../../../types";
import { computeFeeShare, computeMemberTotal, countActiveMembers, isActiveMember } from "../../utils/groupFees";
type Props = {
isOpen: boolean;
onClose: () => void;
group: OrderGroup;
onSaved: (data: any) => void;
};
function parseHal(s: string): number {
const n = parseFloat(s.replace(',', '.'));
return isNaN(n) || n < 0 ? 0 : Math.round(n * 100);
}
function parsePercent(s: string): number {
const n = parseFloat(s.replace(',', '.'));
return isNaN(n) || n < 0 ? 0 : Math.round(n);
}
export default function EditGroupFeesModal({ isOpen, onClose, group, onSaved }: Readonly<Props>) {
const [fees, setFees] = useState('');
const [shipping, setShipping] = useState('');
const [tip, setTip] = useState('');
const [discountType, setDiscountType] = useState<'percent' | 'fixed'>('percent');
const [discountValue, setDiscountValue] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!isOpen) return;
setFees(group.fees ? String(group.fees / 100) : '');
setShipping(group.shipping ? String(group.shipping / 100) : '');
setTip(group.tip ? String(group.tip / 100) : '');
setDiscountType((group.discountType as 'percent' | 'fixed') ?? 'percent');
setDiscountValue(group.discountValue
? ((group.discountType as string) === 'fixed' ? String(group.discountValue / 100) : String(group.discountValue))
: '');
setError(null);
}, [isOpen, group]);
const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][];
// Poplatky se dělí jen mezi aktivní strávníky (kdo si reálně něco objednal).
const activeCount = countActiveMembers(group.members);
const feesNum = parseHal(fees);
const shippingNum = parseHal(shipping);
const tipNum = parseHal(tip);
const discountNum = discountType === 'percent' ? parsePercent(discountValue) : parseHal(discountValue);
const totalFees = feesNum + shippingNum + tipNum;
const feeShare = computeFeeShare(totalFees, activeCount);
const feeParams = { totalFees, discountType, discountValue: discountNum };
const handleSave = async () => {
setError(null);
setLoading(true);
try {
const res = await updateGroupFees({
body: {
id: group.id,
fees: feesNum,
shipping: shippingNum,
tip: tipNum,
discountType: discountNum > 0 ? discountType : undefined,
discountValue: discountNum > 0 ? discountNum : undefined,
}
});
if (res.error) {
setError((res.error as any).error || 'Nastala chyba');
} else {
onSaved(res.data);
onClose();
}
} catch (e: any) {
setError(e.message || 'Nastala chyba');
} finally {
setLoading(false);
}
};
return (
<Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Poplatky skupiny — {group.name}</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{error && (
<Alert variant="danger" onClose={() => setError(null)} dismissible>{error}</Alert>
)}
<div className="d-flex gap-3 flex-wrap mb-3">
<Form.Group>
<Form.Label>Poplatky (Kč)</Form.Label>
<Form.Control
type="number" min={0} step={0.01}
value={fees} onChange={e => setFees(e.target.value)}
placeholder="0" style={{ width: 110 }}
onKeyDown={e => e.stopPropagation()}
/>
</Form.Group>
<Form.Group>
<Form.Label>Doprava (Kč)</Form.Label>
<Form.Control
type="number" min={0} step={0.01}
value={shipping} onChange={e => setShipping(e.target.value)}
placeholder="0" style={{ width: 110 }}
onKeyDown={e => e.stopPropagation()}
/>
</Form.Group>
<Form.Group>
<Form.Label>Spropitné (Kč)</Form.Label>
<Form.Control
type="number" min={0} step={0.01}
value={tip} onChange={e => setTip(e.target.value)}
placeholder="0" style={{ width: 110 }}
onKeyDown={e => e.stopPropagation()}
/>
</Form.Group>
</div>
<div className="d-flex gap-3 align-items-end flex-wrap mb-3">
<Form.Group>
<Form.Label>Sleva</Form.Label>
<div className="d-flex gap-2 align-items-center">
<Form.Select
value={discountType}
onChange={e => setDiscountType(e.target.value as 'percent' | 'fixed')}
style={{ width: 160 }}
>
<option value="percent">Procentuální (%)</option>
<option value="fixed">Pevná částka (Kč)</option>
</Form.Select>
<Form.Control
type="number" min={0} step={discountType === 'percent' ? 1 : 0.01}
value={discountValue} onChange={e => setDiscountValue(e.target.value)}
placeholder="0" style={{ width: 100 }}
onKeyDown={e => e.stopPropagation()}
/>
<span className="text-muted">{discountType === 'percent' ? '%' : 'Kč'}</span>
</div>
</Form.Group>
</div>
<hr />
<h6>Náhled celkových částek ({activeCount} {activeCount === 1 ? 'strávník' : 'strávníků'} s objednávkou, {feeShare > 0 ? `poplatek ${feeShare / 100} Kč/os.` : 'bez poplatku'})</h6>
<Table size="sm" bordered>
<thead>
<tr>
<th>Člen</th>
<th className="text-end">Základ</th>
<th className="text-end">Příplatek</th>
<th className="text-end">Poplatek</th>
<th className="text-end">Sleva</th>
<th className="text-end fw-bold">Celkem</th>
</tr>
</thead>
<tbody>
{memberEntries.map(([login, member]) => {
const base = member.amount ?? 0;
const surcharge = member.surchargeAmount ?? 0;
const active = isActiveMember(member);
const total = computeMemberTotal(member, feeParams, feeShare, activeCount);
// Sleva i poplatek se týkají jen aktivních strávníků.
const discount = active && discountNum > 0
? (discountType === 'percent'
? Math.round((base + surcharge) * discountNum / 100)
: Math.round(discountNum / activeCount))
: 0;
return (
<tr key={login} className={active ? '' : 'text-muted'}>
<td><strong>{login}</strong>{!active && <small className="ms-1">(jen objednává)</small>}</td>
<td className="text-end">{base > 0 ? `${base / 100} Kč` : '—'}</td>
<td className="text-end">{surcharge > 0 ? `${surcharge / 100} Kč` : '—'}</td>
<td className="text-end">{active && feeShare > 0 ? `${feeShare / 100} Kč` : '—'}</td>
<td className="text-end text-danger">{discount > 0 ? `-${discount / 100} Kč` : '—'}</td>
<td className="text-end fw-bold">{total > 0 ? `${total / 100} Kč` : '—'}</td>
</tr>
);
})}
</tbody>
</Table>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose} disabled={loading}>Storno</Button>
<Button variant="primary" onClick={handleSave} disabled={loading}>
{loading ? 'Ukládám...' : 'Uložit'}
</Button>
</Modal.Footer>
</Modal>
);
}
@@ -1,45 +0,0 @@
import { Modal, Button, Form } from "react-bootstrap"
import { FeatureRequest } from "../../../../types";
type Props = {
isOpen: boolean,
onClose: () => void,
onChange: (option: FeatureRequest, active: boolean) => void,
initialValues?: FeatureRequest[],
}
/** Modální dialog pro hlasování o nových funkcích. */
export default function FeaturesVotingModal({ isOpen, onClose, onChange, initialValues }: Props) {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.currentTarget.value as FeatureRequest, e.currentTarget.checked);
}
return <Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title>
Hlasujte pro nové funkce
<p style={{ fontSize: '12px' }}>Je možno vybrat maximálně 4 možnosti</p>
</Modal.Title>
</Modal.Header>
<Modal.Body>
{(Object.keys(FeatureRequest) as Array<keyof typeof FeatureRequest>).map(key => {
return <Form.Check
key={key}
type='checkbox'
id={key}
label={FeatureRequest[key]}
onChange={handleChange}
value={key}
defaultChecked={initialValues && initialValues.includes(key as FeatureRequest)}
/>
})}
<p className="mt-3" style={{ fontSize: '12px' }}>Něco jiného? Dejte vědět.</p>
</Modal.Body>
<Modal.Footer>
<Button variant="primary" onClick={onClose}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
}
@@ -0,0 +1,153 @@
import { useState } from "react";
import { Modal, Button, Form, Alert } from "react-bootstrap";
import { generateMockData, DayIndex } from "../../../../types";
type Props = {
isOpen: boolean;
onClose: () => void;
currentDayIndex?: number;
};
const DAY_NAMES = ['Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek'];
/** 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). */
export default function GenerateMockDataModal({ isOpen, onClose, currentDayIndex }: Readonly<Props>) {
const [daySelection, setDaySelection] = useState<string>(currentDayIndex !== undefined ? String(currentDayIndex) : '');
const [count, setCount] = useState<string>('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const wholeWeek = daySelection === WHOLE_WEEK;
const handleGenerate = async () => {
setError(null);
setLoading(true);
try {
const body: any = {};
if (wholeWeek) {
body.wholeWeek = true;
} else if (daySelection !== '') {
body.dayIndex = parseInt(daySelection, 10) as DayIndex;
}
if (count && count.trim() !== '') {
const countNum = parseInt(count, 10);
if (isNaN(countNum) || countNum < 1 || countNum > 100) {
setError('Počet musí být číslo mezi 1 a 100');
setLoading(false);
return;
}
body.count = countNum;
}
const response = await generateMockData({ body });
if (response.error) {
setError((response.error as any).error || 'Nastala chyba při generování dat');
} else {
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(() => {
onClose();
setSuccess(null);
setCount('');
}, 1500);
}
} catch (e: any) {
setError(e.message || 'Nastala chyba při generování dat');
} finally {
setLoading(false);
}
};
const handleClose = () => {
setError(null);
setSuccess(null);
setCount('');
onClose();
};
return (
<Modal show={isOpen} onHide={handleClose}>
<Modal.Header closeButton>
<Modal.Title><h2>Generovat mock data</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{success ? (
<Alert variant="success">
{success}
</Alert>
) : (
<>
<Alert variant="warning">
<strong>DEV režim</strong> - Tato funkce je dostupná pouze ve vývojovém prostředí.
</Alert>
{error && (
<Alert variant="danger" onClose={() => setError(null)} dismissible>
{error}
</Alert>
)}
<Form.Group className="mb-3">
<Form.Label>Den</Form.Label>
<Form.Select
value={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.Text className="text-muted">
Pokud není vybráno, použije se aktuální den.
</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Počet záznamů</Form.Label>
<Form.Control
type="number"
placeholder="Náhodný (5-20)"
value={count}
onChange={e => setCount(e.target.value)}
min={1}
max={100}
onKeyDown={e => e.stopPropagation()}
/>
<Form.Text className="text-muted">
{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.Group>
</>
)}
</Modal.Body>
<Modal.Footer>
{!success && (
<>
<Button variant="secondary" onClick={handleClose} disabled={loading}>
Storno
</Button>
<Button variant="primary" onClick={handleGenerate} disabled={loading}>
{loading ? 'Generuji...' : 'Generovat'}
</Button>
</>
)}
{success && (
<Button variant="secondary" onClick={handleClose}>
Zavřít
</Button>
)}
</Modal.Footer>
</Modal>
);
}
@@ -0,0 +1,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>
);
}
@@ -0,0 +1,255 @@
import { useState, useEffect, useCallback } from "react";
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
import { generateQr, LunchChoices, QrRecipient } from "../../../../types";
type UserEntry = {
login: string;
selected: boolean;
purpose: string;
amount: string;
};
type Props = {
isOpen: boolean;
onClose: () => void;
choices: LunchChoices;
bankAccount: string;
bankAccountHolder: string;
};
/** Modální dialog pro generování QR kódů pro platbu. */
export default function GenerateQrModal({ isOpen, onClose, choices, bankAccount, bankAccountHolder }: Readonly<Props>) {
const [users, setUsers] = useState<UserEntry[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
// Při otevření modálu načteme seznam uživatelů z choices
useEffect(() => {
if (isOpen && choices) {
const userLogins = new Set<string>();
// Projdeme všechny lokace a získáme unikátní loginy
Object.values(choices).forEach(locationChoices => {
if (locationChoices) {
Object.keys(locationChoices).forEach(login => {
userLogins.add(login);
});
}
});
// Vytvoříme seznam uživatelů
const userList: UserEntry[] = Array.from(userLogins)
.sort((a, b) => a.localeCompare(b, 'cs'))
.map(login => ({
login,
selected: false,
purpose: '',
amount: '',
}));
setUsers(userList);
setError(null);
setSuccess(false);
}
}, [isOpen, choices]);
const handleCheckboxChange = useCallback((login: string, checked: boolean) => {
setUsers(prev => prev.map(u =>
u.login === login ? { ...u, selected: checked } : u
));
}, []);
const handlePurposeChange = useCallback((login: string, value: string) => {
setUsers(prev => prev.map(u =>
u.login === login ? { ...u, purpose: value } : u
));
}, []);
const handleAmountChange = useCallback((login: string, value: string) => {
// Povolíme pouze čísla, tečku a čárku
const sanitized = value.replace(/[^0-9.,]/g, '').replace(',', '.');
setUsers(prev => prev.map(u =>
u.login === login ? { ...u, amount: sanitized } : u
));
}, []);
const validateAmount = (amountStr: string): number | null => {
if (!amountStr || amountStr.trim().length === 0) {
return null;
}
const amount = parseFloat(amountStr);
if (isNaN(amount) || amount <= 0) {
return null;
}
// Max 2 desetinná místa
const parts = amountStr.split('.');
if (parts.length === 2 && parts[1].length > 2) {
return null;
}
return Math.round(amount * 100) / 100; // Zaokrouhlíme na 2 desetinná místa
};
const handleGenerate = async () => {
setError(null);
const selectedUsers = users.filter(u => u.selected);
if (selectedUsers.length === 0) {
setError("Nebyl vybrán žádný uživatel");
return;
}
// Validace
const recipients: QrRecipient[] = [];
for (const user of selectedUsers) {
if (!user.purpose || user.purpose.trim().length === 0) {
setError(`Uživatel ${user.login} nemá vyplněný účel platby`);
return;
}
const amount = validateAmount(user.amount);
if (amount === null) {
setError(`Uživatel ${user.login} má neplatnou částku (musí být kladné číslo s max. 2 desetinnými místy)`);
return;
}
recipients.push({
login: user.login,
purpose: user.purpose.trim(),
amount,
});
}
setLoading(true);
try {
const response = await generateQr({
body: {
recipients,
bankAccount,
bankAccountHolder,
}
});
if (response.error) {
setError((response.error as any).error || 'Nastala chyba při generování QR kódů');
} else {
setSuccess(true);
// Po 2 sekundách zavřeme modal
setTimeout(() => {
onClose();
}, 2000);
}
} catch (e: any) {
setError(e.message || 'Nastala chyba při generování QR kódů');
} finally {
setLoading(false);
}
};
const handleClose = () => {
setError(null);
setSuccess(false);
onClose();
};
const selectedCount = users.filter(u => u.selected).length;
return (
<Modal show={isOpen} onHide={handleClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Generování QR kódů</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{success ? (
<Alert variant="success">
QR kódy byly úspěšně vygenerovány! Uživatelé je uvidí v sekci "Nevyřízené platby".
</Alert>
) : (
<>
<p>
Vyberte uživatele, kterým chcete vygenerovat QR kód pro platbu.
QR kódy se uživatelům zobrazí v sekci "Nevyřízené platby".
</p>
{error && (
<Alert variant="danger" onClose={() => setError(null)} dismissible>
{error}
</Alert>
)}
{users.length === 0 ? (
<Alert variant="info">
V tento den nemá žádný uživatel zvolenou možnost stravování.
</Alert>
) : (
<Table striped bordered hover responsive>
<thead>
<tr>
<th style={{ width: '50px' }}></th>
<th>Uživatel</th>
<th>Účel platby</th>
<th style={{ width: '120px' }}>Částka (Kč)</th>
</tr>
</thead>
<tbody>
{users.map(user => (
<tr key={user.login} className={user.selected ? '' : 'text-muted'}>
<td className="text-center">
<Form.Check
type="checkbox"
checked={user.selected}
onChange={e => handleCheckboxChange(user.login, e.target.checked)}
/>
</td>
<td>{user.login}</td>
<td>
<Form.Control
type="text"
placeholder="např. Pizza prosciutto"
value={user.purpose}
onChange={e => handlePurposeChange(user.login, e.target.value)}
disabled={!user.selected}
size="sm"
onKeyDown={e => e.stopPropagation()}
/>
</td>
<td>
<Form.Control
type="text"
placeholder="0.00"
value={user.amount}
onChange={e => handleAmountChange(user.login, e.target.value)}
disabled={!user.selected}
size="sm"
onKeyDown={e => e.stopPropagation()}
/>
</td>
</tr>
))}
</tbody>
</Table>
)}
</>
)}
</Modal.Body>
<Modal.Footer>
{!success && (
<>
<span className="me-auto text-muted">
Vybráno: {selectedCount} / {users.length}
</span>
<Button variant="secondary" onClick={handleClose} disabled={loading}>
Storno
</Button>
<Button
variant="primary"
onClick={handleGenerate}
disabled={loading || selectedCount === 0}
>
{loading ? 'Generuji...' : 'Generovat'}
</Button>
</>
)}
{success && (
<Button variant="secondary" onClick={handleClose}>
Zavřít
</Button>
)}
</Modal.Footer>
</Modal>
);
}
+1 -1
View File
@@ -8,7 +8,7 @@ type Props = {
} }
/** Modální dialog pro úpravu obecné poznámky. */ /** Modální dialog pro úpravu obecné poznámky. */
export default function NoteModal({ isOpen, onClose, onSave }: Props) { export default function NoteModal({ isOpen, onClose, onSave }: Readonly<Props>) {
const note = useRef<HTMLInputElement>(null); const note = useRef<HTMLInputElement>(null);
const save = () => { const save = () => {
@@ -0,0 +1,306 @@
import { useState, useEffect, useCallback } from "react";
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
import { generateQr, LunchChoice, LocationLunchChoicesMap, RestaurantDayMenu, QrRecipient } from "../../../../types";
import { parsePriceCzk } from "../../utils/parsePrice";
type DinerEntry = {
login: string;
selectedFoods: number[];
baseAmount: number;
baseAmountParseFailed: boolean;
surchargeText: string;
surchargeAmount: string;
included: boolean;
};
type Props = {
isOpen: boolean;
onClose: () => void;
locationKey: LunchChoice;
locationName: string;
locationChoices: LocationLunchChoicesMap;
menu: RestaurantDayMenu | undefined;
payerLogin: string;
bankAccount: string;
bankAccountHolder: string;
};
function sanitizeAmount(value: string): string {
return value.replace(/[^0-9.,]/g, '').replace(',', '.');
}
function parseAmount(s: string): number | null {
if (!s || s.trim().length === 0) return null;
const n = parseFloat(s);
if (isNaN(n) || n < 0) return null;
return Math.round(n * 100);
}
export default function PayForAllModal({ isOpen, onClose, locationName, locationChoices, menu, payerLogin, bankAccount, bankAccountHolder }: Readonly<Props>) {
const [diners, setDiners] = useState<DinerEntry[]>([]);
const [tipTotal, setTipTotal] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const hasMenu = !!menu;
useEffect(() => {
if (!isOpen) return;
const entries: DinerEntry[] = Object.entries(locationChoices).map(([login, choice]) => {
const selectedFoods = choice.selectedFoods ?? [];
// 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;
if (menu && choice.price == null) {
for (const idx of selectedFoods) {
const priceKc = parsePriceCzk(menu.food?.[idx]?.price);
if (priceKc === null) {
baseAmountParseFailed = true;
} else {
baseAmount += Math.round(priceKc * 100);
}
}
}
return {
login,
selectedFoods,
baseAmount,
baseAmountParseFailed,
surchargeText: '',
surchargeAmount: '',
included: login !== payerLogin,
};
});
setDiners(entries);
setTipTotal('');
setError(null);
setSuccess(false);
}, [isOpen, locationChoices, menu, payerLogin]);
const includedDiners = diners.filter(d => d.included && d.login !== payerLogin);
const tipPerPerson = (() => {
if (includedDiners.length === 0) return 0;
const tip = parseAmount(tipTotal);
if (tip === null || tip === 0) return 0;
const totalPeople = includedDiners.length + 1;
return Math.round(tip / totalPeople);
})();
const payerTipShare = (() => {
const tip = parseAmount(tipTotal);
if (!tip) return 0;
return tip - tipPerPerson * includedDiners.length;
})();
const getTotal = (d: DinerEntry): number => {
const surcharge = parseAmount(d.surchargeAmount) ?? 0;
const tip = d.login === payerLogin ? payerTipShare : tipPerPerson;
return d.baseAmount + surcharge + tip;
};
const handleInclude = useCallback((login: string, checked: boolean) => {
setDiners(prev => prev.map(d => d.login === login ? { ...d, included: checked } : d));
}, []);
const handleSurchargeText = useCallback((login: string, value: string) => {
setDiners(prev => prev.map(d => d.login === login ? { ...d, surchargeText: value } : d));
}, []);
const handleSurchargeAmount = useCallback((login: string, value: string) => {
setDiners(prev => prev.map(d => d.login === login ? { ...d, surchargeAmount: sanitizeAmount(value) } : d));
}, []);
const handleGenerate = async () => {
setError(null);
const recipients: QrRecipient[] = [];
for (const d of diners) {
if (!d.included || d.login === payerLogin) continue;
const total = getTotal(d);
if (total <= 0) {
setError(`Celková částka pro ${d.login} musí být kladná`);
return;
}
const foods = d.selectedFoods.map(i => menu?.food?.[i]?.name).filter(Boolean).join(', ');
const purposeBase = `Oběd ${locationName}${foods ? ` — ${foods}` : ''}`;
recipients.push({
login: d.login,
purpose: purposeBase.substring(0, 60),
amount: total,
});
}
if (recipients.length === 0) {
setError("Nebyl vybrán žádný příjemce");
return;
}
setLoading(true);
try {
const response = await generateQr({
body: { recipients, bankAccount, bankAccountHolder },
});
if (response.error) {
setError((response.error as any).error || 'Nastala chyba při generování QR kódů');
} else {
setSuccess(true);
setTimeout(() => onClose(), 2000);
}
} catch (e: any) {
setError(e.message || 'Nastala chyba při generování QR kódů');
} finally {
setLoading(false);
}
};
const anyParseFailed = diners.some(d => d.baseAmountParseFailed && d.included);
return (
<Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Zaplatit za všechny — {locationName}</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{success ? (
<Alert variant="success">
QR kódy byly úspěšně vygenerovány! Uživatelé je uvidí v sekci „Nevyřízené platby".
</Alert>
) : (
<>
<p>Zaplatili jste za skupinu v restauraci. Nastavte příplatky a společné poplatky, poté vygenerujte QR kódy pro ostatní.</p>
{!hasMenu && !diners.some(d => d.baseAmount > 0) && (
<Alert variant="info">
Pro tuto skupinu nejsou k dispozici ceny jídel — vyplňte příplatky ručně.
</Alert>
)}
{anyParseFailed && (
<Alert variant="warning">
U některých jídel se nepodařilo načíst cenu — doplňte ji ručně v sloupci Příplatek.
</Alert>
)}
{error && (
<Alert variant="danger" onClose={() => setError(null)} dismissible>
{error}
</Alert>
)}
<Table striped bordered hover responsive size="sm">
<thead>
<tr>
<th style={{ width: 40 }}></th>
<th>Strávník</th>
<th>Jídla</th>
<th style={{ width: 220 }}>Příplatek</th>
<th style={{ width: 90 }}>Poplatek</th>
<th style={{ width: 90 }}>Celkem</th>
</tr>
</thead>
<tbody>
{diners.map(d => {
const isPayer = d.login === payerLogin;
const foodNames = d.selectedFoods.map(i => menu?.food?.[i]?.name).filter(Boolean).join(', ');
const total = getTotal(d);
return (
<tr key={d.login} className={!d.included && !isPayer ? 'text-muted' : ''}>
<td className="text-center">
{isPayer ? (
<small className="text-muted">plátce</small>
) : (
<Form.Check
type="checkbox"
checked={d.included}
onChange={e => handleInclude(d.login, e.target.checked)}
/>
)}
</td>
<td><strong>{d.login}</strong></td>
<td>
<small>
{foodNames || <span className="text-muted">—</span>}
{d.baseAmount > 0 && <span className="text-muted"> ({d.baseAmount / 100} Kč)</span>}
{d.baseAmountParseFailed && <span className="text-warning"> ⚠</span>}
</small>
</td>
<td>
<div className="d-flex gap-1">
<Form.Control
type="text"
placeholder="popis"
value={d.surchargeText}
onChange={e => handleSurchargeText(d.login, e.target.value)}
disabled={!isPayer && !d.included}
size="sm"
onKeyDown={e => e.stopPropagation()}
/>
<Form.Control
type="text"
placeholder="Kč"
value={d.surchargeAmount}
onChange={e => handleSurchargeAmount(d.login, e.target.value)}
disabled={!isPayer && !d.included}
size="sm"
style={{ width: 70 }}
onKeyDown={e => e.stopPropagation()}
/>
</div>
</td>
<td className="text-end">
{(() => { const s = isPayer ? payerTipShare : tipPerPerson; return s > 0 ? `${s / 100} Kč` : '—'; })()}
</td>
<td className="text-end fw-bold">
{`${total / 100} Kč`}
</td>
</tr>
);
})}
</tbody>
</Table>
<div className="d-flex align-items-center gap-2 mt-2">
<label className="mb-0 text-nowrap">Poplatky celkem (Kč):</label>
<Form.Control
type="text"
placeholder="0"
value={tipTotal}
onChange={e => setTipTotal(sanitizeAmount(e.target.value))}
size="sm"
style={{ width: 100 }}
onKeyDown={e => e.stopPropagation()}
/>
<small className="text-muted">
{includedDiners.length > 0 && tipPerPerson > 0
? `(${tipPerPerson / 100} Kč / osoba)`
: ''}
</small>
</div>
</>
)}
</Modal.Body>
<Modal.Footer>
{!success && (
<>
<span className="me-auto text-muted">
Příjemci: {includedDiners.length}
</span>
<Button variant="secondary" onClick={onClose} disabled={loading}>
Storno
</Button>
<Button
variant="primary"
onClick={handleGenerate}
disabled={loading || includedDiners.length === 0}
>
{loading ? 'Generuji...' : 'Vygenerovat QR'}
</Button>
</>
)}
{success && (
<Button variant="secondary" onClick={onClose}>Zavřít</Button>
)}
</Modal.Footer>
</Modal>
);
}
Loaded 100 of 412 files, more files were not shown because too many files have changed in this diff. Show more